diff --git a/datasette/telemetry.py b/datasette/telemetry.py index 2fd5a9dc..87ed9c3b 100644 --- a/datasette/telemetry.py +++ b/datasette/telemetry.py @@ -192,6 +192,26 @@ def _url_path(scope): return scope.get("path", "") +def _add_trace_response_headers(headers, span_context): + """ + Append W3C `traceresponse` and `Server-Timing: traceparent` headers. + + Hand-rolled because `TraceResponsePropagator` is not in `opentelemetry-api`. + """ + trace_id = otel_trace.format_trace_id(span_context.trace_id) + span_id = otel_trace.format_span_id(span_context.span_id) + value = f"00-{trace_id}-{span_id}-{span_context.trace_flags:02x}".encode("latin-1") + headers.append((b"traceresponse", value)) + headers.append((b"server-timing", b'traceparent;desc="' + value + b'"')) + # Only extend an existing Access-Control-Expose-Headers, which is only + # present if CORS is enabled. + for i, (key, existing) in enumerate(headers): + if key.lower() == b"access-control-expose-headers": + if existing.strip() != b"*": + headers[i] = (key, existing + b", traceresponse") + return + + # The request span is passed to the router in the ASGI scope, because a # plugin's asgi_wrapper() middleware may have made its own span current. # Absent if the span is not recording. @@ -265,6 +285,11 @@ class TelemetryMiddleware: and "status" not in status_holder ): status_holder["status"] = message["status"] + # Copy so the app's message and headers are not mutated + message = dict(message, headers=list(message.get("headers") or [])) + _add_trace_response_headers( + message["headers"], span.get_span_context() + ) await send(message) escaped = False diff --git a/docs/changelog.rst b/docs/changelog.rst index 2775cbc5..a8e0c3a6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _unreleased: + +Unreleased +---------- + +- Responses now include the request span's trace context in ``traceresponse`` and ``Server-Timing`` headers while tracing is recording. See :ref:`internals_telemetry_response_headers`. (:issue:`1730`) + .. _v1_0_a41: 1.0a41 (2026-09-24) diff --git a/docs/internals.rst b/docs/internals.rst index 6ecf5b73..d3f04a7e 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -2644,6 +2644,20 @@ A request to a table page produces a span named, in full:: .. [[[end]]] +.. _internals_telemetry_response_headers: + +Trace context in responses +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +While the request span is recording, every response includes that span's trace context in two headers:: + + traceresponse: 00--- + Server-Timing: traceparent;desc="00---" + +``traceresponse`` is the W3C Trace Context Level 2 response header. If CORS is enabled, ``traceresponse`` is added to the existing ``Access-Control-Expose-Headers`` value so cross-origin ``fetch()`` calls can read it. ``Server-Timing`` lets page JavaScript read the trace context through ``performance.getEntriesByType("navigation")[0].serverTiming``. Datasette does not set ``Timing-Allow-Origin``. + +Neither header is sent if no tracer provider is installed. If the sampler dropped the trace, the flags are ``00`` and the IDs will not be found in your tracing backend. + .. _internals_telemetry_metrics: Metric reference diff --git a/tests/test_api.py b/tests/test_api.py index 690cb080..50fa91c8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -854,7 +854,8 @@ def test_cors( response.headers["Access-Control-Allow-Headers"] == "Authorization, Content-Type" ) - assert response.headers["Access-Control-Expose-Headers"] == "Link" + # TelemetryMiddleware appends traceresponse while the suite's provider records. + assert response.headers["Access-Control-Expose-Headers"] == "Link, traceresponse" assert ( response.headers["Access-Control-Allow-Methods"] == "GET, POST, HEAD, OPTIONS" ) diff --git a/tests/test_telemetry_response_headers.py b/tests/test_telemetry_response_headers.py new file mode 100644 index 00000000..178d4c8f --- /dev/null +++ b/tests/test_telemetry_response_headers.py @@ -0,0 +1,132 @@ +""" +Trace context in HTTP response headers: `traceresponse` and +`Server-Timing: traceparent`, emitted by `TelemetryMiddleware` only while the +request span is recording. +""" + +import itertools + +import pytest +import pytest_asyncio + +pytest.importorskip("opentelemetry.sdk") + +from opentelemetry.trace import NoOpTracer, SpanKind + +import datasette.telemetry +from datasette import hookimpl +from datasette.app import Datasette + +_names = itertools.count() + + +@pytest_asyncio.fixture +async def ds(): + name = f"responseheaders{next(_names)}" + instance = Datasette(memory=True) + instance.add_memory_database(name) + await instance.invoke_startup() + instance.db_name = name + try: + yield instance + finally: + instance.close() + + +def _server_span(otel_spans): + server = [ + span for span in otel_spans.get_finished_spans() if span.kind is SpanKind.SERVER + ] + assert len(server) == 1 + return server[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/", "/-/does-not-exist"]) +async def test_headers_point_at_the_server_span(ds, otel_spans, path): + response = await ds.client.get(path) + span = _server_span(otel_spans) + ctx = span.context + expected = f"00-{ctx.trace_id:032x}-{ctx.span_id:016x}-{ctx.trace_flags:02x}" + assert response.headers["traceresponse"] == expected + assert response.headers["server-timing"] == f'traceparent;desc="{expected}"' + # No CORS configured, so no expose-headers entry is invented. + assert "access-control-expose-headers" not in response.headers + + +@pytest.mark.asyncio +async def test_inbound_traceparent_keeps_trace_id_but_not_span_id(ds, otel_spans): + trace_id = "4bf92f3577b34da6a3ce929d0e0e4736" + client_span_id = "00f067aa0ba902b7" + response = await ds.client.get( + "/", headers={"traceparent": f"00-{trace_id}-{client_span_id}-01"} + ) + _, got_trace_id, got_span_id, _ = response.headers["traceresponse"].split("-") + assert got_trace_id == trace_id + assert got_span_id != client_span_id + assert got_span_id == f"{_server_span(otel_spans).context.span_id:016x}" + + +@pytest.mark.asyncio +async def test_absent_when_the_request_span_is_not_recording(ds, monkeypatch): + # NoOpTracer is what the API hands out with no provider installed. The + # inbound traceparent makes its span context valid, which must still not + # be enough to emit headers. + monkeypatch.setattr(datasette.telemetry, "tracer", NoOpTracer()) + response = await ds.client.get( + "/", + headers={ + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + }, + ) + assert response.status_code == 200 + assert "traceresponse" not in response.headers + assert "server-timing" not in response.headers + assert "access-control-expose-headers" not in response.headers + + +class _ExposeHeadersPlugin: + "Stands in for a CORS plugin that already set Access-Control-Expose-Headers." + + __name__ = "ExposeHeadersPlugin" + + def __init__(self, value): + self.value = value + + @hookimpl + def asgi_wrapper(self, datasette): + value = self.value + + def wrap(app): + async def wrapped(scope, receive, send): + async def add_header(message): + if message["type"] == "http.response.start": + message = dict( + message, + headers=list(message.get("headers") or []) + + [(b"Access-Control-Expose-Headers", value)], + ) + await send(message) + + await app(scope, receive, add_header) + + return wrapped + + return wrap + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "existing,expected", [(b"Link", "Link, traceresponse"), (b"*", "*")] +) +async def test_expose_headers_is_appended_not_clobbered( + ds, otel_spans, existing, expected +): + plugin = _ExposeHeadersPlugin(existing) + ds.pm.register(plugin, name="expose-headers-plugin") + try: + response = await ds.client.get("/") + finally: + ds.pm.unregister(name="expose-headers-plugin") + assert response.headers.get_list("access-control-expose-headers") == [expected] + assert "traceresponse" in response.headers