trace.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. """Per-request trace ID plumbing.
  2. Each HTTP request gets a short hex ID set in a ``ContextVar``; downstream
  3. log records (application *and* uvicorn access) read the same context and
  4. include the ID in their output. The result is that one ``grep <trace_id>``
  5. on ``bambuddy.log`` returns the access line + every line emitted on the
  6. server side while that request was being handled — closing the loop
  7. opened by piping uvicorn access into the file: the access line tells you
  8. *who* called the endpoint, the trace ID tells you *what else happened*
  9. on the server because of it.
  10. Why a ContextVar instead of e.g. ``request.state``:
  11. * asyncio copies the current context into every ``asyncio.create_task``,
  12. so background work spawned from within a request inherits the same
  13. trace ID without having to be passed it explicitly. ``request.state``
  14. doesn't survive that hop.
  15. * The logging filter has no access to the FastAPI request object — it
  16. runs synchronously inside the stdlib logging machinery — and the
  17. ContextVar is the only mechanism that bridges async request scope to
  18. sync log emission.
  19. Why no fancy structured-logging schema: this is a small project. The
  20. existing log format is a single line per record; we add a single
  21. bracketed token for the trace ID and stop there. If structured logging
  22. is wanted later, it can layer on top — the ContextVar carries an opaque
  23. string regardless of what consumes it downstream.
  24. """
  25. from __future__ import annotations
  26. import logging
  27. import re
  28. import secrets
  29. from contextvars import ContextVar
  30. # Default ``"-"`` (instead of None or empty string) so the format string
  31. # always produces a stable visual width; a bare empty bracket pair would
  32. # read as "no trace ID at all" which is hard to grep for. ``-`` reads as
  33. # "no value in this column" the way it does in HTTP access logs already.
  34. TRACE_ID_PLACEHOLDER = "-"
  35. trace_id_var: ContextVar[str] = ContextVar("trace_id", default=TRACE_ID_PLACEHOLDER)
  36. # Length of a freshly minted trace ID in hex chars. 8 chars = 32 bits of
  37. # entropy = ~4 billion possibilities; collisions are astronomically
  38. # unlikely within a single rotation window of bambuddy.log and grep stays
  39. # easy at this length. Increase later if it proves too short for a busy
  40. # install — the filter and format don't care about width.
  41. _GENERATED_LENGTH = 8
  42. # Bound on how long an *inbound* trace ID can be when echoed from the
  43. # X-Trace-Id request header. Without a cap a malicious / buggy client
  44. # could push 1 MB of garbage into every log line for a request. 64 chars
  45. # comfortably accommodates UUIDs (32 hex), Datadog-style 64-bit IDs,
  46. # OpenTelemetry's 32-hex spans — anything longer is almost certainly
  47. # wrong and we'd rather mint our own than honour it.
  48. _MAX_INBOUND_LENGTH = 64
  49. # Whitelist of characters allowed in an inbound trace ID. Restricted to
  50. # the alphanumerics + a small set of separators that real-world
  51. # correlation IDs use, so newlines / quotes / control chars cannot be
  52. # smuggled into log lines via the X-Trace-Id header. A request with an
  53. # unacceptable header just gets a freshly minted server-side ID — we
  54. # never reject the request for it.
  55. _VALID_INBOUND = re.compile(r"^[A-Za-z0-9_\-]+$")
  56. def get_trace_id() -> str:
  57. """Return the current trace ID, or the placeholder if none is set."""
  58. return trace_id_var.get()
  59. def generate_trace_id() -> str:
  60. """Mint a fresh server-side trace ID."""
  61. return secrets.token_hex(_GENERATED_LENGTH // 2)
  62. def normalise_inbound_trace_id(raw: str | None) -> str | None:
  63. """Validate and return a caller-supplied trace ID, or ``None`` to mint fresh.
  64. Accepts only short alphanumeric + ``_-`` strings so a hostile or buggy
  65. client can't smuggle log-injection payloads through the X-Trace-Id
  66. header. Returns ``None`` for any input that fails the gate, signalling
  67. to the middleware that it should generate one instead.
  68. """
  69. if raw is None:
  70. return None
  71. if not raw or len(raw) > _MAX_INBOUND_LENGTH:
  72. return None
  73. if not _VALID_INBOUND.match(raw):
  74. return None
  75. return raw
  76. class TraceIDFilter(logging.Filter):
  77. """Inject the current ``trace_id_var`` value into every LogRecord.
  78. Attach to the file handler (or any handler whose format string
  79. references ``%(trace_id)s``) so that every line written through that
  80. handler carries the request scope it was generated under. The filter
  81. always returns ``True`` — it never drops records, only annotates
  82. them.
  83. Records emitted outside any HTTP request (startup, MQTT callbacks,
  84. scheduled tasks not chained from a request) get the placeholder
  85. string, so the format column stays aligned and absent values are
  86. obviously visible as ``[-]`` rather than blanks.
  87. """
  88. def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 — stdlib API name
  89. # Use direct attribute set (not setdefault-style) so the value is
  90. # always taken from the *current* context — a record formatted on
  91. # a different task than where it was created (rare but possible
  92. # via QueueHandler or async-throttled handlers) still picks up
  93. # the right ID.
  94. record.trace_id = trace_id_var.get()
  95. return True