mirror of
https://github.com/simonw/datasette.git
synced 2026-09-16 13:34:07 +02:00
Give every span a request to belong to
Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9231a6c564
commit
7bc91bdcfc
6 changed files with 812 additions and 50 deletions
344
tests/test_http_span.py
Normal file
344
tests/test_http_span.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""
|
||||
The HTTP request span.
|
||||
|
||||
`tests/test_telemetry_registry.py` already pins the span's name shape, kind
|
||||
and attribute keys against literals, so this file deliberately does not
|
||||
repeat that. What it covers is the three properties of the middleware that
|
||||
the registry conformance test structurally cannot see:
|
||||
|
||||
- **where the middleware sits.** Outermost is the entire point - moving it
|
||||
inside the plugin `asgi_wrapper()` loop leaves plugin middleware creating
|
||||
orphan root traces, which is the problem this span exists to fix, and every
|
||||
attribute assertion still passes.
|
||||
- **method clamping**, which a workload of ordinary GETs can never exercise.
|
||||
- **the query string never being recorded**, which only fails if a request
|
||||
actually carries one.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip("opentelemetry.sdk")
|
||||
|
||||
from opentelemetry.trace import SpanKind, StatusCode
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.app import Datasette
|
||||
from datasette.telemetry import TelemetryMiddleware, tracer
|
||||
|
||||
# Named in-memory databases are shared-cache: two Datasette instances given
|
||||
# the same name share one SQLite database and the second `create table`
|
||||
# fails.
|
||||
_names = itertools.count()
|
||||
|
||||
|
||||
PLUGIN_MIDDLEWARE_SPAN = "test.plugin.middleware"
|
||||
|
||||
|
||||
class _MiddlewarePlugin:
|
||||
"A plugin asgi_wrapper() that creates a span, standing in for a real one."
|
||||
|
||||
__name__ = "HttpSpanMiddlewarePlugin"
|
||||
|
||||
@hookimpl
|
||||
def asgi_wrapper(self, datasette):
|
||||
def wrap(app):
|
||||
async def wrapped(scope, receive, send):
|
||||
with tracer.start_as_current_span(PLUGIN_MIDDLEWARE_SPAN):
|
||||
await app(scope, receive, send)
|
||||
|
||||
return wrapped
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
class _RaisingMiddlewarePlugin:
|
||||
"""
|
||||
A plugin asgi_wrapper() that raises.
|
||||
|
||||
`route_path` converts almost every exception into a 500 itself, so an
|
||||
exception escaping into the request span is only reachable from *outside*
|
||||
the router - a plugin wrapper, or a failure inside the 500 handler.
|
||||
"""
|
||||
|
||||
__name__ = "HttpSpanRaisingMiddlewarePlugin"
|
||||
|
||||
def __init__(self, call_app_first):
|
||||
self.call_app_first = call_app_first
|
||||
|
||||
@hookimpl
|
||||
def asgi_wrapper(self, datasette):
|
||||
call_app_first = self.call_app_first
|
||||
|
||||
def wrap(app):
|
||||
async def wrapped(scope, receive, send):
|
||||
if call_app_first:
|
||||
await app(scope, receive, send)
|
||||
raise RuntimeError("wrapper exploded")
|
||||
|
||||
return wrapped
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
class _BoomPlugin:
|
||||
"A route that raises, which route_path turns into a 500."
|
||||
|
||||
__name__ = "HttpSpanBoomPlugin"
|
||||
|
||||
@hookimpl
|
||||
def register_routes(self):
|
||||
return [(r"^/-/http-span-boom$", lambda: 1 / 0)]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ds():
|
||||
name = f"httpspan{next(_names)}"
|
||||
instance = Datasette(memory=True)
|
||||
instance.add_memory_database(name)
|
||||
await instance.invoke_startup()
|
||||
await instance.get_database(name).execute_write(
|
||||
"create table t (id integer primary key, v text)"
|
||||
)
|
||||
instance.db_name = name
|
||||
try:
|
||||
yield instance
|
||||
finally:
|
||||
instance.close()
|
||||
|
||||
|
||||
def _server_spans(otel_spans):
|
||||
return [
|
||||
span for span in otel_spans.get_finished_spans() if span.kind is SpanKind.SERVER
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_asgi_wrapper_middleware_runs_inside_the_request_span(
|
||||
ds, otel_spans
|
||||
):
|
||||
"""
|
||||
The placement check.
|
||||
|
||||
A span created by a plugin `asgi_wrapper()` must be a *child* of the
|
||||
request span. If the middleware is mounted anywhere inside the plugin
|
||||
loop the two swap places - the plugin's span becomes the root and the
|
||||
request span its child - which is exactly the orphaning this is meant to
|
||||
prevent, and which no attribute assertion notices.
|
||||
"""
|
||||
ds.pm.register(_MiddlewarePlugin(), name="httpspan-middleware")
|
||||
try:
|
||||
otel_spans.clear()
|
||||
response = await ds.client.get(f"/{ds.db_name}/t")
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
ds.pm.unregister(name="httpspan-middleware")
|
||||
|
||||
spans = otel_spans.get_finished_spans()
|
||||
server = [span for span in spans if span.kind is SpanKind.SERVER]
|
||||
assert len(server) == 1, "expected exactly one SERVER span per request"
|
||||
request_span = server[0]
|
||||
assert request_span.parent is None, "the request span should be the trace root"
|
||||
|
||||
plugin_spans = [span for span in spans if span.name == PLUGIN_MIDDLEWARE_SPAN]
|
||||
assert len(plugin_spans) == 1
|
||||
assert plugin_spans[0].parent is not None
|
||||
assert plugin_spans[0].parent.span_id == request_span.context.span_id
|
||||
assert plugin_spans[0].context.trace_id == request_span.context.trace_id
|
||||
|
||||
# And the database work is in the same trace, not off on its own.
|
||||
queries = [span for span in spans if span.name == "db.query"]
|
||||
assert queries, "a table page should have issued at least one query"
|
||||
for query in queries:
|
||||
assert query.context.trace_id == request_span.context.trace_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecognised_method_is_clamped(ds, otel_spans):
|
||||
"""
|
||||
Anyone can send `FROB / HTTP/1.1`. An unclamped method is an unbounded
|
||||
dimension a client controls, so semantic conventions map anything off the
|
||||
known list to `_OTHER` - and the span name is the method, so an unclamped
|
||||
one would put attacker-supplied text in the span name too.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
await ds.client.request("FROB", f"/{ds.db_name}/t")
|
||||
server = _server_spans(otel_spans)
|
||||
assert len(server) == 1
|
||||
assert server[0].name == "_OTHER"
|
||||
assert server[0].attributes["http.request.method"] == "_OTHER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_known_method_is_not_clamped(ds, otel_spans):
|
||||
"The other half of clamping: a real method must survive it verbatim."
|
||||
otel_spans.clear()
|
||||
await ds.client.get(f"/{ds.db_name}/t")
|
||||
server = _server_spans(otel_spans)
|
||||
assert len(server) == 1
|
||||
assert server[0].name == "GET"
|
||||
assert server[0].attributes["http.request.method"] == "GET"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_query_string_is_never_recorded(ds, otel_spans):
|
||||
"""
|
||||
Datasette puts user-supplied SQL in `?sql=` and canned query parameters in
|
||||
the query string, so no span may carry it. Asserting on the absence of a
|
||||
`url.query` key alone would not catch it arriving under some other name,
|
||||
so this searches every attribute value of every span for the marker.
|
||||
"""
|
||||
marker = "canary-9f2b1c"
|
||||
otel_spans.clear()
|
||||
await ds.client.get(f"/{ds.db_name}/t?_facet=v&_nosuch={marker}")
|
||||
spans = otel_spans.get_finished_spans()
|
||||
assert _server_spans(otel_spans), "no request span was emitted"
|
||||
leaked = [
|
||||
f"{span.name} -> {key}={value!r}"
|
||||
for span in spans
|
||||
for key, value in (span.attributes or {}).items()
|
||||
if marker in str(value) or key == "url.query"
|
||||
]
|
||||
assert not leaked, "the query string reached a span attribute: " + ", ".join(leaked)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_url_path_is_recorded_without_the_query_string(ds, otel_spans):
|
||||
otel_spans.clear()
|
||||
await ds.client.get(f"/{ds.db_name}/t?_facet=v")
|
||||
server = _server_spans(otel_spans)
|
||||
assert len(server) == 1
|
||||
assert server[0].attributes["url.path"] == f"/{ds.db_name}/t"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escaping_exception_sets_error_type_and_reraises(ds, otel_spans):
|
||||
"""
|
||||
An exception that gets past `route_path` must be recorded, not swallowed.
|
||||
|
||||
No response ever started, so there is no status code to record either.
|
||||
"""
|
||||
ds.pm.register(
|
||||
_RaisingMiddlewarePlugin(call_app_first=False), name="httpspan-raiser"
|
||||
)
|
||||
try:
|
||||
otel_spans.clear()
|
||||
with pytest.raises(RuntimeError):
|
||||
await ds.client.get(f"/{ds.db_name}/t")
|
||||
finally:
|
||||
ds.pm.unregister(name="httpspan-raiser")
|
||||
server = _server_spans(otel_spans)
|
||||
assert len(server) == 1
|
||||
assert server[0].attributes["error.type"] == "RuntimeError"
|
||||
assert "http.response.status_code" not in server[0].attributes
|
||||
assert server[0].status.status_code is StatusCode.ERROR
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_escaping_exception_beats_the_status_code_for_error_type(
|
||||
ds, otel_spans
|
||||
):
|
||||
"""
|
||||
Both paths can fire on one request: a 500 response is sent and *then*
|
||||
something raises on the way out. The `finally` block runs while the
|
||||
exception is propagating, so without the guard it would overwrite the
|
||||
exception's class name with the string "500" - strictly less information
|
||||
about what actually went wrong.
|
||||
"""
|
||||
ds.pm.register(_BoomPlugin(), name="httpspan-boom")
|
||||
ds.pm.register(
|
||||
_RaisingMiddlewarePlugin(call_app_first=True), name="httpspan-raiser"
|
||||
)
|
||||
try:
|
||||
otel_spans.clear()
|
||||
with pytest.raises(RuntimeError):
|
||||
await ds.client.get("/-/http-span-boom")
|
||||
finally:
|
||||
ds.pm.unregister(name="httpspan-raiser")
|
||||
ds.pm.unregister(name="httpspan-boom")
|
||||
server = _server_spans(otel_spans)
|
||||
assert len(server) == 1
|
||||
# The 500 really was sent, so the status is still recorded ...
|
||||
assert server[0].attributes["http.response.status_code"] == 500
|
||||
# ... but error.type names the exception, not the status.
|
||||
assert server[0].attributes["error.type"] == "RuntimeError"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_404_is_not_an_error(ds, otel_spans):
|
||||
"""
|
||||
Per semantic conventions a 4xx is the client's mistake, not the server's,
|
||||
so a SERVER span must record the status and leave both its own status and
|
||||
`error.type` alone. Datasette 404s are routine - every missing table, and
|
||||
every bot probing for /wp-login.php - so treating them as errors would
|
||||
drown a real 500 in noise.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
response = await ds.client.get("/no-such-database-at-all")
|
||||
assert response.status_code == 404
|
||||
server = _server_spans(otel_spans)
|
||||
assert len(server) == 1
|
||||
assert server[0].attributes["http.response.status_code"] == 404
|
||||
assert "error.type" not in server[0].attributes
|
||||
assert server[0].status.status_code is StatusCode.UNSET
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_the_first_http_response_start_is_recorded(otel_spans):
|
||||
"""
|
||||
The `send` wrapper keeps the first status it sees.
|
||||
|
||||
Nothing in Datasette sends two `http.response.start` messages, so this
|
||||
drives the middleware directly rather than pretending a request could
|
||||
reach it. Without the guard a misbehaving plugin's second start message
|
||||
would silently replace the status the client actually received.
|
||||
"""
|
||||
|
||||
async def two_starts(scope, receive, send):
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.start", "status": 503, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
middleware = TelemetryMiddleware(two_starts)
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/twice",
|
||||
"raw_path": b"/twice",
|
||||
"scheme": "http",
|
||||
"headers": [],
|
||||
}
|
||||
otel_spans.clear()
|
||||
await middleware(scope, None, lambda message: asyncio.sleep(0))
|
||||
server = _server_spans(otel_spans)
|
||||
assert len(server) == 1
|
||||
assert server[0].attributes["http.response.status_code"] == 200
|
||||
assert "error.type" not in server[0].attributes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_scope_passes_through_unspanned(otel_spans):
|
||||
"""
|
||||
`AsgiLifespan` sits *inside* this middleware, so the scope-type check has
|
||||
to come first or startup and shutdown events never reach it. A SERVER
|
||||
span for a lifespan scope is the symptom of that check being missing or
|
||||
late.
|
||||
"""
|
||||
instance = Datasette(memory=True)
|
||||
app = instance.app()
|
||||
events = iter([{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}])
|
||||
sent = []
|
||||
|
||||
async def receive():
|
||||
return next(events)
|
||||
|
||||
async def send(message):
|
||||
sent.append(message["type"])
|
||||
|
||||
otel_spans.clear()
|
||||
await app({"type": "lifespan"}, receive, send)
|
||||
assert sent == ["lifespan.startup.complete", "lifespan.shutdown.complete"]
|
||||
assert not _server_spans(otel_spans)
|
||||
|
|
@ -30,6 +30,9 @@ import pytest_asyncio
|
|||
|
||||
pytest.importorskip("opentelemetry.sdk")
|
||||
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette import telemetry_registry as reg
|
||||
from datasette.app import Datasette
|
||||
from datasette.database import QueryInterrupted
|
||||
|
|
@ -67,6 +70,31 @@ EXPECTED_ATTRIBUTES = {
|
|||
}
|
||||
EXPECTED_SPANS = set(EXPECTED_ATTRIBUTES)
|
||||
|
||||
# The HTTP request span is handled separately because its name is composed at
|
||||
# runtime - it is the request method - so there is no fixed string to pin it
|
||||
# to. What can still be pinned, and is what a dashboard depends on, is the
|
||||
# shape of the name and the attribute keys. The workload below only issues
|
||||
# GETs, so a change that stopped clamping the method, or that started naming
|
||||
# the span after the path, fails here.
|
||||
EXPECTED_HTTP_SPAN_NAME = "{http.request.method}"
|
||||
EXPECTED_HTTP_SPAN_NAMES = {"GET"}
|
||||
EXPECTED_HTTP_ATTRIBUTES = {
|
||||
"http.request.method",
|
||||
"url.path",
|
||||
"url.scheme",
|
||||
"server.address",
|
||||
"user_agent.original",
|
||||
"http.response.status_code",
|
||||
"error.type",
|
||||
}
|
||||
|
||||
# The registry's own name for the request span is that template, not anything
|
||||
# that appears on the wire.
|
||||
EXPECTED_REGISTRY_ATTRIBUTES = dict(
|
||||
EXPECTED_ATTRIBUTES, **{EXPECTED_HTTP_SPAN_NAME: EXPECTED_HTTP_ATTRIBUTES}
|
||||
)
|
||||
EXPECTED_REGISTRY_NAMES = set(EXPECTED_REGISTRY_ATTRIBUTES)
|
||||
|
||||
# Named in-memory databases are shared-cache, so two Datasette instances using
|
||||
# the same name share one SQLite database - and the second `create table`
|
||||
# fails. Every workload below therefore gets its own name.
|
||||
|
|
@ -77,6 +105,23 @@ def _unique(prefix):
|
|||
return f"{prefix}{next(_names)}"
|
||||
|
||||
|
||||
class _BoomPlugin:
|
||||
"""
|
||||
A route that raises.
|
||||
|
||||
`error.type` on the request span is only ever set by a 5xx, and nothing
|
||||
in Datasette returns one on a healthy instance - `route_path` converts
|
||||
exceptions into a 500 itself, so the workload has to supply the
|
||||
exception.
|
||||
"""
|
||||
|
||||
__name__ = "TelemetryRegistryBoomPlugin"
|
||||
|
||||
@hookimpl
|
||||
def register_routes(self):
|
||||
return [(r"^/-/telemetry-registry-boom$", lambda: 1 / 0)]
|
||||
|
||||
|
||||
async def exercise():
|
||||
"""
|
||||
Drive enough of Datasette to emit every span and attribute the registry
|
||||
|
|
@ -140,37 +185,62 @@ async def exercise():
|
|||
custom_time_limit=1,
|
||||
)
|
||||
|
||||
# db.collection.name - set only by views that already know their table
|
||||
# db.collection.name - set only by views that already know their table.
|
||||
# These requests are also what produces the HTTP request span and its
|
||||
# http.request.method / url.path / url.scheme / server.address /
|
||||
# user_agent.original / http.response.status_code attributes.
|
||||
assert (await ds.client.get(f"/{name}/t?_facet=v")).status_code == 200
|
||||
assert (await ds.client.get(f"/{name}/t/1.json")).status_code == 200
|
||||
|
||||
# error.type on the request span, which only a 5xx sets
|
||||
ds.pm.register(_BoomPlugin(), name="telemetry-registry-boom")
|
||||
try:
|
||||
response = await ds.client.get("/-/telemetry-registry-boom")
|
||||
assert response.status_code == 500
|
||||
finally:
|
||||
ds.pm.unregister(name="telemetry-registry-boom")
|
||||
return ds
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def emitted(otel_spans):
|
||||
"Every span name and (span name, attribute key) pair a broad workload emits."
|
||||
"""
|
||||
Every (span name, span kind, attribute keys) triple a broad workload emits.
|
||||
|
||||
The kind is carried because the request span's name is composed at
|
||||
runtime, so `span_for()` resolves it by kind instead.
|
||||
"""
|
||||
# otel_spans has already cleared the exporter, and nothing is cleared
|
||||
# after this point: the workload's own startup emits datasette.startup.
|
||||
ds = await exercise()
|
||||
spans = otel_spans.get_finished_spans()
|
||||
assert spans, "no spans captured - the fixture is not exercising anything"
|
||||
names = set()
|
||||
pairs = set()
|
||||
for span in spans:
|
||||
# str() because span.name is the registry's SpanName instance, and a
|
||||
# set of those would compare equal to literals but read confusingly
|
||||
# in a failure message.
|
||||
names.add(str(span.name))
|
||||
for key in span.attributes or {}:
|
||||
pairs.add((str(span.name), str(key)))
|
||||
# str() because span.name is the registry's SpanName instance, and a set
|
||||
# of those would compare equal to literals but read confusingly in a
|
||||
# failure message.
|
||||
collected = tuple(
|
||||
(
|
||||
str(span.name),
|
||||
span.kind,
|
||||
frozenset(str(key) for key in span.attributes or {}),
|
||||
)
|
||||
for span in spans
|
||||
)
|
||||
ds.close()
|
||||
return {"names": names, "pairs": pairs}
|
||||
return collected
|
||||
|
||||
|
||||
def _keys_by_span(pairs):
|
||||
def _partition(emitted):
|
||||
"The statically named spans, and the dynamically named request spans."
|
||||
static = [record for record in emitted if record[1] is not SpanKind.SERVER]
|
||||
server = [record for record in emitted if record[1] is SpanKind.SERVER]
|
||||
return static, server
|
||||
|
||||
|
||||
def _keys_by_span(records):
|
||||
by_span = {}
|
||||
for span_name, key in pairs:
|
||||
by_span.setdefault(span_name, set()).add(key)
|
||||
for name, _kind, keys in records:
|
||||
by_span.setdefault(name, set()).update(keys)
|
||||
return by_span
|
||||
|
||||
|
||||
|
|
@ -182,27 +252,34 @@ async def test_workload_emits_exactly_the_expected_names(emitted):
|
|||
Not derived from the registry, so this is what catches a rename that the
|
||||
registry and the call sites make together.
|
||||
"""
|
||||
assert emitted["names"] == EXPECTED_SPANS
|
||||
by_span = _keys_by_span(emitted["pairs"])
|
||||
assert {name: by_span.get(name, set()) for name in emitted["names"]} == (
|
||||
EXPECTED_ATTRIBUTES
|
||||
)
|
||||
static, server = _partition(emitted)
|
||||
by_span = _keys_by_span(static)
|
||||
assert set(by_span) == EXPECTED_SPANS
|
||||
assert by_span == EXPECTED_ATTRIBUTES
|
||||
|
||||
assert server, "the workload made HTTP requests but no SERVER span was emitted"
|
||||
server_keys = _keys_by_span(server)
|
||||
assert set(server_keys) == EXPECTED_HTTP_SPAN_NAMES
|
||||
union = set()
|
||||
for keys in server_keys.values():
|
||||
union |= keys
|
||||
assert union == EXPECTED_HTTP_ATTRIBUTES
|
||||
|
||||
|
||||
def test_registry_matches_the_expected_names():
|
||||
"The other half of the rename check: the registry against the same literals."
|
||||
assert {str(span) for span in reg.SPANS} == EXPECTED_SPANS
|
||||
assert {str(span) for span in reg.SPANS} == EXPECTED_REGISTRY_NAMES
|
||||
for span in reg.SPANS:
|
||||
assert {str(attribute) for attribute in span.attributes} == EXPECTED_ATTRIBUTES[
|
||||
str(span)
|
||||
], f"{span} attributes have drifted"
|
||||
assert {
|
||||
str(attribute) for attribute in span.attributes
|
||||
} == EXPECTED_REGISTRY_ATTRIBUTES[str(span)], f"{span} attributes have drifted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_emitted_span_is_registered(emitted):
|
||||
"A span added without a registry entry would be missing from the docs."
|
||||
unregistered = sorted(
|
||||
name for name in emitted["names"] if reg.span_for(name) is None
|
||||
{name for name, kind, _ in emitted if reg.span_for(name, kind) is None}
|
||||
)
|
||||
assert (
|
||||
not unregistered
|
||||
|
|
@ -213,9 +290,12 @@ async def test_every_emitted_span_is_registered(emitted):
|
|||
async def test_every_emitted_attribute_is_registered(emitted):
|
||||
"An attribute added without a registry entry would be missing from the docs."
|
||||
unregistered = sorted(
|
||||
f"{span_name} -> {key}"
|
||||
for span_name, key in emitted["pairs"]
|
||||
if not reg.attribute_allowed(reg.span_for(span_name), key)
|
||||
{
|
||||
f"{name} -> {key}"
|
||||
for name, kind, keys in emitted
|
||||
for key in keys
|
||||
if not reg.attribute_allowed(reg.span_for(name, kind), key)
|
||||
}
|
||||
)
|
||||
assert (
|
||||
not unregistered
|
||||
|
|
@ -230,11 +310,10 @@ async def test_every_registered_span_is_emitted(emitted):
|
|||
The direction nothing else catches: the docs must not describe a span that
|
||||
no longer exists.
|
||||
"""
|
||||
missing = sorted(
|
||||
str(span)
|
||||
for span in reg.SPANS
|
||||
if not any(reg.span_for(name) is span for name in emitted["names"])
|
||||
)
|
||||
# By identity, not by name: a dynamic entry's own string never appears on
|
||||
# the wire, so comparing strings would be comparing the wrong things.
|
||||
resolved = {id(reg.span_for(name, kind)) for name, kind, _ in emitted}
|
||||
missing = sorted(str(span) for span in reg.SPANS if id(span) not in resolved)
|
||||
assert not missing, (
|
||||
f"these spans are documented but never emitted by the workload: {missing}. "
|
||||
"Either the instrumentation was removed, or exercise() no longer reaches it."
|
||||
|
|
@ -253,10 +332,14 @@ async def test_every_registered_attribute_is_emitted(emitted):
|
|||
new attribute only appears in some rare case, extend exercise() to reach
|
||||
that case.
|
||||
"""
|
||||
by_span = _keys_by_span(emitted["pairs"])
|
||||
by_entry = {}
|
||||
for name, kind, keys in emitted:
|
||||
entry = reg.span_for(name, kind)
|
||||
if entry is not None:
|
||||
by_entry.setdefault(id(entry), set()).update(keys)
|
||||
missing = []
|
||||
for span in reg.SPANS:
|
||||
emitted_keys = by_span.get(str(span), set())
|
||||
emitted_keys = by_entry.get(id(span), set())
|
||||
for attribute in span.attributes:
|
||||
if attribute not in emitted_keys:
|
||||
missing.append(f"{span} -> {attribute}")
|
||||
|
|
@ -291,6 +374,25 @@ def test_registry_entries_are_usable_as_plain_strings():
|
|||
assert f"{reg.DB_QUERY}.execute" == "db.query.execute"
|
||||
|
||||
|
||||
def test_dynamic_span_lookup():
|
||||
"""
|
||||
`dynamic=True` matching, which is how the request span resolves.
|
||||
|
||||
The last two assertions are the ones worth having: a dynamic entry must
|
||||
not swallow a span that does have a registered name, and must not match at
|
||||
all when the caller supplies no kind - otherwise every unregistered span
|
||||
in the suite would silently resolve to the request span and the
|
||||
emitted-but-not-registered direction would stop catching anything.
|
||||
"""
|
||||
assert reg.span_for("GET", SpanKind.SERVER) is reg.HTTP_REQUEST
|
||||
assert reg.span_for("POST /^/(?P<database>[^/]+)$", SpanKind.SERVER) is (
|
||||
reg.HTTP_REQUEST
|
||||
)
|
||||
assert reg.span_for("GET") is None
|
||||
assert reg.span_for("anything at all", SpanKind.INTERNAL) is None
|
||||
assert reg.span_for("db.query", SpanKind.SERVER) is reg.DB_QUERY
|
||||
|
||||
|
||||
def test_span_and_attribute_lookup():
|
||||
assert reg.span_for("db.query") is reg.DB_QUERY
|
||||
assert reg.span_for("datasette.startup") is reg.STARTUP
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue