This commit is contained in:
Alex Garcia 2026-10-13 00:40:29 -04:00 • committed by GitHub
commit 9cb8e37ad8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 180 additions and 1 deletions

View file

@ -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

View file

@ -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)

View file

@ -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-<trace-id>-<span-id>-<flags>
Server-Timing: traceparent;desc="00-<trace-id>-<span-id>-<flags>"
``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

View file

@ -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"
)

View file

@ -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