From 4c0a35ccb84279065f7a5e5f841a8b80b8cf5123 Mon Sep 17 00:00:00 2001 From: Rafael Pereira Ramos Date: Mon, 29 Sep 2025 11:28:41 -0300 Subject: [PATCH 1/2] commit huble --- huble/README.md | 109 +++++++++++++++++++++++++++++ huble/__init__.py | 11 +++ huble/core.py | 135 ++++++++++++++++++++++++++++++++++++ huble/httpcycle/chalice.py | 85 +++++++++++++++++++++++ huble/httpcycle/django.py | 61 ++++++++++++++++ huble/httpcycle/fast_api.py | 68 ++++++++++++++++++ huble/logging/filter.py | 12 ++++ tracker/providers/logger.py | 7 +- 8 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 huble/README.md create mode 100644 huble/__init__.py create mode 100644 huble/core.py create mode 100644 huble/httpcycle/chalice.py create mode 100644 huble/httpcycle/django.py create mode 100644 huble/httpcycle/fast_api.py create mode 100644 huble/logging/filter.py diff --git a/huble/README.md b/huble/README.md new file mode 100644 index 0000000..28ed616 --- /dev/null +++ b/huble/README.md @@ -0,0 +1,109 @@ +# Exemplos de ligação — FastAPI, Django/DRF e Chalice + + +--- + +# FastAPI + +```python +# main.py +from fastapi import FastAPI +from huble import setup +from huble.httpcycle.fastapi import ( + HubleTraceMiddleware, + RequestResponseLoggingMiddleware, # opcional +) + +setup() # ativa patches httpx/requests + filtro de logging com trace_id + +app = FastAPI() + +# entrada/saída do X-HUBLE-TRACE-ID +app.add_middleware(HubleTraceMiddleware) + +# (opcional) log de request/response +# app.add_middleware( +# RequestResponseLoggingMiddleware, +# logger=None, # ou passe um logger configurado +# redact_headers={"authorization","cookie","x-api-key"}, +# ) + +@app.get("/health") +def health(): + return {"ok": True} +``` + +--- + +# Django / DRF + +**asgi.py** (ou **wsgi.py**) — bootstrap cedo: +```python +# asgi.py +from huble import setup +setup() + +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "seu_projeto.settings") +application = get_asgi_application() +``` + +**settings.py** — ligar middlewares: +```python +MIDDLEWARE = [ + "huble.httpcycle.django.HubleTraceMiddleware", # garante X-HUBLE-TRACE-ID em entrada/saída + # "huble.httpcycle.django.RequestResponseLoggingMiddleware", # opcional: log de request/response + # ... seus outros middlewares +] + +# Em dev/compose, evite DisallowedHost +# ALLOWED_HOSTS = ["*", "localhost", "djangoapi"] +``` + +--- + +# Chalice + +## Opção A) Auto-wrap global (todas as rotas) +```python +# app.py +from chalice import Chalice +from huble import setup +from huble.httpcycle.chalice import enable_autowrap + +setup() + +app = Chalice(app_name="api") +enable_autowrap( + app, + logger=None, # ou passe um logger + log_request_response=True, # opcional + redact_headers={"authorization","cookie"}, +) + +@app.route("/gamma") +def gamma(): + return {"ok": True} +``` + +## Opção B) Decorator por rota +```python +# app.py +from chalice import Chalice +from huble import setup +from huble.httpcycle.chalice import with_huble_trace + +setup() + +app = Chalice(app_name="api") + +@app.route("/gamma") +@with_huble_trace(log_request_response=True, redact_headers={"authorization","cookie"}) +def gamma(): + return {"ok": True} +``` + +> Em todos os casos acima, o `setup()` garante que **requisições HTTP de saída** (`httpx`/`requests`) carreguem o `X-HUBLE-TRACE-ID` e que **todos os logs** recebam `trace_id`. +> Os middlewares/decorator cuidam de **capturar/gerar** o header na entrada e **devolver** o mesmo na resposta. diff --git a/huble/__init__.py b/huble/__init__.py new file mode 100644 index 0000000..9ba7625 --- /dev/null +++ b/huble/__init__.py @@ -0,0 +1,11 @@ +from .core import ( + CANON_TRACE_HEADER, + get_current_trace_id, + set_current_trace_id, + reset_current_trace_id, + get_trace_id_if_any, + get_or_create_trace_id, + ensure_current_trace_id, + install_outbound_patches, + setup, +) diff --git a/huble/core.py b/huble/core.py new file mode 100644 index 0000000..048413e --- /dev/null +++ b/huble/core.py @@ -0,0 +1,135 @@ +import uuid +from contextvars import ContextVar +from typing import Optional, Mapping, Any + +CANON_TRACE_HEADER = "X-HUBLE-TRACE-ID" + +_current_trace_id: ContextVar[Optional[str]] = ContextVar("x_huble_trace_id", default=None) + +def get_current_trace_id() -> Optional[str]: + return _current_trace_id.get() + +def set_current_trace_id(trace_id: str): + return _current_trace_id.set(trace_id) + +def reset_current_trace_id(token) -> None: + try: + _current_trace_id.reset(token) + except Exception: + pass + +def _ci_get(headers: Mapping[str, Any], name: str) -> Optional[str]: + nl = name.lower() + for k, v in headers.items(): + if str(k).lower() == nl: + return v + return None + +def _normalize_uuid(raw: str) -> Optional[str]: + if not raw: + return None + s = raw.strip() + try: + return str(uuid.UUID(s)) + except Exception: + try: + return str(uuid.UUID(hex=s)) + except Exception: + return None + +def get_trace_id_if_any(headers: Mapping[str, Any]) -> Optional[str]: + raw = _ci_get(headers, CANON_TRACE_HEADER) + return _normalize_uuid(raw) if raw else None + +def get_or_create_trace_id(headers: Mapping[str, Any]) -> tuple[str, str]: + raw = _ci_get(headers, CANON_TRACE_HEADER) + if raw: + norm = _normalize_uuid(raw) + if norm: + return norm, CANON_TRACE_HEADER.lower() + return str(uuid.uuid4()), "generated" + +_PATCHED = False +def install_outbound_patches(header_name: str = CANON_TRACE_HEADER) -> None: + global _PATCHED + if _PATCHED: + return + _PATCHED = True + + def _ci_has(h: Mapping[str, Any], name: str) -> bool: + nl = name.lower() + return any(str(k).lower() == nl for k in h.keys()) + + try: + import httpx + _orig_client_request = httpx.Client.request + _orig_async_client_request = httpx.AsyncClient.request + _orig_client_send = httpx.Client.send + _orig_async_client_send = httpx.AsyncClient.send + + def _inject_headers_dict(headers): + tid = get_current_trace_id() + if not tid: + return headers + base = dict(headers or {}) + if not _ci_has(base, header_name): + base[header_name] = tid + return base + + def _client_request(self, method, url, *args, headers=None, **kwargs): + headers = _inject_headers_dict(headers) + return _orig_client_request(self, method, url, *args, headers=headers, **kwargs) + + async def _async_client_request(self, method, url, *args, headers=None, **kwargs): + headers = _inject_headers_dict(headers) + return await _orig_async_client_request(self, method, url, *args, headers=headers, **kwargs) + + def _client_send(self, request, *args, **kwargs): + tid = get_current_trace_id() + if tid and header_name not in request.headers: + request.headers[header_name] = tid + return _orig_client_send(self, request, *args, **kwargs) + + async def _async_client_send(self, request, *args, **kwargs): + tid = get_current_trace_id() + if tid and header_name not in request.headers: + request.headers[header_name] = tid + return await _orig_async_client_send(self, request, *args, **kwargs) + + httpx.Client.request = _client_request + httpx.AsyncClient.request = _async_client_request + httpx.Client.send = _client_send + httpx.AsyncClient.send = _async_client_send + except Exception: + pass + + try: + import requests + _orig_session_request = requests.sessions.Session.request + def _session_request(self, method, url, **kwargs): + tid = get_current_trace_id() + if tid: + hdrs = dict(kwargs.get("headers") or {}) + if not _ci_has(hdrs, header_name): + hdrs[header_name] = tid + kwargs["headers"] = hdrs + return _orig_session_request(self, method, url, **kwargs) + requests.sessions.Session.request = _session_request + except Exception: + pass + + +def ensure_current_trace_id() -> str: + """Garante que exista um trace_id na ContextVar e retorna-o.""" + tid = get_current_trace_id() + if tid: + return tid + new = str(uuid.uuid4()) + set_current_trace_id(new) + return new + +def setup() -> None: + """Atalhos para ligar cabeçalho outbound e filtro de logging.""" + from .logging.filter import HubleTraceFilter + install_outbound_patches() + logging.getLogger().addFilter(HubleTraceFilter()) \ No newline at end of file diff --git a/huble/httpcycle/chalice.py b/huble/httpcycle/chalice.py new file mode 100644 index 0000000..ddb3691 --- /dev/null +++ b/huble/httpcycle/chalice.py @@ -0,0 +1,85 @@ +import time +from functools import wraps +from typing import Optional, Callable, Dict, Any +from ..core import ( + CANON_TRACE_HEADER, + get_trace_id_if_any, + set_current_trace_id, + reset_current_trace_id, + ensure_current_trace_id, +) + +def with_huble_trace(view_func: Callable = None, *, logger=None, log_request_response: bool = False, redact_headers: Optional[set[str]] = None): + """Decorator para rotas Chalice: captura/gera trace, devolve header e (opcional) loga request/response.""" + if redact_headers is None: + redact_headers = set() + + def decorator(fn: Callable): + @wraps(fn) + def wrapper(*args, **kwargs): + from chalice import current_request, Response + + incoming = get_trace_id_if_any(current_request.headers or {}) + token = set_current_trace_id(incoming or ensure_current_trace_id()) + + try: + t0 = time.perf_counter() + result = fn(*args, **kwargs) + + if not isinstance(result, Response): + from chalice import Response as ChaliceResponse + resp = ChaliceResponse( + body=result, + status_code=200, + headers={CANON_TRACE_HEADER: ensure_current_trace_id()}, + ) + else: + + result.headers = dict(result.headers or {}) + result.headers[CANON_TRACE_HEADER] = ensure_current_trace_id() + resp = result + + if log_request_response and logger: + try: + def _clean(headers) -> Dict[str, str]: + out = {} + for k, v in (headers or {}).items(): + kl = str(k).lower() + out[kl] = "***REDACTED***" if kl in redact_headers else str(v) + return out + dt = (time.perf_counter() - t0) * 1000.0 + logger.info("http_request", extra={ + "method": current_request.method, + "path": current_request.context.get("resourcePath", current_request.path), + "status": resp.status_code, + "req_headers": _clean(current_request.headers), + "res_headers": _clean(resp.headers), + "time_taken_ms": round(dt, 2), + }) + except Exception: + pass + + return resp + finally: + reset_current_trace_id(token) + return wrapper + return decorator(view_func) if view_func else decorator + + +def enable_autowrap(app, *, logger=None, log_request_response: bool = False, redact_headers: Optional[set[str]] = None): + """ + Monkeypatch de app.route para auto-aplicar @with_huble_trace em TODAS as rotas + definidas depois desta chamada. Chame ANTES de declarar @app.route(...). + """ + original_route = app.route + + def route_wrapper(*r_args, **r_kwargs): + def registrar(fn): + wrapped = with_huble_trace( + fn, logger=logger, log_request_response=log_request_response, redact_headers=redact_headers + ) + return original_route(*r_args, **r_kwargs)(wrapped) + return registrar + + app.route = route_wrapper + return app diff --git a/huble/httpcycle/django.py b/huble/httpcycle/django.py new file mode 100644 index 0000000..aa4ee8e --- /dev/null +++ b/huble/httpcycle/django.py @@ -0,0 +1,61 @@ +import time +from typing import Optional, Dict +from django.utils.deprecation import MiddlewareMixin +from ..core import ( + CANON_TRACE_HEADER, + get_trace_id_if_any, + set_current_trace_id, + reset_current_trace_id, + ensure_current_trace_id, +) + +class HubleTraceMiddleware(MiddlewareMixin): + """Garante entrada/saída do X-HUBLE-TRACE-ID no ciclo Django/DRF.""" + def process_request(self, request): + incoming = get_trace_id_if_any(request.headers) + self._token = set_current_trace_id(incoming or ensure_current_trace_id()) + + def process_response(self, request, response): + try: + tid = ensure_current_trace_id() + response[CANON_TRACE_HEADER] = tid + finally: + token = getattr(self, "_token", None) + if token is not None: + reset_current_trace_id(token) + return response + + +class RequestResponseLoggingMiddleware(MiddlewareMixin): + def __init__(self, get_response=None, *, logger=None, redact_headers: Optional[set[str]] = None): + super().__init__(get_response) + self.logger = logger + self.redact_headers = {h.lower() for h in (redact_headers or set())} + + def process_request(self, request): + self._t0 = time.perf_counter() + ensure_current_trace_id() + + def process_response(self, request, response): + dt = (time.perf_counter() - getattr(self, "_t0", time.perf_counter())) * 1000.0 + def _clean(django_headers) -> Dict[str, str]: + out = {} + for k, v in django_headers.items(): + kl = str(k).lower() + out[kl] = "***REDACTED***" if kl in self.redact_headers else str(v) + return out + + if self.logger: + try: + path = request.get_full_path() if hasattr(request, "get_full_path") else request.path + self.logger.info("http_request", extra={ + "method": request.method, + "path": path, + "status": getattr(response, "status_code", None), + "req_headers": _clean(request.headers), + "res_headers": _clean(getattr(response, "headers", {})), + "time_taken_ms": round(dt, 2), + }) + except Exception: + pass + return response diff --git a/huble/httpcycle/fast_api.py b/huble/httpcycle/fast_api.py new file mode 100644 index 0000000..57bd85a --- /dev/null +++ b/huble/httpcycle/fast_api.py @@ -0,0 +1,68 @@ +import time +from typing import Callable, Dict, Any, Optional +from uuid import uuid4 + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from ...huble.core import ( + CANON_TRACE_HEADER, get_trace_id_if_any, + set_current_trace_id, reset_current_trace_id, ensure_current_trace_id +) + +class HubleTraceMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Callable): + incoming = get_trace_id_if_any(request.headers) + trace_id = incoming or str(uuid4()) + token = set_current_trace_id(trace_id) + try: + response: Response = await call_next(request) + finally: + reset_current_trace_id(token) + response.headers[CANON_TRACE_HEADER] = trace_id + return response + +class RequestResponseLoggingMiddleware(BaseHTTPMiddleware): + def __init__(self, app, logger, redact_headers: Optional[set[str]] = None): + super().__init__(app) + self.logger = logger + self.redact_headers = {h.lower() for h in (redact_headers or set())} + + async def dispatch(self, request: Request, call_next: Callable): + trace_id = ensure_current_trace_id() + + t0 = time.perf_counter() + try: + response: Response = await call_next(request) + status = response.status_code + except Exception as exc: + status = 500 + self.logger.exception("unhandled_exception", extra={ + "trace_id": trace_id, + "path": request.url.path, + "method": request.method, + "reason": str(exc), + }) + raise + finally: + dt = time.perf_counter() - t0 + + def _clean(h) -> Dict[str, str]: + out: Dict[str, str] = {} + for k, v in h.items(): + kl = k.lower() + out[kl] = "***REDACTED***" if kl in self.redact_headers else str(v) + return out + + self.logger.info("http_request", + extra={ + "trace_id": trace_id, + "method": request.method, + "path": request.url.path, + "client_ip": (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() or (request.client.host if request.client else None), + "req_headers": _clean(request.headers), + "res_headers": _clean(response.headers), + "status": status, + "time_taken_ms": round(dt * 1000, 2), + }, + ) + return response diff --git a/huble/logging/filter.py b/huble/logging/filter.py new file mode 100644 index 0000000..cd7fff9 --- /dev/null +++ b/huble/logging/filter.py @@ -0,0 +1,12 @@ +import logging +import uuid +from ..core import get_current_trace_id, set_current_trace_id + +class HubleTraceFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + tid = get_current_trace_id() + if not tid: + tid = str(uuid.uuid4()) + set_current_trace_id(tid) + setattr(record, "trace_id", tid) + return True diff --git a/tracker/providers/logger.py b/tracker/providers/logger.py index 1ce116a..f52820d 100644 --- a/tracker/providers/logger.py +++ b/tracker/providers/logger.py @@ -12,6 +12,8 @@ ) from ..types import Contexts, Tags +from huble.core import ensure_current_trace_id + _logger_tags = ContextVar("logger_tags", default=default_dict) _logger_contexts = ContextVar("logger_contexts", default=default_dict) @@ -81,8 +83,9 @@ def capture_exception(self, tracker_exception: TrackerException): extra = { "tags": _logger_tags.get(), "contexts": _logger_contexts.get(), + "trace_id": ensure_current_trace_id(), } - + if tracker_exception.tags: extra["tags"].update(tracker_exception.tags) @@ -107,7 +110,7 @@ def set_contexts(self, contexts: Contexts): self.core.set_contexts(contexts) def capture_event(self, tracker_event: TrackerEvent): - extra = {"tags": _logger_tags.get(), "contexts": _logger_contexts.get()} + extra = {"tags": _logger_tags.get(), "contexts": _logger_contexts.get(), "trace_id": ensure_current_trace_id()} if tracker_event.tags: extra["tags"].update(tracker_event.tags) From 80266e1df35678193741e1762d5a2a410f7dbb00 Mon Sep 17 00:00:00 2001 From: Rafael Pereira Ramos Date: Fri, 3 Oct 2025 09:25:31 -0300 Subject: [PATCH 2/2] modularizacao dos clientes e ajustes em geral --- huble/README.md | 109 --------- huble/__init__.py | 11 - huble/core.py | 135 ----------- huble/httpcycle/chalice.py | 85 ------- huble/httpcycle/django.py | 61 ----- tracker/huble/README.md | 210 ++++++++++++++++++ tracker/huble/__init__.py | 15 ++ tracker/huble/core.py | 50 +++++ tracker/huble/integrations/__init__.py | 33 +++ tracker/huble/integrations/chalice_fw.py | 55 +++++ tracker/huble/integrations/django_fw.py | 59 +++++ .../huble/integrations/fastapi_fw.py | 2 +- tracker/huble/integrations/httpx_client.py | 52 +++++ tracker/huble/integrations/requests_client.py | 27 +++ tracker/huble/interfaces.py | 18 ++ {huble => tracker/huble}/logging/filter.py | 0 tracker/huble/tests/conftest.py | 35 +++ .../huble/tests/test_clients_httpx_patch.py | 55 +++++ .../tests/test_clients_requests_patch.py | 32 +++ tracker/huble/tests/test_core_trace.py | 23 ++ .../huble/tests/test_framework_dispatch.py | 61 +++++ tracker/huble/tests/test_logging_filter.py | 18 ++ tracker/huble/tests/test_setup_core.py | 13 ++ tracker/providers/logger.py | 1 + 24 files changed, 758 insertions(+), 402 deletions(-) delete mode 100644 huble/README.md delete mode 100644 huble/__init__.py delete mode 100644 huble/core.py delete mode 100644 huble/httpcycle/chalice.py delete mode 100644 huble/httpcycle/django.py create mode 100644 tracker/huble/README.md create mode 100644 tracker/huble/__init__.py create mode 100644 tracker/huble/core.py create mode 100644 tracker/huble/integrations/__init__.py create mode 100644 tracker/huble/integrations/chalice_fw.py create mode 100644 tracker/huble/integrations/django_fw.py rename huble/httpcycle/fast_api.py => tracker/huble/integrations/fastapi_fw.py (98%) create mode 100644 tracker/huble/integrations/httpx_client.py create mode 100644 tracker/huble/integrations/requests_client.py create mode 100644 tracker/huble/interfaces.py rename {huble => tracker/huble}/logging/filter.py (100%) create mode 100644 tracker/huble/tests/conftest.py create mode 100644 tracker/huble/tests/test_clients_httpx_patch.py create mode 100644 tracker/huble/tests/test_clients_requests_patch.py create mode 100644 tracker/huble/tests/test_core_trace.py create mode 100644 tracker/huble/tests/test_framework_dispatch.py create mode 100644 tracker/huble/tests/test_logging_filter.py create mode 100644 tracker/huble/tests/test_setup_core.py diff --git a/huble/README.md b/huble/README.md deleted file mode 100644 index 28ed616..0000000 --- a/huble/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# Exemplos de ligação — FastAPI, Django/DRF e Chalice - - ---- - -# FastAPI - -```python -# main.py -from fastapi import FastAPI -from huble import setup -from huble.httpcycle.fastapi import ( - HubleTraceMiddleware, - RequestResponseLoggingMiddleware, # opcional -) - -setup() # ativa patches httpx/requests + filtro de logging com trace_id - -app = FastAPI() - -# entrada/saída do X-HUBLE-TRACE-ID -app.add_middleware(HubleTraceMiddleware) - -# (opcional) log de request/response -# app.add_middleware( -# RequestResponseLoggingMiddleware, -# logger=None, # ou passe um logger configurado -# redact_headers={"authorization","cookie","x-api-key"}, -# ) - -@app.get("/health") -def health(): - return {"ok": True} -``` - ---- - -# Django / DRF - -**asgi.py** (ou **wsgi.py**) — bootstrap cedo: -```python -# asgi.py -from huble import setup -setup() - -import os -from django.core.asgi import get_asgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "seu_projeto.settings") -application = get_asgi_application() -``` - -**settings.py** — ligar middlewares: -```python -MIDDLEWARE = [ - "huble.httpcycle.django.HubleTraceMiddleware", # garante X-HUBLE-TRACE-ID em entrada/saída - # "huble.httpcycle.django.RequestResponseLoggingMiddleware", # opcional: log de request/response - # ... seus outros middlewares -] - -# Em dev/compose, evite DisallowedHost -# ALLOWED_HOSTS = ["*", "localhost", "djangoapi"] -``` - ---- - -# Chalice - -## Opção A) Auto-wrap global (todas as rotas) -```python -# app.py -from chalice import Chalice -from huble import setup -from huble.httpcycle.chalice import enable_autowrap - -setup() - -app = Chalice(app_name="api") -enable_autowrap( - app, - logger=None, # ou passe um logger - log_request_response=True, # opcional - redact_headers={"authorization","cookie"}, -) - -@app.route("/gamma") -def gamma(): - return {"ok": True} -``` - -## Opção B) Decorator por rota -```python -# app.py -from chalice import Chalice -from huble import setup -from huble.httpcycle.chalice import with_huble_trace - -setup() - -app = Chalice(app_name="api") - -@app.route("/gamma") -@with_huble_trace(log_request_response=True, redact_headers={"authorization","cookie"}) -def gamma(): - return {"ok": True} -``` - -> Em todos os casos acima, o `setup()` garante que **requisições HTTP de saída** (`httpx`/`requests`) carreguem o `X-HUBLE-TRACE-ID` e que **todos os logs** recebam `trace_id`. -> Os middlewares/decorator cuidam de **capturar/gerar** o header na entrada e **devolver** o mesmo na resposta. diff --git a/huble/__init__.py b/huble/__init__.py deleted file mode 100644 index 9ba7625..0000000 --- a/huble/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .core import ( - CANON_TRACE_HEADER, - get_current_trace_id, - set_current_trace_id, - reset_current_trace_id, - get_trace_id_if_any, - get_or_create_trace_id, - ensure_current_trace_id, - install_outbound_patches, - setup, -) diff --git a/huble/core.py b/huble/core.py deleted file mode 100644 index 048413e..0000000 --- a/huble/core.py +++ /dev/null @@ -1,135 +0,0 @@ -import uuid -from contextvars import ContextVar -from typing import Optional, Mapping, Any - -CANON_TRACE_HEADER = "X-HUBLE-TRACE-ID" - -_current_trace_id: ContextVar[Optional[str]] = ContextVar("x_huble_trace_id", default=None) - -def get_current_trace_id() -> Optional[str]: - return _current_trace_id.get() - -def set_current_trace_id(trace_id: str): - return _current_trace_id.set(trace_id) - -def reset_current_trace_id(token) -> None: - try: - _current_trace_id.reset(token) - except Exception: - pass - -def _ci_get(headers: Mapping[str, Any], name: str) -> Optional[str]: - nl = name.lower() - for k, v in headers.items(): - if str(k).lower() == nl: - return v - return None - -def _normalize_uuid(raw: str) -> Optional[str]: - if not raw: - return None - s = raw.strip() - try: - return str(uuid.UUID(s)) - except Exception: - try: - return str(uuid.UUID(hex=s)) - except Exception: - return None - -def get_trace_id_if_any(headers: Mapping[str, Any]) -> Optional[str]: - raw = _ci_get(headers, CANON_TRACE_HEADER) - return _normalize_uuid(raw) if raw else None - -def get_or_create_trace_id(headers: Mapping[str, Any]) -> tuple[str, str]: - raw = _ci_get(headers, CANON_TRACE_HEADER) - if raw: - norm = _normalize_uuid(raw) - if norm: - return norm, CANON_TRACE_HEADER.lower() - return str(uuid.uuid4()), "generated" - -_PATCHED = False -def install_outbound_patches(header_name: str = CANON_TRACE_HEADER) -> None: - global _PATCHED - if _PATCHED: - return - _PATCHED = True - - def _ci_has(h: Mapping[str, Any], name: str) -> bool: - nl = name.lower() - return any(str(k).lower() == nl for k in h.keys()) - - try: - import httpx - _orig_client_request = httpx.Client.request - _orig_async_client_request = httpx.AsyncClient.request - _orig_client_send = httpx.Client.send - _orig_async_client_send = httpx.AsyncClient.send - - def _inject_headers_dict(headers): - tid = get_current_trace_id() - if not tid: - return headers - base = dict(headers or {}) - if not _ci_has(base, header_name): - base[header_name] = tid - return base - - def _client_request(self, method, url, *args, headers=None, **kwargs): - headers = _inject_headers_dict(headers) - return _orig_client_request(self, method, url, *args, headers=headers, **kwargs) - - async def _async_client_request(self, method, url, *args, headers=None, **kwargs): - headers = _inject_headers_dict(headers) - return await _orig_async_client_request(self, method, url, *args, headers=headers, **kwargs) - - def _client_send(self, request, *args, **kwargs): - tid = get_current_trace_id() - if tid and header_name not in request.headers: - request.headers[header_name] = tid - return _orig_client_send(self, request, *args, **kwargs) - - async def _async_client_send(self, request, *args, **kwargs): - tid = get_current_trace_id() - if tid and header_name not in request.headers: - request.headers[header_name] = tid - return await _orig_async_client_send(self, request, *args, **kwargs) - - httpx.Client.request = _client_request - httpx.AsyncClient.request = _async_client_request - httpx.Client.send = _client_send - httpx.AsyncClient.send = _async_client_send - except Exception: - pass - - try: - import requests - _orig_session_request = requests.sessions.Session.request - def _session_request(self, method, url, **kwargs): - tid = get_current_trace_id() - if tid: - hdrs = dict(kwargs.get("headers") or {}) - if not _ci_has(hdrs, header_name): - hdrs[header_name] = tid - kwargs["headers"] = hdrs - return _orig_session_request(self, method, url, **kwargs) - requests.sessions.Session.request = _session_request - except Exception: - pass - - -def ensure_current_trace_id() -> str: - """Garante que exista um trace_id na ContextVar e retorna-o.""" - tid = get_current_trace_id() - if tid: - return tid - new = str(uuid.uuid4()) - set_current_trace_id(new) - return new - -def setup() -> None: - """Atalhos para ligar cabeçalho outbound e filtro de logging.""" - from .logging.filter import HubleTraceFilter - install_outbound_patches() - logging.getLogger().addFilter(HubleTraceFilter()) \ No newline at end of file diff --git a/huble/httpcycle/chalice.py b/huble/httpcycle/chalice.py deleted file mode 100644 index ddb3691..0000000 --- a/huble/httpcycle/chalice.py +++ /dev/null @@ -1,85 +0,0 @@ -import time -from functools import wraps -from typing import Optional, Callable, Dict, Any -from ..core import ( - CANON_TRACE_HEADER, - get_trace_id_if_any, - set_current_trace_id, - reset_current_trace_id, - ensure_current_trace_id, -) - -def with_huble_trace(view_func: Callable = None, *, logger=None, log_request_response: bool = False, redact_headers: Optional[set[str]] = None): - """Decorator para rotas Chalice: captura/gera trace, devolve header e (opcional) loga request/response.""" - if redact_headers is None: - redact_headers = set() - - def decorator(fn: Callable): - @wraps(fn) - def wrapper(*args, **kwargs): - from chalice import current_request, Response - - incoming = get_trace_id_if_any(current_request.headers or {}) - token = set_current_trace_id(incoming or ensure_current_trace_id()) - - try: - t0 = time.perf_counter() - result = fn(*args, **kwargs) - - if not isinstance(result, Response): - from chalice import Response as ChaliceResponse - resp = ChaliceResponse( - body=result, - status_code=200, - headers={CANON_TRACE_HEADER: ensure_current_trace_id()}, - ) - else: - - result.headers = dict(result.headers or {}) - result.headers[CANON_TRACE_HEADER] = ensure_current_trace_id() - resp = result - - if log_request_response and logger: - try: - def _clean(headers) -> Dict[str, str]: - out = {} - for k, v in (headers or {}).items(): - kl = str(k).lower() - out[kl] = "***REDACTED***" if kl in redact_headers else str(v) - return out - dt = (time.perf_counter() - t0) * 1000.0 - logger.info("http_request", extra={ - "method": current_request.method, - "path": current_request.context.get("resourcePath", current_request.path), - "status": resp.status_code, - "req_headers": _clean(current_request.headers), - "res_headers": _clean(resp.headers), - "time_taken_ms": round(dt, 2), - }) - except Exception: - pass - - return resp - finally: - reset_current_trace_id(token) - return wrapper - return decorator(view_func) if view_func else decorator - - -def enable_autowrap(app, *, logger=None, log_request_response: bool = False, redact_headers: Optional[set[str]] = None): - """ - Monkeypatch de app.route para auto-aplicar @with_huble_trace em TODAS as rotas - definidas depois desta chamada. Chame ANTES de declarar @app.route(...). - """ - original_route = app.route - - def route_wrapper(*r_args, **r_kwargs): - def registrar(fn): - wrapped = with_huble_trace( - fn, logger=logger, log_request_response=log_request_response, redact_headers=redact_headers - ) - return original_route(*r_args, **r_kwargs)(wrapped) - return registrar - - app.route = route_wrapper - return app diff --git a/huble/httpcycle/django.py b/huble/httpcycle/django.py deleted file mode 100644 index aa4ee8e..0000000 --- a/huble/httpcycle/django.py +++ /dev/null @@ -1,61 +0,0 @@ -import time -from typing import Optional, Dict -from django.utils.deprecation import MiddlewareMixin -from ..core import ( - CANON_TRACE_HEADER, - get_trace_id_if_any, - set_current_trace_id, - reset_current_trace_id, - ensure_current_trace_id, -) - -class HubleTraceMiddleware(MiddlewareMixin): - """Garante entrada/saída do X-HUBLE-TRACE-ID no ciclo Django/DRF.""" - def process_request(self, request): - incoming = get_trace_id_if_any(request.headers) - self._token = set_current_trace_id(incoming or ensure_current_trace_id()) - - def process_response(self, request, response): - try: - tid = ensure_current_trace_id() - response[CANON_TRACE_HEADER] = tid - finally: - token = getattr(self, "_token", None) - if token is not None: - reset_current_trace_id(token) - return response - - -class RequestResponseLoggingMiddleware(MiddlewareMixin): - def __init__(self, get_response=None, *, logger=None, redact_headers: Optional[set[str]] = None): - super().__init__(get_response) - self.logger = logger - self.redact_headers = {h.lower() for h in (redact_headers or set())} - - def process_request(self, request): - self._t0 = time.perf_counter() - ensure_current_trace_id() - - def process_response(self, request, response): - dt = (time.perf_counter() - getattr(self, "_t0", time.perf_counter())) * 1000.0 - def _clean(django_headers) -> Dict[str, str]: - out = {} - for k, v in django_headers.items(): - kl = str(k).lower() - out[kl] = "***REDACTED***" if kl in self.redact_headers else str(v) - return out - - if self.logger: - try: - path = request.get_full_path() if hasattr(request, "get_full_path") else request.path - self.logger.info("http_request", extra={ - "method": request.method, - "path": path, - "status": getattr(response, "status_code", None), - "req_headers": _clean(request.headers), - "res_headers": _clean(getattr(response, "headers", {})), - "time_taken_ms": round(dt, 2), - }) - except Exception: - pass - return response diff --git a/tracker/huble/README.md b/tracker/huble/README.md new file mode 100644 index 0000000..279e919 --- /dev/null +++ b/tracker/huble/README.md @@ -0,0 +1,210 @@ +Tracker — Integração do X-HUBLE-TRACE-ID (FastAPI, Django, Chalice) + +Este README mostra como ligar a lib do trace id e dos logs nos três frameworks que você usa: FastAPI, Django (DRF) e Chalice — de forma modular, sem dependências cruzadas. +A lib garante que: + +toda requisição entra com um X-HUBLE-TRACE-ID (se não vier, a lib cria um UUID v4); + +o X-HUBLE-TRACE-ID é propagado no contexto e adicionado automaticamente no response; + +chamadas outbound via httpx/requests recebem o header automaticamente; + +todos os logs ganham o campo huble_trace_id. + +APIs públicas principais: + +from tracker.huble import setup_core, enable_framework, enable_clients +from tracker.huble.core import CANON_TRACE_HEADER, get_current_trace_id +# CANON_TRACE_HEADER == "X-HUBLE-TRACE-ID" + + +setup_core() → instala o filtro de log para incluir huble_trace_id em todos os logs. + +enable_framework(app, log_request_response=False) → pluga o middleware/hook do framework detectado. + +enable_clients(wanted=None) → aplica patch em httpx e requests para enviar o header automaticamente +(wanted pode ser ["httpx"], ["requests"] ou None para ambos). + +1) FastAPI + +main.py + +import logging +from fastapi import FastAPI +from tracker.huble import setup_core, enable_framework, enable_clients +from tracker.huble.core import CANON_TRACE_HEADER, get_current_trace_id + +# 1) Inicializa logging + trace-id em logs +setup_core() + +# 2) Aplica patch de clients (httpx/requests) para outbound +enable_clients() # ou enable_clients(["httpx"]) / enable_clients(["requests"]) + +# 3) Cria app e liga o middleware +app = FastAPI() +enable_framework(app, log_request_response=True) # opcional: log detalhado de request/response + +@app.get("/health") +def health(): + return { + "ok": True, + "trace_id_in_context": get_current_trace_id(), + } + + +Testes rápidos + +# 1) Sem header → a lib cria um UUID e retorna no response: +curl -i http://localhost:8000/health + +# 2) Com header fixo → a lib respeita e propaga: +curl -i -H "X-HUBLE-TRACE-ID: 4a750618-2ad1-494f-9142-bbe0a6e55874" \ + http://localhost:8000/health + +2) Django (com DRF ou não) +2.1. Habilitar o middleware + +settings.py + +MIDDLEWARE = [ + # Coloque o Huble cedo na cadeia + "tracker.huble.integrations.django_fw.HubleMiddleware", + # ... seus outros middlewares ... +] + + +Em dev dentro de Docker Compose, lembre de ajustar ALLOWED_HOSTS para aceitar o host/container (ex.: ALLOWED_HOSTS = ["*", "django_api", "localhost"]). + +2.2. Inicializar a lib no startup do Django + +Crie um AppConfig para rodar código de inicialização: + +core/apps.py + +from django.apps import AppConfig +from tracker.huble import setup_core, enable_clients + +class CoreConfig(AppConfig): + name = "core" + + def ready(self): + # 1) logging + trace em logs + setup_core() + # 2) patch de clients p/ outbound + enable_clients() # httpx/requests, conforme usado no projeto + + +settings.py + +INSTALLED_APPS = [ + "core.apps.CoreConfig", # <-- registre o AppConfig + # ... suas outras apps ... +] + +2.3. Exemplo de view +# views.py +from django.http import JsonResponse +from tracker.huble.core import get_current_trace_id + +def health(request): + return JsonResponse({ + "ok": True, + "trace_id_in_context": get_current_trace_id(), + }) + + +Testes rápidos + +# Sem header → a lib cria e envia de volta +curl -i http://localhost:8000/health + +# Com header fixo +curl -i -H "X-HUBLE-TRACE-ID: 4a750618-2ad1-494f-9142-bbe0a6e55874" \ + http://localhost:8000/health + +3) Chalice + +app.py + +from chalice import Chalice +from tracker.huble import setup_core, enable_framework, enable_clients +from tracker.huble.core import get_current_trace_id + +app = Chalice(app_name="my-chalice-app") + +# 1) logging + trace nos logs +setup_core() + +# 2) patch de clients p/ outbound +enable_clients() + +# 3) integra com o Chalice (hooks para request/response) +enable_framework(app, log_request_response=True) + +@app.route("/health") +def health(): + return {"ok": True, "trace_id_in_context": get_current_trace_id()} + + +Testes rápidos + +# Chalice local (por exemplo via docker/compose) +curl -i http://localhost:8000/health + +# Usando um trace fixo +curl -i -H "X-HUBLE-TRACE-ID: 4a750618-2ad1-494f-9142-bbe0a6e55874" \ + http://localhost:8000/health + +4) Logs com huble_trace_id + +Ao chamar setup_core(), a lib instala um filtro no root logger que injeta huble_trace_id em todos os registros. +Você pode referenciar %(huble_trace_id)s no seu formatter: + +Exemplo (Django settings.py) +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console": { + "format": "%(asctime)s %(levelname)s trace=%(huble_trace_id)s %(name)s: %(message)s", + }, + }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "console"}, + }, + "root": {"level": "INFO", "handlers": ["console"]}, +} + + +Não precisa declarar o filtro aqui; o setup_core() já o adiciona programaticamente no root logger. + +5) Outbound HTTP (httpx / requests) + +Chamou enable_clients()? Então toda chamada cliente sai com o header X-HUBLE-TRACE-ID do contexto atual automaticamente. + +import httpx +from tracker.huble.core import get_current_trace_id + +async def call_upstream(): + async with httpx.AsyncClient() as c: + r = await c.get("http://service/internal") + # O header X-HUBLE-TRACE-ID foi enviado automaticamente + + +Mesma ideia para requests: + +import requests + +def call_upstream_sync(): + s = requests.Session() + r = s.get("http://service/internal") + +6) Dicas e troubleshooting + +Django 400 / DisallowedHost: adicione o hostname do container em ALLOWED_HOSTS (ou * em dev). + +Quer ver o header no response? Ele sempre é adicionado; rode curl -i ... e procure X-HUBLE-TRACE-ID. + +Header fixo para teste: gere um UUID v4 e envie: + +curl -i -H "X-HUBLE-TRACE-ID: 4a750618-2ad1-494f-9142-bbe0a6e55874" http://... diff --git a/tracker/huble/__init__.py b/tracker/huble/__init__.py new file mode 100644 index 0000000..0c3e9be --- /dev/null +++ b/tracker/huble/__init__.py @@ -0,0 +1,15 @@ +import logging +from .logging.filter import HubleTraceFilter + +def setup_core() -> None: + root = logging.getLogger() + if not any(isinstance(f, HubleTraceFilter) for f in getattr(root, "filters", [])): + root.addFilter(HubleTraceFilter()) + +def enable_framework(app, **kwargs): + from .integrations import attach_framework + return attach_framework(app, **kwargs) + +def enable_clients(clients=None) -> None: + from .integrations import attach_clients + attach_clients(clients) diff --git a/tracker/huble/core.py b/tracker/huble/core.py new file mode 100644 index 0000000..3a3bfaf --- /dev/null +++ b/tracker/huble/core.py @@ -0,0 +1,50 @@ +import uuid +from contextvars import ContextVar +from typing import Optional, Mapping, Any + +CANON_TRACE_HEADER = "X-HUBLE-TRACE-ID" + +_current_trace_id: ContextVar[Optional[str]] = ContextVar("x_huble_trace_id", default=None) + +def get_current_trace_id() -> Optional[str]: + return _current_trace_id.get() + +def set_current_trace_id(trace_id: str): + return _current_trace_id.set(trace_id) + +def reset_current_trace_id(token) -> None: + try: + _current_trace_id.reset(token) + except Exception: + pass + +def _ci_get(headers: Mapping[str, Any], name: str) -> Optional[str]: + nl = name.lower() + for k, v in headers.items(): + if str(k).lower() == nl: + return v + return None + +def _normalize_uuid(raw: str) -> Optional[str]: + if not raw: + return None + s = raw.strip() + try: + return str(uuid.UUID(s)) + except Exception: + try: + return str(uuid.UUID(hex=s)) + except Exception: + return None + +def get_trace_id_if_any(headers: Mapping[str, Any]) -> Optional[str]: + raw = _ci_get(headers, CANON_TRACE_HEADER) + return _normalize_uuid(raw) if raw else None + +def get_or_create_trace_id(headers: Mapping[str, Any]) -> tuple[str, str]: + raw = _ci_get(headers, CANON_TRACE_HEADER) + if raw: + norm = _normalize_uuid(raw) + if norm: + return norm, CANON_TRACE_HEADER.lower() + return str(uuid.uuid4()), "generated" diff --git a/tracker/huble/integrations/__init__.py b/tracker/huble/integrations/__init__.py new file mode 100644 index 0000000..12c71b8 --- /dev/null +++ b/tracker/huble/integrations/__init__.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from typing import Any, Iterable, Optional + +def attach_framework(app: Any, **kwargs): + mod = app.__class__.__module__ + if "fastapi.applications" in mod or "starlette.applications" in mod: + from .fastapi_fw import FastAPIFrameworkAdapter + return FastAPIFrameworkAdapter().attach(app, **kwargs) + if "chalice.app" in mod: + from .chalice_fw import ChaliceFrameworkAdapter + return ChaliceFrameworkAdapter().attach(app, **kwargs) + if "django.core.handlers" in mod or "django.core.asgi" in mod: + from .django_fw import DjangoFrameworkAdapter + return DjangoFrameworkAdapter().attach(app, **kwargs) + raise RuntimeError(f"Framework não suportado: {mod}") + +def attach_clients(clients: Optional[Iterable[str]] = None) -> None: + # auto por default + wanted = set((clients or {"httpx","requests"})) + if "httpx" in wanted: + try: + import httpx # noqa + from .httpx_client import HttpxClientAdapter + HttpxClientAdapter().patch() + except Exception: + pass + if "requests" in wanted: + try: + import requests # noqa + from .requests_client import RequestsClientAdapter + RequestsClientAdapter().patch() + except Exception: + pass diff --git a/tracker/huble/integrations/chalice_fw.py b/tracker/huble/integrations/chalice_fw.py new file mode 100644 index 0000000..e8c4ba7 --- /dev/null +++ b/tracker/huble/integrations/chalice_fw.py @@ -0,0 +1,55 @@ +from __future__ import annotations +from typing import Any, Optional, Iterable, Callable +from ..interfaces import IFrameworkAdapter +from ..core import ( + CANON_TRACE_HEADER, get_or_create_trace_id, set_current_trace_id, reset_current_trace_id +) +import time, json + +def _with_trace(log_request_response: bool=False, logger=None, redact_headers=None): + redact = {*(redact_headers or {"authorization","cookie","x-api-key"})} + def deco(fn: Callable): + def wrapper(*args, **kwargs): + from chalice import current_app, Response + req = current_app.current_request + tid, _ = get_or_create_trace_id(req.headers or {}) + tok = set_current_trace_id(tid) + try: + start = time.perf_counter() if log_request_response else None + rv = fn(*args, **kwargs) + if log_request_response and logger: + try: + elapsed = round((time.perf_counter()-start)*1000, 2) if start else None + safe_headers = { + str(k).lower(): ("***REDACTED***" if str(k).lower() in redact else str(v)) + for k, v in (req.headers or {}).items() + } + logger.info(json.dumps({ + "event":"http_cycle","framework":"chalice", + "trace_id": tid, "method": req.method, + "path": req.context.get("path") or req.context.get("resourcePath"), + "status_code": 200, "elapsed_ms": elapsed, "headers": safe_headers + })) + except Exception: + pass + if isinstance(rv, Response): + rv.headers[CANON_TRACE_HEADER] = tid + return rv + # tuple/dict simples + return rv, 200, {CANON_TRACE_HEADER: tid} + finally: + reset_current_trace_id(tok) + return wrapper + return deco + +def _autowrap(app, **opts): + for route in list(app.routes.values()): + for method, view in list(route.view_functions.items()): + route.view_functions[method] = _with_trace(**opts)(view) + +class ChaliceFrameworkAdapter(IFrameworkAdapter): + def attach( + self, app: Any, *, log_request_response: bool=False, logger=None, redact_headers=None + ) -> Any: + _autowrap(app, log_request_response=log_request_response, logger=logger, redact_headers=redact_headers) + return app diff --git a/tracker/huble/integrations/django_fw.py b/tracker/huble/integrations/django_fw.py new file mode 100644 index 0000000..bb2d5c0 --- /dev/null +++ b/tracker/huble/integrations/django_fw.py @@ -0,0 +1,59 @@ +# huble/integrations/django_fw.py +from __future__ import annotations +from typing import Any, Optional, Iterable +from django.utils.deprecation import MiddlewareMixin +from ..interfaces import IFrameworkAdapter +from ..core import ( + CANON_TRACE_HEADER, get_or_create_trace_id, set_current_trace_id, reset_current_trace_id +) +import time, json + +class HubleTraceMiddleware(MiddlewareMixin): + def process_request(self, request): + tid, _ = get_or_create_trace_id(getattr(request, "headers", {})) + token = set_current_trace_id(tid) + request._huble_trace_id = tid + request._huble_trace_token = token + request._huble_start = time.perf_counter() + + def process_response(self, request, response): + tid = getattr(request, "_huble_trace_id", None) + tok = getattr(request, "_huble_trace_token", None) + if tid: response[CANON_TRACE_HEADER] = tid + if tok: reset_current_trace_id(tok) + return response + +class RequestResponseLoggingMiddleware(MiddlewareMixin): + def __init__(self, get_response=None, logger=None, redact_headers=None): + super().__init__(get_response) + self.logger = logger + self.redact = {*(redact_headers or {"authorization","cookie","x-api-key"})} + + def process_response(self, request, response): + if not self.logger: + return response + try: + elapsed = None + if hasattr(request, "_huble_start"): + elapsed = round((time.perf_counter()-request._huble_start)*1000, 2) + hdrs = getattr(request, "headers", {}) + safe_headers = { + str(k).lower(): ("***REDACTED***" if str(k).lower() in self.redact else str(v)) + for k, v in hdrs.items() + } + self.logger.info(json.dumps({ + "event":"http_cycle","framework":"django", + "trace_id": getattr(request,"_huble_trace_id",None), + "method": getattr(request,"method",None), + "path": getattr(request,"path",None), + "status_code": getattr(response,"status_code",None), + "elapsed_ms": elapsed, "headers": safe_headers + })) + except Exception: + pass + return response + +class DjangoFrameworkAdapter(IFrameworkAdapter): + def attach(self, app: Any, **kwargs) -> Any: + + return app diff --git a/huble/httpcycle/fast_api.py b/tracker/huble/integrations/fastapi_fw.py similarity index 98% rename from huble/httpcycle/fast_api.py rename to tracker/huble/integrations/fastapi_fw.py index 57bd85a..a51379f 100644 --- a/huble/httpcycle/fast_api.py +++ b/tracker/huble/integrations/fastapi_fw.py @@ -4,7 +4,7 @@ from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware -from ...huble.core import ( +from ..core import ( CANON_TRACE_HEADER, get_trace_id_if_any, set_current_trace_id, reset_current_trace_id, ensure_current_trace_id ) diff --git a/tracker/huble/integrations/httpx_client.py b/tracker/huble/integrations/httpx_client.py new file mode 100644 index 0000000..29641d7 --- /dev/null +++ b/tracker/huble/integrations/httpx_client.py @@ -0,0 +1,52 @@ +# huble/integrations/httpx_client.py +from __future__ import annotations +from ..interfaces import IHttpClientAdapter +from ..core import CANON_TRACE_HEADER, get_current_trace_id + +class HttpxClientAdapter(IHttpClientAdapter): + _patched = False + def patch(self) -> None: + if self._patched: + return + try: + import httpx + except Exception: + return + + self._patched = True + _orig_client_request = httpx.Client.request + _orig_async_client_request = httpx.AsyncClient.request + _orig_client_send = httpx.Client.send + _orig_async_client_send = httpx.AsyncClient.send + + def _inject_dict(headers): + tid = get_current_trace_id() + if not tid: + return headers + base = dict(headers or {}) + if CANON_TRACE_HEADER not in {k.lower(): v for k,v in base.items()}: + base[CANON_TRACE_HEADER] = tid + return base + + def _client_request(self, method, url, *args, headers=None, **kw): + return _orig_client_request(self, method, url, *args, headers=_inject_dict(headers), **kw) + + async def _async_client_request(self, method, url, *args, headers=None, **kw): + return await _orig_async_client_request(self, method, url, *args, headers=_inject_dict(headers), **kw) + + def _client_send(self, request, *args, **kw): + tid = get_current_trace_id() + if tid and CANON_TRACE_HEADER not in request.headers: + request.headers[CANON_TRACE_HEADER] = tid + return _orig_client_send(self, request, *args, **kw) + + async def _async_client_send(self, request, *args, **kw): + tid = get_current_trace_id() + if tid and CANON_TRACE_HEADER not in request.headers: + request.headers[CANON_TRACE_HEADER] = tid + return await _orig_async_client_send(self, request, *args, **kw) + + httpx.Client.request = _client_request + httpx.AsyncClient.request = _async_client_request + httpx.Client.send = _client_send + httpx.AsyncClient.send = _async_client_send diff --git a/tracker/huble/integrations/requests_client.py b/tracker/huble/integrations/requests_client.py new file mode 100644 index 0000000..46c2de2 --- /dev/null +++ b/tracker/huble/integrations/requests_client.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from ..interfaces import IHttpClientAdapter +from ..core import CANON_TRACE_HEADER, get_current_trace_id + +class RequestsClientAdapter(IHttpClientAdapter): + _patched = False + def patch(self) -> None: + if self._patched: + return + try: + import requests + except Exception: + return + + self._patched = True + _orig = requests.sessions.Session.request + + def _session_request(self, method, url, **kw): + tid = get_current_trace_id() + if tid: + hdrs = dict(kw.get("headers") or {}) + if CANON_TRACE_HEADER.lower() not in {k.lower() for k in hdrs}: + hdrs[CANON_TRACE_HEADER] = tid + kw["headers"] = hdrs + return _orig(self, method, url, **kw) + + requests.sessions.Session.request = _session_request diff --git a/tracker/huble/interfaces.py b/tracker/huble/interfaces.py new file mode 100644 index 0000000..43a4226 --- /dev/null +++ b/tracker/huble/interfaces.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Any, Optional, Iterable + +class IFrameworkAdapter(ABC): + @abstractmethod + def attach( + self, + app: Any, + *, + log_request_response: bool = False, + logger: Optional[Any] = None, + redact_headers: Optional[Iterable[str]] = None, + ) -> Any: ... + +class IHttpClientAdapter(ABC): + @abstractmethod + def patch(self) -> None: ... diff --git a/huble/logging/filter.py b/tracker/huble/logging/filter.py similarity index 100% rename from huble/logging/filter.py rename to tracker/huble/logging/filter.py diff --git a/tracker/huble/tests/conftest.py b/tracker/huble/tests/conftest.py new file mode 100644 index 0000000..05ffdd1 --- /dev/null +++ b/tracker/huble/tests/conftest.py @@ -0,0 +1,35 @@ +import types +import logging +import sys +import pytest + +class MemoryHandler(logging.Handler): + def __init__(self): + super().__init__() + self.records = [] + def emit(self, record): + self.records.append(record) + +@pytest.fixture +def mem_logger(): + root = logging.getLogger() + handler = MemoryHandler() + root.addHandler(handler) + try: + yield handler + finally: + root.removeHandler(handler) + +def stub_module(monkeypatch, fullname: str, attrs: dict | None = None): + mod = types.ModuleType(fullname) + for k, v in (attrs or {}).items(): + setattr(mod, k, v) + # Garantir a hierarquia (pacotes) existe em sys.modules + parts = fullname.split(".") + pkg = "" + for i, part in enumerate(parts): + pkg = part if i == 0 else f"{pkg}.{part}" + if pkg not in sys.modules: + sys.modules[pkg] = types.ModuleType(pkg) + monkeypatch.setitem(sys.modules, fullname, mod) + return mod diff --git a/tracker/huble/tests/test_clients_httpx_patch.py b/tracker/huble/tests/test_clients_httpx_patch.py new file mode 100644 index 0000000..b6ed9fa --- /dev/null +++ b/tracker/huble/tests/test_clients_httpx_patch.py @@ -0,0 +1,55 @@ +import types +import asyncio +import pytest + +from huble import enable_clients +from core import set_current_trace_id, reset_current_trace_id, CANON_TRACE_HEADER + +@pytest.fixture +def stub_httpx(monkeypatch): + class _Req: + def __init__(self): + self.headers = {} + + class Client: + def __init__(self): pass + def request(self, method, url, headers=None, **kw): + self.last_headers = dict(headers or {}) + return types.SimpleNamespace(status_code=200) + def send(self, request, *a, **kw): + return types.SimpleNamespace(status_code=200) + + class AsyncClient: + def __init__(self): pass + async def request(self, method, url, headers=None, **kw): + self.last_headers = dict(headers or {}) + return types.SimpleNamespace(status_code=200) + async def send(self, request, *a, **kw): + return types.SimpleNamespace(status_code=200) + + mod = types.ModuleType("httpx") + mod.Client = Client + mod.AsyncClient = AsyncClient + monkeypatch.setitem(__import__("sys").modules, "httpx", mod) + return mod + +def test_httpx_patch_injeta_header(stub_httpx): + enable_clients(["httpx"]) # aplica o patch no módulo stub + tok = set_current_trace_id("4a750618-2ad1-494f-9142-bbe0a6e55874") + try: + c = stub_httpx.Client() + c.request("GET", "http://x", headers={"foo": "bar"}) + assert c.last_headers[CANON_TRACE_HEADER] == "4a750618-2ad1-494f-9142-bbe0a6e55874" + finally: + reset_current_trace_id(tok) + +@pytest.mark.asyncio +async def test_httpx_async_patch_injeta_header(stub_httpx): + enable_clients(["httpx"]) + tok = set_current_trace_id("4a750618-2ad1-494f-9142-bbe0a6e55874") + try: + c = stub_httpx.AsyncClient() + resp = await c.request("GET", "http://x") + assert getattr(c, "last_headers")[CANON_TRACE_HEADER] == "4a750618-2ad1-494f-9142-bbe0a6e55874" + finally: + reset_current_trace_id(tok) diff --git a/tracker/huble/tests/test_clients_requests_patch.py b/tracker/huble/tests/test_clients_requests_patch.py new file mode 100644 index 0000000..458c26b --- /dev/null +++ b/tracker/huble/tests/test_clients_requests_patch.py @@ -0,0 +1,32 @@ +import types +from huble import enable_clients +from core import set_current_trace_id, reset_current_trace_id, CANON_TRACE_HEADER + +def test_requests_patch_injeta_header(monkeypatch): + class Session: + def __init__(self): + self.last_kwargs = None + def request(self, method, url, **kwargs): + self.last_kwargs = kwargs + return types.SimpleNamespace(status_code=200) + + sess_mod = types.ModuleType("requests.sessions") + sess_mod.Session = Session + + req_mod = types.ModuleType("requests") + req_mod.sessions = sess_mod + + sys = __import__("sys") + monkeypatch.setitem(sys.modules, "requests", req_mod) + monkeypatch.setitem(sys.modules, "requests.sessions", sess_mod) + + enable_clients(["requests"]) + + tok = set_current_trace_id("4a750618-2ad1-494f-9142-bbe0a6e55874") + try: + s = Session() + s.request("GET", "http://x") + hdrs = s.last_kwargs.get("headers", {}) + assert hdrs[CANON_TRACE_HEADER] == "4a750618-2ad1-494f-9142-bbe0a6e55874" + finally: + reset_current_trace_id(tok) diff --git a/tracker/huble/tests/test_core_trace.py b/tracker/huble/tests/test_core_trace.py new file mode 100644 index 0000000..aaec059 --- /dev/null +++ b/tracker/huble/tests/test_core_trace.py @@ -0,0 +1,23 @@ +from core import ( + CANON_TRACE_HEADER, + get_current_trace_id, set_current_trace_id, reset_current_trace_id, + get_trace_id_if_any, get_or_create_trace_id, +) + +def test_context_var_roundtrip(): + assert get_current_trace_id() is None + tok = set_current_trace_id("11111111-1111-1111-1111-111111111111") + try: + assert get_current_trace_id() == "11111111-1111-1111-1111-111111111111" + finally: + reset_current_trace_id(tok) + assert get_current_trace_id() is None + +def test_get_trace_id_if_any_present_case_insensitive(): + headers = {CANON_TRACE_HEADER.lower(): "4a750618-2ad1-494f-9142-bbe0a6e55874"} + assert get_trace_id_if_any(headers) == "4a750618-2ad1-494f-9142-bbe0a6e55874" + +def test_get_or_create_trace_id_generated_when_missing(): + tid, source = get_or_create_trace_id({}) + assert source == "generated" + assert len(tid) == 36 and tid.count("-") == 4 diff --git a/tracker/huble/tests/test_framework_dispatch.py b/tracker/huble/tests/test_framework_dispatch.py new file mode 100644 index 0000000..bd17fe9 --- /dev/null +++ b/tracker/huble/tests/test_framework_dispatch.py @@ -0,0 +1,61 @@ +import types +from huble.integrations import attach_framework + +class _Hit: + called = False + args = None + kwargs = None + @classmethod + def reset(cls): + cls.called, cls.args, cls.kwargs = False, None, None + @classmethod + def attach(cls, app, **kwargs): + cls.called = True + cls.args, cls.kwargs = (app,), kwargs + +def test_dispatch_fastapi(monkeypatch): + from sys import modules + mod = types.ModuleType("huble.integrations.fastapi_fw") + class FakeAdapter: + def attach(self, app, **kwargs): + _Hit.attach(app, **kwargs) + mod.HubleFrameworkAdapter = FakeAdapter + monkeypatch.setitem(modules, "huble.integrations.fastapi_fw", mod) + + class FakeFastAPI: + router = object() + _Hit.reset() + attach_framework(FakeFastAPI(), log_request_response=True) + assert _Hit.called is True + assert _Hit.kwargs.get("log_request_response") is True + +def test_dispatch_django(monkeypatch): + from sys import modules + mod = types.ModuleType("huble.integrations.django_fw") + class FakeAdapter: + def attach(self, app, **kwargs): + _Hit.attach(app, **kwargs) + mod.HubleFrameworkAdapter = FakeAdapter + monkeypatch.setitem(modules, "huble.integrations.django_fw", mod) + + class FakeDjangoApp: + _is_django = True + _Hit.reset() + attach_framework(FakeDjangoApp(), log_request_response=False) + assert _Hit.called is True + +def test_dispatch_chalice(monkeypatch): + from sys import modules + mod = types.ModuleType("huble.integrations.chalice_fw") + class FakeAdapter: + def attach(self, app, **kwargs): + _Hit.attach(app, **kwargs) + mod.HubleFrameworkAdapter = FakeAdapter + monkeypatch.setitem(modules, "huble.integrations.chalice_fw", mod) + + class FakeChalice: + app_name = "demo" + _Hit.reset() + attach_framework(FakeChalice(), some_flag=123) + assert _Hit.called is True + assert _Hit.kwargs.get("some_flag") == 123 diff --git a/tracker/huble/tests/test_logging_filter.py b/tracker/huble/tests/test_logging_filter.py new file mode 100644 index 0000000..cd67764 --- /dev/null +++ b/tracker/huble/tests/test_logging_filter.py @@ -0,0 +1,18 @@ +import logging +from logging.filter import HubleTraceFilter +from core import set_current_trace_id, reset_current_trace_id + +def test_filter_injeta_trace_id(mem_logger): + root = logging.getLogger() + f = HubleTraceFilter() + root.addFilter(f) + tok = set_current_trace_id("4a750618-2ad1-494f-9142-bbe0a6e55874") + try: + logging.info("hello") + finally: + reset_current_trace_id(tok) + root.removeFilter(f) + + assert len(mem_logger.records) == 1 + rec = mem_logger.records[0] + assert getattr(rec, "huble_trace_id") == "4a750618-2ad1-494f-9142-bbe0a6e55874" diff --git a/tracker/huble/tests/test_setup_core.py b/tracker/huble/tests/test_setup_core.py new file mode 100644 index 0000000..7d6b4bf --- /dev/null +++ b/tracker/huble/tests/test_setup_core.py @@ -0,0 +1,13 @@ +import logging +from huble import setup_core +from core import set_current_trace_id, reset_current_trace_id + +def test_setup_core_instala_filter(mem_logger): + setup_core() + tok = set_current_trace_id("4a750618-2ad1-494f-9142-bbe0a6e55874") + try: + logging.warning("oi") + finally: + reset_current_trace_id(tok) + rec = mem_logger.records[0] + assert getattr(rec, "huble_trace_id") == "4a750618-2ad1-494f-9142-bbe0a6e55874" diff --git a/tracker/providers/logger.py b/tracker/providers/logger.py index f52820d..6413ecd 100644 --- a/tracker/providers/logger.py +++ b/tracker/providers/logger.py @@ -65,6 +65,7 @@ def capture_message(self, tracker_message: TrackerMessage): if tracker_message.contexts: extra["contexts"].update(tracker_message.contexts) + extra["trace_id"] = tracker_message.contexts.get("trace_id", ensure_current_trace_id()) self.core.logger.info(tracker_message.message.value, extra=extra)