yym36991 0f088033fe
fix(gateway): prefer X-Trace-Id over metadata.deerflow_trace_id when header is set (#4283)
When TraceMiddleware binds a valid inbound X-Trace-Id, worker resolution
prefers that id over config.metadata.deerflow_trace_id so logs, response
headers, Langfuse, and runtime context stay aligned.
Also add trace-context marker reset coverage for the inbound-header flag.
2026-07-19 09:13:51 +08:00

120 lines
4.3 KiB
Python

"""Request trace context helpers.
The value stored here is DeerFlow's request-level correlation id. It is
separate from Langfuse's own trace id and from DeerFlow run ids.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar, Token
from typing import Final
TRACE_ID_HEADER: Final[str] = "X-Trace-Id"
DEERFLOW_TRACE_METADATA_KEY: Final[str] = "deerflow_trace_id"
_MAX_TRACE_ID_LENGTH: Final[int] = 512
_current_trace_id: Final[ContextVar[str | None]] = ContextVar("deerflow_current_trace_id", default=None)
_trace_id_from_request_header: Final[ContextVar[bool]] = ContextVar(
"deerflow_trace_id_from_request_header",
default=False,
)
def generate_trace_id() -> str:
"""Return a fresh header-safe trace id."""
return uuid.uuid4().hex
def normalize_trace_id(value: object) -> str | None:
"""Return a safe trace id string, or ``None`` when *value* is unusable.
Only printable ASCII (0x20-0x7E) is accepted. Codepoints above 0x7E are
rejected because the trace id round-trips through HTTP response headers,
which Starlette encodes as latin-1: codepoints > 0xFF raise
``UnicodeEncodeError`` inside ``MutableHeaders.__setitem__`` (forcing a
500 before the response body is even dispatched), and C1 controls
(0x80-0x9F) technically encode but are stripped or rejected by hardened
intermediaries (nginx / envoy / cloudfront), silently breaking the
response. C0 controls (< 0x20) and DEL (0x7F) are rejected for the same
header-safety reason plus log-injection defense.
"""
if not isinstance(value, str):
return None
trace_id = value.strip()
if not trace_id or len(trace_id) > _MAX_TRACE_ID_LENGTH:
return None
if any(ord(ch) < 32 or ord(ch) > 126 for ch in trace_id):
return None
return trace_id
def set_current_trace_id(trace_id: str) -> Token[str | None]:
"""Bind *trace_id* to the current execution context."""
normalized = normalize_trace_id(trace_id)
if normalized is None:
normalized = generate_trace_id()
return _current_trace_id.set(normalized)
def reset_current_trace_id(token: Token[str | None]) -> None:
"""Restore the trace context captured by *token*."""
_current_trace_id.reset(token)
def get_current_trace_id() -> str | None:
"""Return the current request trace id, if one is bound."""
return _current_trace_id.get()
def mark_trace_id_from_request_header(*, from_header: bool) -> Token[bool]:
"""Record whether the current trace id came from a valid inbound header."""
return _trace_id_from_request_header.set(from_header)
def reset_trace_id_from_request_header(token: Token[bool]) -> None:
"""Restore the inbound-header flag captured by *token*."""
_trace_id_from_request_header.reset(token)
def is_trace_id_from_request_header() -> bool:
"""Return ``True`` when a valid ``X-Trace-Id`` header bound the request."""
return _trace_id_from_request_header.get()
def resolve_deerflow_trace_id(metadata_trace_id: object) -> str | None:
"""Resolve the effective ``deerflow_trace_id`` for a run.
When Gateway ``TraceMiddleware`` bound a valid inbound ``X-Trace-Id``,
that value wins over ``config.metadata.deerflow_trace_id`` so logs,
response headers, Langfuse, and runtime context stay aligned. Otherwise
caller metadata wins, then the ambient request trace context.
"""
if is_trace_id_from_request_header():
return get_current_trace_id()
return normalize_trace_id(metadata_trace_id) or get_current_trace_id()
@contextmanager
def request_trace_context(trace_id: str | None = None) -> Iterator[str]:
"""Bind a request trace id for the duration of a request or entry point."""
normalized = normalize_trace_id(trace_id) or generate_trace_id()
token = _current_trace_id.set(normalized)
try:
yield normalized
finally:
_current_trace_id.reset(token)
@contextmanager
def ensure_trace_context(trace_id: str | None = None) -> Iterator[str]:
"""Bind *trace_id*, inherit the current trace, or create a fresh one."""
normalized = normalize_trace_id(trace_id) or get_current_trace_id() or generate_trace_id()
token = _current_trace_id.set(normalized)
try:
yield normalized
finally:
_current_trace_id.reset(token)