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/tracker/huble/integrations/fastapi_fw.py b/tracker/huble/integrations/fastapi_fw.py new file mode 100644 index 0000000..a51379f --- /dev/null +++ b/tracker/huble/integrations/fastapi_fw.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 ..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/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/tracker/huble/logging/filter.py b/tracker/huble/logging/filter.py new file mode 100644 index 0000000..cd7fff9 --- /dev/null +++ b/tracker/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/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 1ce116a..6413ecd 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) @@ -63,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) @@ -81,8 +84,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 +111,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)