mirror of
https://github.com/simonw/datasette.git
synced 2026-10-18 15:26:57 +02:00
Emit traceresponse and Server-Timing trace context response headers
While the request span is recording, TelemetryMiddleware adds `traceresponse: 00-<trace>-<span>-<flags>` and `Server-Timing: traceparent;desc="..."` to http.response.start, and appends `traceresponse` to an existing Access-Control-Expose-Headers (never overwriting it, never adding one when CORS is off, leaving `*` alone). Hand-rolled because TraceResponsePropagator lives in opentelemetry-instrumentation, not the API. No Timing-Allow-Origin, no new setting. test_cors now expects "Link, traceresponse", since the test suite always has a recording provider installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cec5e6b2ef
commit
86bdc7515f
5 changed files with 180 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
132
tests/test_telemetry_response_headers.py
Normal file
132
tests/test_telemetry_response_headers.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue