mirror of
https://github.com/simonw/datasette.git
synced 2026-09-27 04:14:25 +02:00
parent
90f2f1910f
commit
8e17729ff3
14 changed files with 412 additions and 1451 deletions
|
|
@ -58,8 +58,6 @@ def find_free_port():
|
|||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
# The otel fixtures moved to datasette.telemetry_testing, which is public
|
||||
# plugin API - core's suite consumes it exactly the way a plugin's would.
|
||||
from datasette.telemetry_testing import ( # noqa: F401
|
||||
MetricsCollector,
|
||||
otel_meter_provider,
|
||||
|
|
@ -183,11 +181,8 @@ def pytest_collection_modifyitems(config, items):
|
|||
move_to_front(items, "test_spatialite_error_if_attempt_to_open_spatialite")
|
||||
move_to_front(items, "test_package")
|
||||
move_to_front(items, "test_package_with_port")
|
||||
# Same reason: this one shells out to a fresh interpreter. Late in a serial
|
||||
# run the pytest process holds enough threads that the fork half of
|
||||
# subprocess' fork+exec crashes the interpreter on macOS/CPython 3.13
|
||||
# (SIGSEGV/SIGBUS inside _execute_child). Reproduces with any subprocess
|
||||
# call placed there, on an unmodified tree - running it first avoids it.
|
||||
# These start subprocesses, which can crash on macOS/CPython 3.13 late in
|
||||
# a test run once the pytest process has started many threads
|
||||
move_to_front(items, "test_datasette_package_never_imports_the_sdk")
|
||||
move_to_front(items, "test_kit_module_itself_never_imports_the_sdk")
|
||||
move_to_front(items, "test_no_provider_takes_the_fast_path")
|
||||
|
|
|
|||
|
|
@ -1,23 +1,6 @@
|
|||
"""
|
||||
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 properties of the middleware and of the
|
||||
router's `http.route` enrichment 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.
|
||||
- **which span the route lands on**, which only diverges once something else
|
||||
has made a span current.
|
||||
- **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.
|
||||
- **the span outliving a streamed response body**, which only a paging export
|
||||
can distinguish from ending far too early.
|
||||
Tests for the HTTP request span created by TelemetryMiddleware and the
|
||||
`http.route` enrichment added by the router.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -51,9 +34,8 @@ from datasette.telemetry import (
|
|||
)
|
||||
from datasette.utils import resolve_routes
|
||||
|
||||
# Named in-memory databases are shared-cache: two Datasette instances given
|
||||
# the same name share one SQLite database and the second `create table`
|
||||
# fails.
|
||||
# Named in-memory databases are shared between instances, so each fixture
|
||||
# needs a unique name.
|
||||
_names = itertools.count()
|
||||
|
||||
|
||||
|
|
@ -61,7 +43,7 @@ PLUGIN_MIDDLEWARE_SPAN = "test.plugin.middleware"
|
|||
|
||||
|
||||
class _MiddlewarePlugin:
|
||||
"A plugin asgi_wrapper() that creates a span, standing in for a real one."
|
||||
"A plugin asgi_wrapper() that creates a span."
|
||||
|
||||
__name__ = "HttpSpanMiddlewarePlugin"
|
||||
|
||||
|
|
@ -79,11 +61,8 @@ class _MiddlewarePlugin:
|
|||
|
||||
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.
|
||||
A plugin asgi_wrapper() that raises. `route_path` turns most exceptions
|
||||
into a 500, so this is how an exception reaches the request span.
|
||||
"""
|
||||
|
||||
__name__ = "HttpSpanRaisingMiddlewarePlugin"
|
||||
|
|
@ -135,18 +114,12 @@ async def ds():
|
|||
@pytest_asyncio.fixture
|
||||
async def ds_paging():
|
||||
"""
|
||||
An instance whose table is bigger than `max_returned_rows`.
|
||||
|
||||
That is what makes `?_stream=1` genuinely page: `stream_csv` loops calling
|
||||
`fetch_data` for each page *inside* the response body send, so the trace
|
||||
contains `db.query` spans that start after the response has begun. On a
|
||||
table that fits in one page every query finishes before the body starts
|
||||
and the span-covers-the-body assertion cannot fail.
|
||||
An instance whose table is bigger than `max_returned_rows`, so a
|
||||
`?_stream=1` export runs queries for later pages during the body send.
|
||||
"""
|
||||
name = f"httpspanpaging{next(_names)}"
|
||||
# Both settings matter. `?_stream=1` forces `_size=max`, which is
|
||||
# `max_returned_rows` - so lowering only that gives one page of five rows
|
||||
# and no `next` token, and the export never loops.
|
||||
# Both settings are needed: lowering only max_returned_rows gives a
|
||||
# single page with no `next` token.
|
||||
instance = Datasette(
|
||||
memory=True, settings={"max_returned_rows": 5, "default_page_size": 3}
|
||||
)
|
||||
|
|
@ -182,13 +155,8 @@ 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.
|
||||
Spans created by plugin asgi_wrapper() middleware are children of the
|
||||
request span.
|
||||
"""
|
||||
ds.pm.register(_MiddlewarePlugin(), name="httpspan-middleware")
|
||||
try:
|
||||
|
|
@ -210,7 +178,7 @@ async def test_plugin_asgi_wrapper_middleware_runs_inside_the_request_span(
|
|||
assert plugin_spans[0].parent.span_id == server_span.context.span_id
|
||||
assert plugin_spans[0].context.trace_id == server_span.context.trace_id
|
||||
|
||||
# And the database work is in the same trace, not off on its own.
|
||||
# Database spans are in the same trace.
|
||||
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:
|
||||
|
|
@ -220,15 +188,8 @@ async def test_plugin_asgi_wrapper_middleware_runs_inside_the_request_span(
|
|||
@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`.
|
||||
|
||||
The span name is checked too, and it is the reason the router clamps the
|
||||
method a second time when it renames the span: the middleware's clamping
|
||||
protects the attribute, but the name is rebuilt from `request.method` in
|
||||
`route_path`, which is the raw client string. An unclamped rename would
|
||||
put attacker-supplied text straight back into the span name.
|
||||
Unknown methods are recorded as `_OTHER` in both the attribute and the
|
||||
span name, which the router rebuilds from the raw `request.method`.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
await ds.client.request("FROB", f"/{ds.db_name}/t")
|
||||
|
|
@ -240,7 +201,7 @@ async def test_unrecognised_method_is_clamped(ds, otel_spans):
|
|||
|
||||
@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."
|
||||
"Known methods are recorded unchanged."
|
||||
otel_spans.clear()
|
||||
await ds.client.get(f"/{ds.db_name}/t")
|
||||
server = _server_spans(otel_spans)
|
||||
|
|
@ -251,12 +212,7 @@ async def test_known_method_is_not_clamped(ds, otel_spans):
|
|||
|
||||
@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.
|
||||
"""
|
||||
"No attribute on any span contains the query string."
|
||||
marker = "canary-9f2b1c"
|
||||
otel_spans.clear()
|
||||
await ds.client.get(f"/{ds.db_name}/t?_facet=v&_nosuch={marker}")
|
||||
|
|
@ -283,9 +239,8 @@ async def test_url_path_is_recorded_without_the_query_string(ds, otel_spans):
|
|||
@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.
|
||||
An exception that escapes `route_path` is recorded and re-raised. No
|
||||
response started, so no status code is recorded.
|
||||
"""
|
||||
ds.pm.register(
|
||||
_RaisingMiddlewarePlugin(call_app_first=False), name="httpspan-raiser"
|
||||
|
|
@ -308,11 +263,8 @@ 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.
|
||||
A 500 response followed by an exception records the exception class as
|
||||
`error.type`, not "500".
|
||||
"""
|
||||
ds.pm.register(_BoomPlugin(), name="httpspan-boom")
|
||||
ds.pm.register(
|
||||
|
|
@ -327,24 +279,16 @@ async def test_an_escaping_exception_beats_the_status_code_for_error_type(
|
|||
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.
|
||||
|
||||
Note this 404 *does* match a route: `/no-such-database-at-all` matches the
|
||||
database pattern and the view then raises `NotFound`. Most Datasette 404s
|
||||
are that shape rather than the unrouted one below.
|
||||
A 4xx records the status code but no `error.type` or error status.
|
||||
`/no-such-database-at-all` matches the database route, so `http.route`
|
||||
is still set.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
response = await ds.client.get("/no-such-database-at-all")
|
||||
|
|
@ -354,7 +298,6 @@ async def test_a_404_is_not_an_error(ds, otel_spans):
|
|||
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
|
||||
# Route enrichment must not be gated on a successful response.
|
||||
assert "http.route" in server[0].attributes
|
||||
assert server[0].name != "GET"
|
||||
|
||||
|
|
@ -362,14 +305,8 @@ async def test_a_404_is_not_an_error(ds, otel_spans):
|
|||
@pytest.mark.asyncio
|
||||
async def test_an_unrouted_404_has_no_route_and_a_bare_method_name(ds, otel_spans):
|
||||
"""
|
||||
When no route matches there is nothing to set `http.route` to, so the span
|
||||
keeps the bare method name it was given at the edge - which is exactly the
|
||||
fallback semantic conventions specify for an unknown route.
|
||||
|
||||
`/a/b/c/d/e` is used rather than a plausible-looking missing name because
|
||||
Datasette's route table is greedy: `/no-such-database-at-all` matches the
|
||||
database pattern, and `/-/nope/deeper` matches the row pattern. Only a
|
||||
path deeper than any route matches nothing at all.
|
||||
With no matching route the span keeps the bare method name. Most missing
|
||||
paths still match a route, so this uses a path deeper than any route.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
response = await ds.client.get("/a/b/c/d/e")
|
||||
|
|
@ -384,14 +321,7 @@ async def test_an_unrouted_404_has_no_route_and_a_bare_method_name(ds, otel_span
|
|||
|
||||
@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.
|
||||
"""
|
||||
"The `send` wrapper records the status from the first `http.response.start`."
|
||||
|
||||
async def two_starts(scope, receive, send):
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
|
|
@ -418,10 +348,8 @@ async def test_only_the_first_http_response_start_is_recorded(otel_spans):
|
|||
@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.
|
||||
Lifespan scopes reach `AsgiLifespan`, which sits inside this middleware,
|
||||
without creating a SERVER span.
|
||||
"""
|
||||
instance = Datasette(memory=True)
|
||||
app = instance.app()
|
||||
|
|
@ -442,13 +370,7 @@ async def test_lifespan_scope_passes_through_unspanned(otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_route_is_the_compiled_pattern(ds, otel_spans):
|
||||
"""
|
||||
`http.route` is the route's compiled regex, not a prettified template.
|
||||
|
||||
Asserted against what Datasette's own router resolves rather than against
|
||||
a copied literal, so this pins the *relationship* - the attribute is the
|
||||
matched route - and does not break when a core pattern is edited.
|
||||
"""
|
||||
"`http.route` is the compiled regex of the route Datasette's router resolves."
|
||||
path = f"/{ds.db_name}/t"
|
||||
expected = _route_for(ds, path)
|
||||
otel_spans.clear()
|
||||
|
|
@ -457,9 +379,7 @@ async def test_http_route_is_the_compiled_pattern(ds, otel_spans):
|
|||
assert len(server) == 1
|
||||
assert server[0].attributes["http.route"] == expected
|
||||
assert server[0].name == f"GET {expected}"
|
||||
# The pattern really is the ugly one, and that is deliberate - if someone
|
||||
# adds a prettifier this is the assertion that should make them argue for
|
||||
# it rather than slip it in.
|
||||
# The raw pattern, not a prettified template:
|
||||
assert "(?P<database>" in expected
|
||||
|
||||
|
||||
|
|
@ -469,16 +389,8 @@ async def test_the_route_lands_on_the_request_span_not_a_plugins_current_span(
|
|||
):
|
||||
"""
|
||||
The route is set on the span the middleware started, found through the
|
||||
ASGI scope - not on whatever span happens to be current when routing
|
||||
resolves.
|
||||
|
||||
Those are the same span only until a plugin `asgi_wrapper()` starts one of
|
||||
its own. A plugin wrapper runs *inside* this middleware, so an instrumented
|
||||
plugin makes its span current for the whole request: reading the current
|
||||
span in `route_path` renames that plugin's INTERNAL span to
|
||||
`GET <route>` and hangs `http.route` off it, while the actual request span
|
||||
keeps a bare method name and never gets the one attribute a trace UI
|
||||
groups requests by. Verified by reproducing it, not by reasoning about it.
|
||||
ASGI scope, not on a plugin `asgi_wrapper()` span that is current during
|
||||
routing.
|
||||
"""
|
||||
ds.pm.register(_MiddlewarePlugin(), name="httpspan-middleware")
|
||||
try:
|
||||
|
|
@ -494,7 +406,7 @@ async def test_the_route_lands_on_the_request_span_not_a_plugins_current_span(
|
|||
assert len(server) == 1
|
||||
assert server[0].attributes["http.route"] == expected
|
||||
assert server[0].name == f"GET {expected}"
|
||||
# And the plugin's span is untouched: same name, no route attribute.
|
||||
# The plugin's span keeps its name and has no route attribute.
|
||||
plugin_spans = [span for span in spans if span.name == PLUGIN_MIDDLEWARE_SPAN]
|
||||
assert len(plugin_spans) == 1
|
||||
assert "http.route" not in (plugin_spans[0].attributes or {})
|
||||
|
|
@ -502,7 +414,7 @@ async def test_the_route_lands_on_the_request_span_not_a_plugins_current_span(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_span_attributes(ds, otel_spans):
|
||||
"The whole attribute set on one ordinary request."
|
||||
"The attributes recorded for an ordinary request."
|
||||
path = f"/{ds.db_name}/t"
|
||||
otel_spans.clear()
|
||||
assert (await ds.client.get(path)).status_code == 200
|
||||
|
|
@ -515,8 +427,7 @@ async def test_request_span_attributes(ds, otel_spans):
|
|||
assert attributes["http.response.status_code"] == 200
|
||||
assert attributes["http.route"] == _route_for(ds, path)
|
||||
assert server[0].status.status_code is StatusCode.UNSET
|
||||
# Never, on any span: an IP is borderline PII and the query string carries
|
||||
# user-supplied SQL.
|
||||
# The client IP address and query string are not recorded.
|
||||
assert "client.address" not in attributes
|
||||
assert "url.query" not in attributes
|
||||
|
||||
|
|
@ -524,12 +435,8 @@ async def test_request_span_attributes(ds, otel_spans):
|
|||
@pytest.mark.asyncio
|
||||
async def test_db_query_spans_are_children_of_the_request_span(ds, otel_spans):
|
||||
"""
|
||||
The point of the whole PR.
|
||||
|
||||
Not just "same trace ID" - every `db.query` span must reach the request
|
||||
span by walking parents, and the request span must be the only root. A
|
||||
stray root would show up in a trace UI as its own single-span trace, which
|
||||
is the state this replaces.
|
||||
Every `db.query` span descends from the request span, which is the only
|
||||
root span.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
assert (await ds.client.get(f"/{ds.db_name}/t?_facet=v")).status_code == 200
|
||||
|
|
@ -550,7 +457,7 @@ async def test_db_query_spans_are_children_of_the_request_span(ds, otel_spans):
|
|||
assert queries, "a faceted table page should have issued queries"
|
||||
for query in queries:
|
||||
assert query.context.trace_id == server_span.context.trace_id
|
||||
# Walk up to the root, which must be the request span.
|
||||
# Walk up to the root, which should be the request span.
|
||||
current = query
|
||||
seen = 0
|
||||
while current.parent is not None:
|
||||
|
|
@ -563,9 +470,8 @@ async def test_db_query_spans_are_children_of_the_request_span(ds, otel_spans):
|
|||
@pytest.mark.asyncio
|
||||
async def test_500_sets_error_status_and_error_type(ds, otel_spans):
|
||||
"""
|
||||
A plain 500 - no exception escaping the app, because `route_path` converts
|
||||
it into a response itself. The status is the only signal the middleware
|
||||
gets, so `error.type` is the status as a string.
|
||||
`route_path` turns the exception into a 500 response, so `error.type` is
|
||||
the status code as a string.
|
||||
"""
|
||||
ds.pm.register(_BoomPlugin(), name="httpspan-boom")
|
||||
try:
|
||||
|
|
@ -584,25 +490,11 @@ async def test_500_sets_error_status_and_error_type(ds, otel_spans):
|
|||
@pytest.mark.asyncio
|
||||
async def test_csv_stream_span_covers_the_body_send(ds_paging, otel_spans):
|
||||
"""
|
||||
The span must not end when the handler returns - it has to cover the
|
||||
response body.
|
||||
The request span covers a streamed CSV body, including queries for later
|
||||
pages that run after the response has started.
|
||||
|
||||
`stream_csv` runs its generator inline inside `AsgiStream.asgi_send`, and
|
||||
that call happens inside the single `await self.app(...)` the middleware
|
||||
makes, so a plain `finally` is enough and no deferred-end machinery is
|
||||
needed. This is the assertion that holds that claim up: a `db.query` that
|
||||
starts during the body send must still finish before the request span
|
||||
does.
|
||||
|
||||
Only meaningful on an export that actually pages, hence `ds_paging` - on a
|
||||
single-page table every query is over before the body begins and this
|
||||
passes however early the span ends. The middle assertion below, that some
|
||||
query *started* after `http.response.start` went out, is what keeps the
|
||||
test honest about that; it is why the app is driven as raw ASGI rather
|
||||
than through `ds.client`, which cannot timestamp the response start.
|
||||
|
||||
`time.time_ns()` is the same clock the SDK stamps spans with, so the two
|
||||
are directly comparable.
|
||||
Driven as raw ASGI to timestamp `http.response.start` with `time.time_ns()`,
|
||||
the clock the SDK uses for spans.
|
||||
"""
|
||||
app = ds_paging.app()
|
||||
body = []
|
||||
|
|
@ -634,7 +526,7 @@ async def test_csv_stream_span_covers_the_body_send(ds_paging, otel_spans):
|
|||
receive,
|
||||
send,
|
||||
)
|
||||
# 40 rows plus a header - the export really did read past one page
|
||||
# 40 rows plus a header, so the export read past the first page
|
||||
assert len(b"".join(body).decode("utf-8").strip().splitlines()) == 41
|
||||
assert response_started_at is not None
|
||||
|
||||
|
|
@ -662,12 +554,8 @@ async def test_csv_stream_span_covers_the_body_send(ds_paging, otel_spans):
|
|||
@pytest.mark.asyncio
|
||||
async def test_inbound_traceparent_becomes_the_parent(ds, otel_spans):
|
||||
"""
|
||||
W3C trace context is extracted with the global propagator, so a request
|
||||
from an already-traced caller continues that trace.
|
||||
|
||||
The sampled flag has to be set: the SDK's default sampler is
|
||||
parentbased_always_on, so a `-00` flag would drop the span and the test
|
||||
would fail for a reason that has nothing to do with propagation.
|
||||
An inbound `traceparent` header continues the caller's trace. It uses the
|
||||
sampled flag (`-01`) because the SDK's default sampler is parent-based.
|
||||
"""
|
||||
trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
|
||||
parent_span_id = "00f067aa0ba902b7"
|
||||
|
|
@ -684,7 +572,7 @@ async def test_inbound_traceparent_becomes_the_parent(ds, otel_spans):
|
|||
assert server_span.parent is not None
|
||||
assert f"{server_span.parent.span_id:016x}" == parent_span_id
|
||||
assert server_span.parent.is_remote
|
||||
# And the database spans joined the caller's trace too, not a new one.
|
||||
# Database spans are in the caller's trace too.
|
||||
queries = [
|
||||
span for span in otel_spans.get_finished_spans() if span.name == "db.query"
|
||||
]
|
||||
|
|
@ -696,20 +584,12 @@ async def test_inbound_traceparent_becomes_the_parent(ds, otel_spans):
|
|||
@pytest.mark.asyncio
|
||||
async def test_user_supplied_sql_in_the_query_string_is_never_recorded(ds, otel_spans):
|
||||
"""
|
||||
The `?sql=` case specifically, which is the one that matters: this is the
|
||||
request where the query string *is* user-supplied SQL, and it reaches a
|
||||
view that runs it. The marker is searched for across every attribute of
|
||||
every span in the trace, not just for a `url.query` key, so recording it
|
||||
under some other name fails too.
|
||||
|
||||
`db.query.text` legitimately contains the SQL - that is documented and
|
||||
deliberate - so the marker is checked against the request span's own
|
||||
attributes, and against `url.*` and `http.*` keys everywhere.
|
||||
SQL from `?sql=` is not recorded on the request span or in any `url.*`
|
||||
or `http.*` attribute. `db.query.text` is expected to contain it.
|
||||
"""
|
||||
marker = "secret_marker_5b1f"
|
||||
otel_spans.clear()
|
||||
# `/{db}?sql=` 302s to the query view, so go straight there - a redirect
|
||||
# would leave the SQL only on a span for a request that never ran it.
|
||||
# `/{db}?sql=` redirects to the query view, so request that directly.
|
||||
response = await ds.client.get(f"/{ds.db_name}/-/query?sql=select+'{marker}'")
|
||||
assert response.status_code == 200
|
||||
spans = otel_spans.get_finished_spans()
|
||||
|
|
@ -723,26 +603,15 @@ async def test_user_supplied_sql_in_the_query_string_is_never_recorded(ds, otel_
|
|||
and (marker in str(value) or str(key) == "url.query")
|
||||
]
|
||||
assert not leaked, "the query string reached a span attribute: " + ", ".join(leaked)
|
||||
# The request really did carry the marker, so the search above had
|
||||
# something to find.
|
||||
# Confirm the query ran with the marker.
|
||||
assert marker in response.text
|
||||
|
||||
|
||||
def test_request_span_skips_a_valid_but_non_recording_span():
|
||||
"""
|
||||
`request_span()` is guarded on `is_recording()`, not on
|
||||
`get_span_context().is_valid`, and this is the case that separates them.
|
||||
|
||||
With no provider installed but an inbound `traceparent`, the API's
|
||||
NoOpTracer hands back a `NonRecordingSpan` carrying the *remote* span
|
||||
context - valid, sampled, and recording nothing. An `is_valid` guard would
|
||||
wave that through and the router would build the name string and call
|
||||
`set_attribute`/`update_name` on a span that discards both.
|
||||
|
||||
Tested at this level deliberately: through a real request the two guards
|
||||
are indistinguishable, because every call the router makes on a
|
||||
NonRecordingSpan is already a no-op. The only difference is the work done
|
||||
to get there, so the guard itself is what has to be asserted on.
|
||||
`request_span()` returns None for a `NonRecordingSpan` with a valid remote
|
||||
span context, which is what an inbound `traceparent` produces with no
|
||||
provider installed.
|
||||
"""
|
||||
remote = SpanContext(
|
||||
trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736,
|
||||
|
|
@ -754,13 +623,13 @@ def test_request_span_skips_a_valid_but_non_recording_span():
|
|||
non_recording = NonRecordingSpan(remote)
|
||||
assert non_recording.is_recording() is False
|
||||
assert request_span({REQUEST_SPAN_SCOPE_KEY: non_recording}) is None
|
||||
# Nothing current, nothing in the scope: the INVALID_SPAN fallback.
|
||||
# No span in the scope and no current span:
|
||||
assert request_span({}) is None
|
||||
# And the case it must not skip.
|
||||
# A recording span is returned:
|
||||
with tracer.start_as_current_span("test.request_span.recording") as span:
|
||||
assert request_span({REQUEST_SPAN_SCOPE_KEY: span}) is span
|
||||
# Falling back to the current span is how an externally installed
|
||||
# SERVER span still gets enriched.
|
||||
# Falls back to the current span, such as one created by another
|
||||
# SERVER instrumentation:
|
||||
assert request_span({}) is span
|
||||
|
||||
|
||||
|
|
@ -820,26 +689,11 @@ NO_PROVIDER_PROGRAM = textwrap.dedent("""
|
|||
|
||||
def test_no_provider_takes_the_fast_path():
|
||||
"""
|
||||
With no `TracerProvider` installed the middleware must hand the
|
||||
application the *original* `send`, not a wrapper - a default Datasette
|
||||
install should pay essentially nothing for instrumentation it is not
|
||||
using.
|
||||
With no `TracerProvider` installed the middleware passes the original
|
||||
`send` to the application, including for requests with a `traceparent`.
|
||||
|
||||
This has to run in a subprocess. The suite's `otel_provider` fixture is
|
||||
session-scoped and autouse, and `set_tracer_provider()` is effectively
|
||||
once-per-process, so in-process every span is recording and the fast path
|
||||
is unreachable.
|
||||
|
||||
The second case, with an inbound `traceparent`, is the one that pins the
|
||||
check itself. With no provider the API's NoOpTracer returns a
|
||||
NonRecordingSpan carrying the *remote* span context: its
|
||||
`get_span_context().is_valid` is True while `is_recording()` is False. A
|
||||
fast path guarded on `is_valid` would therefore silently stop working for
|
||||
exactly the requests that arrive from an already-traced caller - which on
|
||||
a real deployment behind an instrumented proxy is all of them.
|
||||
|
||||
conftest.py's pytest_collection_modifyitems() moves this test to the front
|
||||
of the run by name - if you rename it, rename it there too.
|
||||
Runs in a subprocess because the suite installs a provider for the whole
|
||||
process. conftest.py moves this test to the front of the run by name.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", NO_PROVIDER_PROGRAM],
|
||||
|
|
@ -854,19 +708,15 @@ def test_no_provider_takes_the_fast_path():
|
|||
"entry is the inbound-traceparent case, which fails if the fast path "
|
||||
"is guarded on is_valid instead of is_recording()"
|
||||
)
|
||||
# Same fast path, other observable: nothing is stashed in the scope either.
|
||||
# Nothing is stored in the scope either.
|
||||
assert report["scope_keys"] == [False, False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_client_requests_are_marked(ds, otel_spans):
|
||||
"""
|
||||
An in-process `datasette.client` request runs the full ASGI stack, so it
|
||||
emits its own SERVER span - `datasette.internal_client` marks those so
|
||||
kind-based dashboards can filter the double-count out. A request that
|
||||
arrives through the raw ASGI app (the shape of a real inbound request,
|
||||
without the DatasetteClient wrapper setting the ContextVar) must not
|
||||
carry the attribute.
|
||||
`datasette.internal_client` is set on SERVER spans for `datasette.client`
|
||||
requests, but not for requests made directly to the ASGI app.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
assert (await ds.client.get("/")).status_code == 200
|
||||
|
|
|
|||
|
|
@ -1329,27 +1329,11 @@ async def test_write_thread_context_is_detached_between_tasks(
|
|||
tmp_path, monkeypatch, num_sql_threads
|
||||
):
|
||||
"""
|
||||
The write thread attaches each task's otel Context and must detach it
|
||||
again before picking up the next task. The thread is persistent and
|
||||
shared, so a leaked token would grow that thread's context stack for the
|
||||
rest of the process - and a *wrong*-token detach only logs a warning
|
||||
rather than raising, so "does it throw" cannot catch either mistake.
|
||||
The write thread attaches each task's OpenTelemetry context and detaches
|
||||
it before the next task, including when the task raises an exception.
|
||||
|
||||
Two things are asserted, because neither alone is sufficient:
|
||||
|
||||
1. Each task observes the context value that was current on the event
|
||||
loop when it was queued. This is what fails if the Context is not
|
||||
carried on WriteTask, or is never attached. It does *not* catch a
|
||||
missing detach: attach() replaces the current Context wholesale, so a
|
||||
leftover one from a previous task is simply overwritten.
|
||||
2. The write thread's attach depth is identical at the same point in
|
||||
every task. This is what fails if detach is missing - the stack grows
|
||||
by one per task - and it holds across a task that raises, because the
|
||||
detach lives in a `finally`.
|
||||
|
||||
An otel context value is used rather than a plain contextvars.ContextVar:
|
||||
a plain var set on the event loop never crosses into the write thread, so
|
||||
the probe would read None every time and the test could not fail.
|
||||
Checks that each task sees the context from when it was queued, and that
|
||||
the write thread's attach depth does not grow between tasks.
|
||||
"""
|
||||
name = f"context_leak_test_{num_sql_threads}"
|
||||
db_path = tmp_path / f"{name}.db"
|
||||
|
|
@ -1374,8 +1358,7 @@ async def test_write_thread_context_is_detached_between_tasks(
|
|||
if threading.current_thread().name == write_thread_name:
|
||||
depth["value"] -= 1
|
||||
|
||||
# Patched on the opentelemetry.context module itself, which is what both
|
||||
# database.py and opentelemetry.trace.use_span() look the functions up on.
|
||||
# database.py and opentelemetry.trace both call these via the module
|
||||
monkeypatch.setattr(otel_context_api, "attach", counting_attach)
|
||||
monkeypatch.setattr(otel_context_api, "detach", counting_detach)
|
||||
|
||||
|
|
@ -1388,8 +1371,6 @@ async def test_write_thread_context_is_detached_between_tasks(
|
|||
|
||||
def failing_probe(conn):
|
||||
probe(conn)
|
||||
# Exercises the write thread's exception path: the detach still has
|
||||
# to happen, which is why it lives in a `finally`.
|
||||
raise ValueError("deliberate failure inside a write task")
|
||||
|
||||
try:
|
||||
|
|
@ -1405,9 +1386,7 @@ async def test_write_thread_context_is_detached_between_tasks(
|
|||
finally:
|
||||
real_detach(token)
|
||||
|
||||
# Sanity check: no marker is active in *this* (event loop) context
|
||||
# right now, so the final probe is a fair test of the write thread's
|
||||
# own state rather than something this test forgot to clean up.
|
||||
# No marker is set here, so the final probe should see None
|
||||
assert otel_context_api.get_value(_CONTEXT_LEAK_MARKER_KEY) is None
|
||||
await db.execute_write_fn(probe)
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ SECRET_PARAM_VALUE = "SUPER_SECRET_PARAM_VALUE_XYZ_123"
|
|||
|
||||
INVALID_SQL = "select this_is_not_valid_sql from nowhere"
|
||||
|
||||
# Bounded so a broken time limit fails the test instead of hanging it, but far
|
||||
# too long to finish inside any of the millisecond budgets used below.
|
||||
# Bounded so a broken time limit fails rather than hangs, but too slow to
|
||||
# finish within the millisecond time limits used below.
|
||||
SLOW_SQL = """
|
||||
with recursive counter(x) as (
|
||||
select 1 union all select x + 1 from counter where x < 50000000
|
||||
|
|
@ -41,13 +41,7 @@ def _db_query_spans(otel_spans):
|
|||
|
||||
|
||||
def _spans_for_namespace(otel_spans, namespace):
|
||||
"""
|
||||
db.query spans belonging to one database.
|
||||
|
||||
Datasette queries its internal catalog constantly - including while a
|
||||
Datasette instance is being constructed - so a test that just grabbed
|
||||
every db.query span would be reading someone else's traffic.
|
||||
"""
|
||||
"db.query spans for one database, excluding queries against the internal database."
|
||||
return [
|
||||
span
|
||||
for span in _db_query_spans(otel_spans)
|
||||
|
|
@ -56,14 +50,7 @@ def _spans_for_namespace(otel_spans, namespace):
|
|||
|
||||
|
||||
def _children_named(otel_spans, name, parent_span_context):
|
||||
"""
|
||||
Finished spans called `name` whose parent really is `parent_span_context`.
|
||||
|
||||
Parentage is matched on span id, not on "a span with this name exists" -
|
||||
a span can exist and still be an unparented root if a thread boundary
|
||||
dropped the otel context, which is the exact failure these tests exist
|
||||
to catch.
|
||||
"""
|
||||
"Finished spans called `name` that are direct children of `parent_span_context`."
|
||||
return [
|
||||
span
|
||||
for span in otel_spans.get_finished_spans()
|
||||
|
|
@ -76,12 +63,7 @@ def _children_named(otel_spans, name, parent_span_context):
|
|||
|
||||
|
||||
def _descends_from(span, ancestor_span_context, by_span_id):
|
||||
"""
|
||||
True if `span` reaches `ancestor_span_context` by walking parent links.
|
||||
|
||||
Walks real span ids rather than trusting a shared trace id: a span can
|
||||
carry the right trace id and still hang off the wrong parent.
|
||||
"""
|
||||
"True if `span` reaches `ancestor_span_context` by walking parent links."
|
||||
seen = set()
|
||||
current = span
|
||||
while current.parent is not None:
|
||||
|
|
@ -97,7 +79,7 @@ def _descends_from(span, ancestor_span_context, by_span_id):
|
|||
|
||||
|
||||
def _all_attribute_values(otel_spans):
|
||||
"Every attribute value across every finished span, for the 'no leaked param values' test."
|
||||
"Every attribute value on every finished span and span event."
|
||||
values = []
|
||||
for span in otel_spans.get_finished_spans():
|
||||
values.extend((span.attributes or {}).values())
|
||||
|
|
@ -108,14 +90,9 @@ def _all_attribute_values(otel_spans):
|
|||
|
||||
def test_datasette_package_never_imports_the_sdk():
|
||||
"""
|
||||
Core depends on opentelemetry-api only. The SDK is a test dependency.
|
||||
Importing datasette does not load the OpenTelemetry SDK.
|
||||
|
||||
Checked by importing datasette in a fresh process and inspecting
|
||||
sys.modules, rather than by grepping, so a lazy `import
|
||||
opentelemetry.sdk` inside a function body cannot slip past.
|
||||
|
||||
conftest.py's pytest_collection_modifyitems() moves this test to the
|
||||
front of the run by name - if you rename it, rename it there too.
|
||||
conftest.py moves this test to the front of the run by name.
|
||||
"""
|
||||
code = (
|
||||
"import datasette.app, datasette.database, datasette.telemetry, sys; "
|
||||
|
|
@ -149,13 +126,7 @@ async def test_db_query_span_basic_attributes(ds_client, otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncated_result_sets_truncated_attribute(otel_spans):
|
||||
"""
|
||||
A result actually cut short by max_returned_rows records truncated=True.
|
||||
|
||||
Every other test asserts the attribute is False, so a regression that
|
||||
recorded the flag before the slice (or inverted it) would pass the rest
|
||||
of the suite.
|
||||
"""
|
||||
"A result cut short by max_returned_rows records truncated=True."
|
||||
ds = Datasette(memory=True, settings={"max_returned_rows": 5})
|
||||
db = ds.add_memory_database("t04_truncated")
|
||||
results = await db.execute(
|
||||
|
|
@ -178,8 +149,7 @@ async def test_facetable_request_produces_db_query_spans(ds_client, otel_spans):
|
|||
spans = _db_query_spans(otel_spans)
|
||||
assert spans, "expected at least one db.query span"
|
||||
assert all(span.attributes["db.system"] == "sqlite" for span in spans)
|
||||
# Every db.query names what ran: SQL text for the string methods,
|
||||
# datasette.callback for callback-style calls (schema introspection here).
|
||||
# Each span records the SQL or, for callback methods, the callback name:
|
||||
assert all(
|
||||
span.attributes.get("db.query.text")
|
||||
or span.attributes.get("datasette.callback")
|
||||
|
|
@ -194,8 +164,7 @@ async def test_facetable_request_produces_db_query_spans(ds_client, otel_spans):
|
|||
def test_sql_attribute_truncates_at_2048():
|
||||
short_sql = "select 1"
|
||||
assert sql_attribute(short_sql) == "select 1"
|
||||
# Whitespace is stripped, so the same query logged twice with different
|
||||
# surrounding whitespace produces one attribute value, not two.
|
||||
# Surrounding whitespace is stripped:
|
||||
assert sql_attribute(" select 1\n") == "select 1"
|
||||
|
||||
long_sql = "select 1 -- " + ("x" * 3000)
|
||||
|
|
@ -207,8 +176,7 @@ def test_sql_attribute_truncates_at_2048():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_query_text_is_truncated_in_real_span(ds_client, otel_spans):
|
||||
# A long trailing SQL comment keeps the query valid and executable while
|
||||
# pushing db.query.text well past the 2048 char cap.
|
||||
# A long trailing comment keeps the SQL valid but over the 2048 character limit
|
||||
long_sql = "select 1 -- " + ("x" * 3000)
|
||||
response = await ds_client.get("/fixtures/-/query.json", params={"sql": long_sql})
|
||||
assert response.status_code == 200
|
||||
|
|
@ -231,8 +199,7 @@ async def test_no_span_attribute_ever_contains_a_parameter_value(ds_client, otel
|
|||
params={"sql": "select :secret", "secret": SECRET_PARAM_VALUE},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Sanity check the value really did flow through as a bound parameter,
|
||||
# not inlined into the SQL text, otherwise this test would be vacuous.
|
||||
# Confirm the bound parameter value was used by the query:
|
||||
assert SECRET_PARAM_VALUE in json.dumps(response.json())
|
||||
|
||||
for value in _all_attribute_values(otel_spans):
|
||||
|
|
@ -253,13 +220,10 @@ async def test_no_span_attribute_ever_contains_a_parameter_value(ds_client, otel
|
|||
@pytest.mark.asyncio
|
||||
async def test_query_interrupted_sets_error_status(otel_spans):
|
||||
"""
|
||||
A query that runs out the instance-wide sql_time_limit_ms is an error.
|
||||
A query that exceeds the sql_time_limit_ms setting is a span error.
|
||||
|
||||
This used to force the timeout with `?_timelimit=5`, but a caller-supplied
|
||||
budget shorter than the instance limit is now the signal that the timeout
|
||||
was expected - see test_expected_timeout_is_not_a_span_error - so the
|
||||
timeout has to come from the setting for this to still test what it was
|
||||
written to test.
|
||||
The limit comes from the setting because a shorter custom_time_limit
|
||||
marks the timeout as expected.
|
||||
"""
|
||||
ds = Datasette(memory=True, settings={"sql_time_limit_ms": 20})
|
||||
db = ds.add_memory_database("t09_instance_limit_timeout")
|
||||
|
|
@ -276,23 +240,14 @@ async def test_query_interrupted_sets_error_status(otel_spans):
|
|||
|
||||
|
||||
async def _expected_timeout_count_span(otel_spans, database_name):
|
||||
"""
|
||||
Drive the real table_counts() path into a timeout; return its db.query span.
|
||||
|
||||
table_counts() is where the headline instance of this lives: the homepage
|
||||
counts every table under a 10ms budget and stores None for any table that
|
||||
does not finish in time. Before this was fixed, a two-table database
|
||||
produced four ERROR spans - two db.query and two db.query.execute - on
|
||||
every single homepage hit.
|
||||
"""
|
||||
"Make table_counts() time out and return its db.query span."
|
||||
db = Datasette(memory=True).add_memory_database(database_name)
|
||||
await db.execute_write("create table big (id integer primary key, t text)")
|
||||
await db.execute_write_many(
|
||||
"insert into big (t) values (?)", [["x" * 50] for _ in range(11000)]
|
||||
)
|
||||
# count_limit caps the scan at 10001 rows, and below 20ms sqlite_timelimit()
|
||||
# runs its progress handler on every VM instruction, so 1ms is not a close
|
||||
# call - a scan of that size takes single-digit milliseconds at best.
|
||||
# count_limit caps the scan at 10001 rows. Below 20ms sqlite_timelimit()
|
||||
# checks the limit on every VM instruction, so this reliably exceeds 1ms.
|
||||
counts = await db.table_counts(1)
|
||||
assert counts == {
|
||||
"big": None
|
||||
|
|
@ -310,7 +265,7 @@ async def _expected_timeout_count_span(otel_spans, database_name):
|
|||
@pytest.mark.asyncio
|
||||
async def test_expected_timeout_is_not_a_span_error(otel_spans):
|
||||
span = await _expected_timeout_count_span(otel_spans, "t09_expected_timeout")
|
||||
# The useful signal survives; only the red status goes away.
|
||||
# Recorded as interrupted, but not as an error:
|
||||
assert span.attributes["datasette.interrupted"] is True
|
||||
assert span.status.status_code != StatusCode.ERROR
|
||||
assert not [event for event in span.events if event.name == "exception"]
|
||||
|
|
@ -318,13 +273,7 @@ async def test_expected_timeout_is_not_a_span_error(otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expected_timeout_does_not_error_the_inner_execute_span(otel_spans):
|
||||
"""
|
||||
The same fix has to reach db.query.execute, which sets its own status.
|
||||
|
||||
Half of the original bug lived here: the inner span passed
|
||||
set_status_on_exception=log_sql_errors, and table_counts() leaves
|
||||
log_sql_errors at its True default, so it went ERROR too.
|
||||
"""
|
||||
"The db.query.execute child span is not marked as an error either."
|
||||
span = await _expected_timeout_count_span(otel_spans, "t09_expected_timeout_inner")
|
||||
children = _children_named(otel_spans, "db.query.execute", span.context)
|
||||
assert len(children) == 1
|
||||
|
|
@ -335,13 +284,7 @@ async def test_expected_timeout_does_not_error_the_inner_execute_span(otel_spans
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_timeout_is_still_a_span_error(otel_spans):
|
||||
"""
|
||||
A custom_time_limit *above* sql_time_limit_ms is not a short budget.
|
||||
|
||||
This is the half of the rule that stops the fix collapsing into "never
|
||||
report timeouts": the caller asked for 5 seconds, the instance overruled it
|
||||
at 20ms, and nobody expected that.
|
||||
"""
|
||||
"A timeout is an error if custom_time_limit is above sql_time_limit_ms."
|
||||
ds = Datasette(memory=True, settings={"sql_time_limit_ms": 20})
|
||||
db = ds.add_memory_database("t09_custom_limit_ignored")
|
||||
with pytest.raises(QueryInterrupted):
|
||||
|
|
@ -350,8 +293,7 @@ async def test_unexpected_timeout_is_still_a_span_error(otel_spans):
|
|||
spans = _spans_for_namespace(otel_spans, "t09_custom_limit_ignored")
|
||||
assert spans
|
||||
span = spans[-1]
|
||||
# Proves the caller's larger budget really was discarded - otherwise this
|
||||
# would be asserting on a query that ran under a 5s limit.
|
||||
# The setting overrides the larger custom_time_limit:
|
||||
assert span.attributes["datasette.time_limit_ms"] == 20
|
||||
assert span.attributes["datasette.interrupted"] is True
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
|
|
@ -378,14 +320,7 @@ async def test_unsuppressed_sql_error_is_a_span_error(ds_client, otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppressed_sql_error_is_not_a_span_error(ds_client, otel_spans):
|
||||
"""
|
||||
log_sql_errors=False means the caller is probing and expects failures.
|
||||
|
||||
Facet suggestion runs `json_type(column)` against every column precisely
|
||||
to discover which ones raise, so marking those spans as errors would put
|
||||
two red spans per text column on every table page - burying real failures
|
||||
and tripping any alerting keyed on span status.
|
||||
"""
|
||||
"With log_sql_errors=False the error is recorded as suppressed, not a span error."
|
||||
db = ds_client.ds.get_database("fixtures")
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
await db.execute(INVALID_SQL, log_sql_errors=False)
|
||||
|
|
@ -400,8 +335,7 @@ async def test_suppressed_sql_error_is_not_a_span_error(ds_client, otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_produces_db_query_span(otel_spans):
|
||||
# Named in-memory databases are shared-cache, so every test in this file
|
||||
# needs its own name or the second `create table` hits an existing table.
|
||||
# Named in-memory databases are shared, so each test uses a unique name.
|
||||
db = Datasette(memory=True).add_memory_database("t03_write_span")
|
||||
await db.execute_write("create table docs (id integer primary key, name text)")
|
||||
await db.execute_write("insert into docs (id, name) values (?, ?)", [1, "one"])
|
||||
|
|
@ -451,27 +385,18 @@ async def test_execute_write_many_records_param_sets_not_rows_returned(otel_span
|
|||
span = many_spans[0]
|
||||
|
||||
assert span.attributes["datasette.param_sets"] == 5
|
||||
# executemany() consumes parameter sets and returns no rows at all, so
|
||||
# calling this a row count would be a lie. Asserted explicitly because the
|
||||
# attribute really was named datasette.rows_returned at one point.
|
||||
assert "datasette.rows_returned" not in span.attributes
|
||||
|
||||
|
||||
# --- Context propagation across thread boundaries --------------------------
|
||||
#
|
||||
# Every assertion below checks parentage (child.parent.span_id ==
|
||||
# expected_parent.span_id, in the same trace), not merely that spans exist.
|
||||
# Spans can exist and still be wrongly parented - or be unparented roots - if
|
||||
# a thread boundary drops the otel context, which is exactly the failure mode
|
||||
# these tests exist to prevent.
|
||||
# These tests check span parentage, not just that the spans exist.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_query_execute_parents_to_db_query(ds_client, otel_spans):
|
||||
# execute_fn()'s executor.submit() is thread boundary #1. The
|
||||
# db.query.execute span is created inside the worker thread; without the
|
||||
# copy_context() propagation it comes back as an unparented root span
|
||||
# rather than a child of db.query.
|
||||
# execute_fn() submits to the executor, so db.query.execute is created on
|
||||
# another thread.
|
||||
response = await ds_client.get("/fixtures/-/query.json?sql=select+1")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
|
@ -490,19 +415,15 @@ async def test_db_query_execute_parents_to_db_query(ds_client, otel_spans):
|
|||
], "expected at least one db.query.execute span"
|
||||
children = _children_named(otel_spans, "db.query.execute", query_span.context)
|
||||
assert len(children) == 1, "expected exactly one db.query.execute child of db.query"
|
||||
# The execute span is strictly contained by the round-trip span, and the
|
||||
# gap between the two is the thread-pool wait.
|
||||
# db.query.execute runs within db.query; the gap is the thread pool wait.
|
||||
assert query_span.start_time <= children[0].start_time
|
||||
assert children[0].end_time <= query_span.end_time
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_immutable_database_propagates_context(tmp_path, otel_spans):
|
||||
# Thread boundary #3, the easy one to miss: immutable databases route
|
||||
# execute_isolated_fn() through loop.run_in_executor() directly rather
|
||||
# than through the write thread. A span created inside that worker must
|
||||
# still parent to whatever was current when execute_isolated_fn() was
|
||||
# awaited, or every immutable-database operation emits orphan roots.
|
||||
# Immutable databases run execute_isolated_fn() on another thread using
|
||||
# loop.run_in_executor(), not the write thread.
|
||||
db_path = tmp_path / "t04_immutable.db"
|
||||
sqlite_utils.Database(str(db_path))["t"].insert({"id": 1}, pk="id")
|
||||
|
||||
|
|
@ -526,10 +447,7 @@ async def test_immutable_database_propagates_context(tmp_path, otel_spans):
|
|||
for span in otel_spans.get_finished_spans()
|
||||
if span.name == "t04-child-in-isolated-worker"
|
||||
], "expected a span created inside execute_isolated_fn's worker thread"
|
||||
# execute_isolated_fn() now opens its own db.query span, so the chain is
|
||||
# event-loop parent -> db.query -> worker child. The worker child
|
||||
# parenting to that db.query span, across the thread, is the propagation
|
||||
# this test exists to prove.
|
||||
# Expected chain: event loop parent -> db.query -> worker thread child
|
||||
query_spans = _children_named(otel_spans, "db.query", parent_context)
|
||||
assert len(query_spans) == 1
|
||||
children = _children_named(
|
||||
|
|
@ -540,10 +458,8 @@ async def test_immutable_database_propagates_context(tmp_path, otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_spans_parent_to_db_query(otel_spans):
|
||||
# Thread boundary #2: WriteTask -> queue.Queue -> the write thread.
|
||||
# db.write.queue_wait and db.write.execute are both direct children of
|
||||
# the db.query span that was current on the event loop at enqueue time,
|
||||
# so they are siblings rather than nested inside one another.
|
||||
# execute_write() queues a WriteTask for the write thread.
|
||||
# db.write.queue_wait and db.write.execute are both children of db.query.
|
||||
db = Datasette(memory=True).add_memory_database("t04_write_spans")
|
||||
await db.execute_write("create table docs (id integer primary key)")
|
||||
|
||||
|
|
@ -563,18 +479,14 @@ async def test_write_spans_parent_to_db_query(otel_spans):
|
|||
execute_span = execute_children[0]
|
||||
assert execute_span.attributes["datasette.isolated_connection"] is False
|
||||
assert execute_span.attributes["datasette.transaction"] is True
|
||||
# Siblings, not parent/child: the queue wait is over by the time the
|
||||
# write begins.
|
||||
# The queue wait ends before the write begins.
|
||||
assert queue_wait_children[0].end_time <= execute_span.start_time
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_queue_wait_duration_reflects_real_wait(otel_spans):
|
||||
# db.write.queue_wait is built from explicit start/end timestamps -
|
||||
# task.enqueued_at_ns, captured on the event loop, through to the moment
|
||||
# the write thread dequeued it. If it were a plain `with` block on the
|
||||
# write thread it would instead measure the microseconds spent building
|
||||
# the span object, and this assertion would fail.
|
||||
# db.write.queue_wait runs from task.enqueued_at_ns, captured on the event
|
||||
# loop, to when the write thread dequeues the task.
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("t04_queue_wait")
|
||||
await db.execute_write("create table docs (id integer primary key)")
|
||||
|
|
@ -582,9 +494,7 @@ async def test_write_queue_wait_duration_reflects_real_wait(otel_spans):
|
|||
def slow_write(conn):
|
||||
time.sleep(0.1)
|
||||
|
||||
# Queue a deliberately slow write without waiting for it, then queue a
|
||||
# second write immediately behind it: the second task sits in the queue
|
||||
# for roughly the duration of the first.
|
||||
# Queue a slow write without waiting for it, then a second write behind it:
|
||||
_, slow_future = await db._send_to_write_thread(slow_write, block=False)
|
||||
await db.execute_write("insert into docs (id) values (1)")
|
||||
await slow_future
|
||||
|
|
@ -600,23 +510,17 @@ async def test_write_queue_wait_duration_reflects_real_wait(otel_spans):
|
|||
)
|
||||
assert len(queue_wait_children) == 1
|
||||
duration_ns = queue_wait_children[0].end_time - queue_wait_children[0].start_time
|
||||
# The slow write sleeps 100ms; anything above 10ms is far beyond the
|
||||
# microseconds a mis-timestamped span would report.
|
||||
# The slow write sleeps for 100ms
|
||||
assert duration_ns > 10_000_000, f"queue wait was only {duration_ns}ns"
|
||||
|
||||
|
||||
async def _write_spans_from_one_enqueue(otel_spans, name, block):
|
||||
"""
|
||||
Run exactly one write through the write thread from inside a span of our
|
||||
own, and return (enqueueing span context, {span name: span}).
|
||||
Run one write through the write thread inside a span, returning
|
||||
(enqueueing span context, {span name: span}).
|
||||
|
||||
`_send_to_write_thread` is called directly rather than `execute_write()`
|
||||
because `execute_write()` opens its own db.query span, which would then
|
||||
be the span current at enqueue time - so the parent/link would point at
|
||||
that span rather than at the one this test controls.
|
||||
|
||||
The exporter is cleared immediately before the enqueue so the write spans
|
||||
collected here can only have come from this one write.
|
||||
Uses _send_to_write_thread() because execute_write() would add its own
|
||||
db.query span between the enqueueing span and the write spans.
|
||||
"""
|
||||
db = Datasette(memory=True).add_memory_database(name)
|
||||
await db.execute_write("create table docs (id integer primary key)")
|
||||
|
|
@ -629,11 +533,8 @@ async def _write_spans_from_one_enqueue(otel_spans, name, block):
|
|||
enqueuer_context = enqueuer.get_span_context()
|
||||
queued = await db._send_to_write_thread(insert, block=block)
|
||||
if not block:
|
||||
# The point of block=False is that the write happens after the
|
||||
# caller has returned and the enqueueing span above has closed.
|
||||
# Awaiting the reply future outside that `with` waits for the write
|
||||
# thread deterministically - it is resolved only after both write
|
||||
# spans have ended and been exported.
|
||||
# Wait for the write after the enqueueing span has ended. The reply
|
||||
# future resolves once both write spans have been exported.
|
||||
_, reply_future = queued
|
||||
await reply_future
|
||||
|
||||
|
|
@ -648,9 +549,8 @@ async def _write_spans_from_one_enqueue(otel_spans, name, block):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocking_write_spans_still_parent_normally(otel_spans):
|
||||
# Regression guard for ticket 07: block=True genuinely has containment -
|
||||
# the caller awaits the reply future - so those spans must keep parenting
|
||||
# to the enqueueing span, and must not grow links.
|
||||
# block=True waits for the write, so its spans are children of the
|
||||
# enqueueing span, with no links.
|
||||
enqueuer_context, spans = await _write_spans_from_one_enqueue(
|
||||
otel_spans, "t07_blocking_write", block=True
|
||||
)
|
||||
|
|
@ -664,24 +564,21 @@ async def test_blocking_write_spans_still_parent_normally(otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonblocking_write_spans_are_roots_with_a_link(otel_spans):
|
||||
# block=False returns before the write runs, so the enqueueing span has
|
||||
# already ended (and exported) by the time these spans start. Parenting
|
||||
# them to it would draw a child outliving its closed parent, so they are
|
||||
# roots in their own traces, linked back to the span that caused them.
|
||||
# block=False returns before the write runs, so the write spans are roots
|
||||
# linked to the enqueueing span.
|
||||
enqueuer_context, spans = await _write_spans_from_one_enqueue(
|
||||
otel_spans, "t07_nonblocking_write", block=False
|
||||
)
|
||||
assert enqueuer_context.is_valid, "test's own enqueueing span was not recorded"
|
||||
for name, span in spans.items():
|
||||
assert span.parent is None, f"{name} is still parented"
|
||||
# A link does not join the linked trace: each of these is its own
|
||||
# root trace, which is the correct shape and not a workaround.
|
||||
# Each write span starts its own trace
|
||||
assert span.context.trace_id != enqueuer_context.trace_id, name
|
||||
assert len(span.links) == 1, f"{name} has links {span.links}"
|
||||
link_context = span.links[0].context
|
||||
assert link_context.trace_id == enqueuer_context.trace_id, name
|
||||
assert link_context.span_id == enqueuer_context.span_id, name
|
||||
# The two write spans are independent roots, not nested in one another.
|
||||
# The two write spans are separate roots
|
||||
assert (
|
||||
spans["db.write.queue_wait"].context.trace_id
|
||||
!= spans["db.write.execute"].context.trace_id
|
||||
|
|
@ -690,8 +587,6 @@ async def test_nonblocking_write_spans_are_roots_with_a_link(otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonblocking_write_link_has_no_attributes(otel_spans):
|
||||
# There is only one kind of link here, so a relationship-name attribute
|
||||
# would be a constant conveying nothing the link's existence does not.
|
||||
_, spans = await _write_spans_from_one_enqueue(
|
||||
otel_spans, "t07_nonblocking_link_attrs", block=False
|
||||
)
|
||||
|
|
@ -705,18 +600,10 @@ async def test_nonblocking_write_spans_ignore_the_write_threads_ambient_context(
|
|||
otel_spans,
|
||||
):
|
||||
"""
|
||||
block=False spans pass an explicit empty Context, not merely "no attach".
|
||||
block=False spans ignore any context left attached on the write thread.
|
||||
|
||||
Nothing is attached for a block=False task, but "nothing attached" is not
|
||||
the same as "no ambient context": the write thread is persistent, and
|
||||
anything running on it - a prepare_connection plugin hook, say - can
|
||||
attach a context and never detach it. Without the explicit `context=`
|
||||
these spans would silently parent to that leftover span instead of being
|
||||
roots, and no other test here would notice, because in every other test
|
||||
the write thread's ambient context happens to be empty.
|
||||
|
||||
So this test leaks exactly such a context on the write thread, the way a
|
||||
careless plugin would, and then checks the write spans are still roots.
|
||||
A prepare_connection hook could attach a context and never detach it.
|
||||
This test does that, then checks the write spans are still roots.
|
||||
"""
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("t07_ambient_write_thread")
|
||||
|
|
@ -726,8 +613,8 @@ async def test_nonblocking_write_spans_ignore_the_write_threads_ambient_context(
|
|||
|
||||
def prepare_connection(conn, database):
|
||||
if threading.current_thread().name == write_thread_name:
|
||||
# Runs once, on the write thread, before any task is dequeued -
|
||||
# and never detaches, which is the whole point.
|
||||
# Runs on the write thread before any task is dequeued, and never
|
||||
# detaches.
|
||||
span = tracer.start_span("leaked-write-thread-ambient-span")
|
||||
leaked["span_id"] = span.get_span_context().span_id
|
||||
otel_context_api.attach(otel_trace.set_span_in_context(span))
|
||||
|
|
@ -766,14 +653,7 @@ async def test_nonblocking_write_spans_ignore_the_write_threads_ambient_context(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppressed_error_does_not_mark_execute_span(ds_client, otel_spans):
|
||||
"""
|
||||
The inner db.query.execute span must honour log_sql_errors too.
|
||||
|
||||
It is created inside the worker thread, so without record_exception /
|
||||
set_status_on_exception being passed through it would mark every facet
|
||||
suggestion probe as failed even though the outer db.query span correctly
|
||||
reports the failure as suppressed.
|
||||
"""
|
||||
"The inner db.query.execute span also respects log_sql_errors=False."
|
||||
db = ds_client.ds.get_database("fixtures")
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
await db.execute(INVALID_SQL, log_sql_errors=False)
|
||||
|
|
@ -791,23 +671,14 @@ async def test_suppressed_error_does_not_mark_execute_span(ds_client, otel_spans
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_startup_produces_one_trace_not_dozens_of_orphans(otel_spans):
|
||||
"""
|
||||
invoke_startup() runs with no request, so nothing it does has an ambient
|
||||
span to nest under. Without datasette.startup every register_* hook, every
|
||||
internal-catalog read and every catalog write becomes its own single-span
|
||||
root trace - around twenty of them per fresh instance.
|
||||
"""
|
||||
"Spans emitted by invoke_startup() share a single datasette.startup root span."
|
||||
ds = Datasette(memory=True)
|
||||
# Named in-memory databases are shared-cache, so this needs its own name.
|
||||
ds.add_memory_database("t05_startup_db")
|
||||
# Constructing a Datasette already touches the internal catalog, and that
|
||||
# work is genuinely outside startup. Clear so the assertions below describe
|
||||
# invoke_startup() alone.
|
||||
# Ignore spans from constructing Datasette, which happens before startup
|
||||
otel_spans.clear()
|
||||
|
||||
# Deliberately no ambient span: this mirrors the ASGI lifespan path, where
|
||||
# startup runs before any request exists. If something did wrap this call
|
||||
# the "one root" assertion below would pass for the wrong reason.
|
||||
# No ambient span, as in the ASGI lifespan path where startup runs before
|
||||
# any request.
|
||||
assert (
|
||||
not otel_trace.get_current_span().get_span_context().is_valid
|
||||
), "this test must run with no ambient span"
|
||||
|
|
@ -833,7 +704,7 @@ async def test_invoke_startup_produces_one_trace_not_dozens_of_orphans(otel_span
|
|||
|
||||
by_span_id = {span.context.span_id: span for span in spans}
|
||||
|
||||
# The internal catalog reads are what made up the bulk of the orphans.
|
||||
# Internal database reads:
|
||||
internal_queries = [
|
||||
span
|
||||
for span in spans
|
||||
|
|
@ -844,8 +715,7 @@ async def test_invoke_startup_produces_one_trace_not_dozens_of_orphans(otel_span
|
|||
_descends_from(span, startup.context, by_span_id) for span in internal_queries
|
||||
)
|
||||
|
||||
# ...and the catalog writes, which reach the span through the write thread,
|
||||
# so they also prove the ticket-04 context capture survives startup.
|
||||
# Internal database writes, which run on the write thread:
|
||||
write_spans = [span for span in spans if span.name.startswith("db.write.")]
|
||||
assert write_spans, "expected db.write.* spans during startup"
|
||||
assert all(
|
||||
|
|
@ -859,20 +729,11 @@ async def test_invoke_startup_produces_one_trace_not_dozens_of_orphans(otel_span
|
|||
@pytest.mark.asyncio
|
||||
async def test_db_query_is_client_kind_and_children_are_internal(otel_spans):
|
||||
"""
|
||||
db.query is a database client span; Datasette's decomposition of it is not.
|
||||
|
||||
Trace UIs key their database rendering off the span kind rather than off
|
||||
db.system, so db.query has to be CLIENT. db.query.execute,
|
||||
db.write.execute and db.write.queue_wait deliberately stay INTERNAL: they
|
||||
are parts of one logical query rather than three separate database calls,
|
||||
and queue_wait touches no database at all - marking them CLIENT would
|
||||
make one query look like several to anything counting spans by kind.
|
||||
db.query spans are CLIENT. Their child spans are INTERNAL because they are
|
||||
parts of one query rather than separate database calls.
|
||||
"""
|
||||
# Named in-memory databases are shared-cache, so this needs its own name.
|
||||
db = Datasette(memory=True).add_memory_database("t06_span_kind")
|
||||
# All four db.query entry points, so a missed `kind=` on any one of them
|
||||
# fails here - plus the write path (db.write.queue_wait,
|
||||
# db.write.execute) and the read path (db.query.execute) children.
|
||||
# Call each of the four SQL string methods:
|
||||
await db.execute_write("create table docs (id integer primary key)")
|
||||
await db.execute_write_many(
|
||||
"insert into docs (id) values (?)", [[i] for i in range(1, 4)]
|
||||
|
|
@ -899,15 +760,7 @@ async def test_db_query_is_client_kind_and_children_are_internal(otel_spans):
|
|||
async def test_instrumentation_scope_declares_version_and_schema_url(
|
||||
ds_client, otel_spans
|
||||
):
|
||||
"""
|
||||
Spans say which Datasette produced them and which semconv version their
|
||||
attribute names follow.
|
||||
|
||||
Before get_tracer() was given a version and a schema URL every exported
|
||||
scope was name='datasette' version='' schema_url='', so nothing
|
||||
downstream could tell which Datasette a span came from, or whether
|
||||
`db.system` meant `db.system` or the post-1.30.0 `db.system.name`.
|
||||
"""
|
||||
"The instrumentation scope includes the Datasette version and schema URL."
|
||||
response = await ds_client.get("/fixtures/-/query.json?sql=select+1")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
|
@ -917,10 +770,7 @@ async def test_instrumentation_scope_declares_version_and_schema_url(
|
|||
|
||||
assert scope.name == "datasette"
|
||||
assert scope.version == __version__
|
||||
# The literal URL, not the SCHEMA_URL constant: comparing the span
|
||||
# against the same constant the instrumentation is built from would only
|
||||
# catch a dropped argument, never a wrong value. Bumping this is a claim
|
||||
# about the attribute names on the wire - see SCHEMA_URL in telemetry.py.
|
||||
# Uses the literal URL so changing SCHEMA_URL requires updating this test
|
||||
assert scope.schema_url == "https://opentelemetry.io/schemas/1.29.0"
|
||||
assert SCHEMA_URL == "https://opentelemetry.io/schemas/1.29.0"
|
||||
assert __version__, "the scope version must not be empty"
|
||||
|
|
@ -929,14 +779,11 @@ async def test_instrumentation_scope_declares_version_and_schema_url(
|
|||
def test_db_operation_name_from_leading_keyword():
|
||||
assert sql_operation_name("select 1") == "SELECT"
|
||||
assert sql_operation_name(" insert into x (a) values (1)") == "INSERT"
|
||||
# A leading CTE reports WITH rather than the operation inside it. That is
|
||||
# the documented limitation, not an accident - see sql_operation_name().
|
||||
# A leading CTE reports WITH, not the operation inside it
|
||||
assert sql_operation_name("with foo as (select 1) select * from foo") == "WITH"
|
||||
# Unrecognised leading keyword: no attribute rather than a wrong one, and
|
||||
# no unbounded value set derived from attacker-supplied SQL.
|
||||
# Unrecognized leading keyword
|
||||
assert sql_operation_name("gibberish 1") is None
|
||||
# Not a parser: a parenthesised SELECT and a leading comment both yield
|
||||
# nothing rather than a guess.
|
||||
# A parenthesized SELECT or a leading comment also returns None
|
||||
assert sql_operation_name("(select 1) union select 2") is None
|
||||
assert sql_operation_name("-- a comment\nselect 1") is None
|
||||
assert sql_operation_name("") is None
|
||||
|
|
@ -976,14 +823,10 @@ async def test_execute_write_sets_db_operation_name(otel_spans):
|
|||
@pytest.mark.asyncio
|
||||
async def test_execute_write_script_has_no_operation_name(otel_spans):
|
||||
"""
|
||||
executescript() runs several statements, so naming the operation after
|
||||
the first one would be a lie. Semantic conventions say db.operation.name
|
||||
should not be extracted from query text that can hold more than one
|
||||
operation, so the attribute is absent entirely.
|
||||
Scripts can contain several statements, so db.operation.name is omitted.
|
||||
|
||||
The script deliberately starts with `create`, which *is* on the
|
||||
allowlist - so this fails if the call site ever starts calling
|
||||
sql_operation_name().
|
||||
The script starts with `create`, which is on the allowlist, so this fails
|
||||
if the operation name is extracted anyway.
|
||||
"""
|
||||
db = Datasette(memory=True).add_memory_database("t06_script_operation")
|
||||
await db.execute_write_script(
|
||||
|
|
@ -1022,8 +865,7 @@ async def test_execute_fn_produces_db_query_span(otel_spans):
|
|||
span.attributes["datasette.callback"]
|
||||
== "test_execute_fn_produces_db_query_span.<locals>.count_rows"
|
||||
)
|
||||
# There is no SQL string for a callback, and no statement to take a
|
||||
# leading keyword from - absent beats guessed.
|
||||
# Callbacks have no SQL text to record or take an operation name from
|
||||
assert "db.query.text" not in span.attributes
|
||||
assert "db.operation.name" not in span.attributes
|
||||
children = _children_named(otel_spans, "db.query.execute", span.context)
|
||||
|
|
@ -1032,7 +874,6 @@ async def test_execute_fn_produces_db_query_span(otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_fn_lambda_reports_lambda(otel_spans):
|
||||
# Pins the documented behaviour rather than pretending lambdas have names.
|
||||
db = Datasette(memory=True).add_memory_database("t16_lambda")
|
||||
otel_spans.clear()
|
||||
await db.execute_fn(lambda conn: conn.execute("select 1").fetchone())
|
||||
|
|
@ -1067,9 +908,7 @@ async def test_execute_write_fn_produces_db_query_span(otel_spans):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_fn_callback_name_is_not_the_hook_wrapper(otel_spans):
|
||||
# A callback that declares track_event is the case where
|
||||
# _wrap_fn_with_hooks() actually replaces fn with a wrapper - the span
|
||||
# must still report the caller's function, not the wrapper's name.
|
||||
# _wrap_fn_with_hooks() wraps callbacks that accept track_event
|
||||
db = Datasette(memory=True).add_memory_database("t16_wrapper_name")
|
||||
|
||||
def create_with_events(conn, track_event):
|
||||
|
|
@ -1087,9 +926,8 @@ async def test_execute_write_fn_callback_name_is_not_the_hook_wrapper(otel_spans
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_fn_nonblocking_spans_link_to_the_new_span(otel_spans):
|
||||
# For block=False the public db.query span ends at enqueue and the
|
||||
# write-thread spans become roots. Their link must target that new span,
|
||||
# not whatever was current around the execute_write_fn() call.
|
||||
# With block=False the write thread spans link to the db.query span from
|
||||
# execute_write_fn(), not to the span that was current when it was called.
|
||||
db = Datasette(memory=True).add_memory_database("t16_nonblocking")
|
||||
await db.execute_write("create table docs (id integer primary key)")
|
||||
|
||||
|
|
@ -1100,8 +938,7 @@ async def test_execute_write_fn_nonblocking_spans_link_to_the_new_span(otel_span
|
|||
with tracer.start_as_current_span("t16-enqueueing-span") as enqueuer:
|
||||
enqueuer_context = enqueuer.get_span_context()
|
||||
await db.execute_write_fn(insert, block=False)
|
||||
# Writes are serialized on the write thread, so a blocking write behind
|
||||
# the non-blocking one waits for it deterministically.
|
||||
# Writes run in order, so this waits for the non-blocking write to finish
|
||||
await db.execute_write("insert into docs (id) values (2)")
|
||||
|
||||
query_spans = [
|
||||
|
|
@ -1125,9 +962,8 @@ async def test_execute_write_fn_nonblocking_spans_link_to_the_new_span(otel_span
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_does_not_double_wrap(otel_spans):
|
||||
# The regression guard for the refactor: execute() and the SQL-string
|
||||
# write methods call the private _execute_fn/_execute_write_fn, so they
|
||||
# must not gain a second db.query span from the public wrappers.
|
||||
# execute() and the SQL string write methods call the private
|
||||
# _execute_fn() and _execute_write_fn(), so they create one db.query span.
|
||||
db = Datasette(memory=True).add_memory_database("t16_no_double_wrap")
|
||||
otel_spans.clear()
|
||||
await db.execute_write("create table t (id integer primary key)")
|
||||
|
|
@ -1172,8 +1008,7 @@ async def test_execute_isolated_fn_span_on_mutable_and_immutable(tmp_path, otel_
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_fn_exception_marks_span_error(otel_spans):
|
||||
# Unlike execute(), there is no probing caller on this path - a callback
|
||||
# that raises is an error, with the default record_exception behaviour.
|
||||
# execute_fn() has no log_sql_errors option, so exceptions are span errors
|
||||
db = Datasette(memory=True).add_memory_database("t16_fn_error")
|
||||
|
||||
def boom(conn):
|
||||
|
|
|
|||
|
|
@ -1,19 +1,6 @@
|
|||
"""
|
||||
Tests for the OpenTelemetry metrics Datasette core emits.
|
||||
|
||||
Two layers are tested separately and deliberately:
|
||||
|
||||
- The gauge callbacks are plain generator functions, so they are called
|
||||
directly for exact-value assertions. Going through the SDK for those would
|
||||
be unreliable: the pool gauges carry no attribute identifying which
|
||||
Datasette produced them, and a pytest session has many live instances, so
|
||||
the SDK's last-value aggregation would report whichever one happened to be
|
||||
observed last.
|
||||
|
||||
- The SDK pipeline (instrument -> reader -> data points) is tested through
|
||||
the `otel_metrics` fixture, using metrics that carry `db.namespace` - a
|
||||
uniquely named in-memory database is enough to isolate those from every
|
||||
other instance alive in the session.
|
||||
Tests for the OpenTelemetry metrics emitted by Datasette. Gauge callbacks are
|
||||
called directly, since the pool gauges have no attributes to tell instances apart.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -65,25 +52,17 @@ def metrics_ds():
|
|||
@pytest.mark.asyncio
|
||||
async def test_sql_thread_limit_gauge_reports_num_sql_threads(metrics_ds):
|
||||
values = [value for _, value in observations(telemetry.observe_sql_thread_limit)]
|
||||
# Other instances are alive in this session, so assert membership rather
|
||||
# than uniqueness - 7 is distinctive enough to only come from metrics_ds.
|
||||
# Other Datasette instances may also be reporting:
|
||||
assert 7 in values
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_thread_gauges_in_non_threaded_mode():
|
||||
"""
|
||||
num_sql_threads=0 means there is no pool at all, so the pool gauges must
|
||||
skip the instance rather than report a bogus limit of 0.
|
||||
|
||||
The pool gauges carry no attributes, so the live-instance registry is
|
||||
narrowed to just this instance for the assertion - counting global
|
||||
observations instead would let an unrelated instance being garbage
|
||||
collected mid-test shift the baseline.
|
||||
"""
|
||||
"Pool gauges skip instances with num_sql_threads=0, which have no pool."
|
||||
ds = Datasette(memory=True, settings={"num_sql_threads": 0})
|
||||
try:
|
||||
assert ds.executor is None
|
||||
# Pool gauges have no attributes, so observe only this instance:
|
||||
original = telemetry._live_datasettes
|
||||
telemetry._live_datasettes = weakref.WeakSet([ds])
|
||||
try:
|
||||
|
|
@ -91,7 +70,7 @@ async def test_no_thread_gauges_in_non_threaded_mode():
|
|||
assert list(telemetry.observe_sql_thread_queue_depth()) == []
|
||||
finally:
|
||||
telemetry._live_datasettes = original
|
||||
# Per-database gauges are unaffected - they do not depend on the pool.
|
||||
# Per-database gauges do not depend on the pool:
|
||||
assert observations(telemetry.observe_pending_queries, ds)
|
||||
finally:
|
||||
ds.close()
|
||||
|
|
@ -100,11 +79,8 @@ async def test_no_thread_gauges_in_non_threaded_mode():
|
|||
@pytest.mark.asyncio
|
||||
async def test_thread_queue_depth_gauge_reports_saturation():
|
||||
"""
|
||||
The headline alerting metric must actually read above zero when reads
|
||||
queue behind num_sql_threads. This also pins the private
|
||||
`ThreadPoolExecutor._work_queue` attribute the callback depends on: if a
|
||||
stdlib rename ever removes it, this fails instead of the metric silently
|
||||
vanishing (the callback tolerates its absence at collection time).
|
||||
Queue depth is above zero when reads queue behind num_sql_threads. Also
|
||||
fails if the private ThreadPoolExecutor._work_queue attribute goes away.
|
||||
"""
|
||||
ds = Datasette(memory=True, settings={"num_sql_threads": 1})
|
||||
db = ds.add_memory_database("metrics_saturation_db")
|
||||
|
|
@ -118,11 +94,10 @@ async def test_thread_queue_depth_gauge_reports_saturation():
|
|||
|
||||
try:
|
||||
first = asyncio.ensure_future(db.execute_fn(blocker))
|
||||
# Wait until the blocker owns the pool's only thread.
|
||||
# Wait until the blocker is using the only thread:
|
||||
await asyncio.get_running_loop().run_in_executor(None, entered.wait, 10)
|
||||
second = asyncio.ensure_future(db.execute_fn(lambda conn: 2))
|
||||
# The second submission lands in the executor's queue on the next
|
||||
# event-loop turn; poll briefly rather than assume the timing.
|
||||
# The second query is queued on a later event loop turn, so poll:
|
||||
depths = []
|
||||
for _ in range(500):
|
||||
depths = [
|
||||
|
|
@ -157,8 +132,7 @@ async def test_pending_queries_gauge_tracks_in_flight_queries(metrics_ds):
|
|||
|
||||
assert value() == 0
|
||||
|
||||
# sqlite3.sleep is not a thing, so block the worker thread on an event we
|
||||
# control from the event loop and sample the gauge while it is held.
|
||||
# Hold the worker thread until release is set:
|
||||
release = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
entered = asyncio.Event()
|
||||
|
|
@ -188,8 +162,7 @@ async def test_write_queue_depth_gauge(metrics_ds):
|
|||
if a == attributes
|
||||
]
|
||||
|
||||
# No write has ever been queued, so there is no queue and no observation -
|
||||
# rather than a fabricated zero for a queue that does not exist.
|
||||
# No observation until the write queue has been created:
|
||||
assert depths() == []
|
||||
|
||||
await db.execute_write("create table t (id integer primary key)")
|
||||
|
|
@ -277,11 +250,7 @@ async def test_operation_duration_records_error_type(otel_metrics):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operation_duration_records_write_error_type(otel_metrics):
|
||||
"""
|
||||
Same as the read-path error test, but the write wrappers time a different
|
||||
code path - `execute_write_fn`, the write thread and its reply future -
|
||||
so error propagation through them is pinned separately.
|
||||
"""
|
||||
"A failed write is still timed and records error.type."
|
||||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database("duration_write_error_db")
|
||||
try:
|
||||
|
|
@ -319,7 +288,7 @@ async def test_write_queue_wait_histogram(otel_metrics):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_queries_counter(otel_metrics):
|
||||
"The count of time-limit kills, which sampled traces cannot provide."
|
||||
"Queries cancelled by sql_time_limit_ms are counted."
|
||||
ds = Datasette(memory=True, settings={"sql_time_limit_ms": 1})
|
||||
ds.add_memory_database("interrupted_db")
|
||||
try:
|
||||
|
|
@ -344,7 +313,7 @@ async def test_interrupted_queries_counter(otel_metrics):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_are_reported_through_the_sdk_for_gauges(otel_metrics):
|
||||
"End-to-end: a gauge callback reaches the reader as a data point."
|
||||
"Gauge callbacks reach the metric reader as data points."
|
||||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database("gauge_pipeline_db")
|
||||
try:
|
||||
|
|
@ -373,14 +342,8 @@ def test_closed_datasette_stops_being_observed():
|
|||
|
||||
def test_registry_holds_instances_weakly():
|
||||
"""
|
||||
Registering an instance must never be the thing that keeps it alive.
|
||||
|
||||
A stand-in object is used rather than a real Datasette because a Datasette
|
||||
with a temp-disk internal database is pinned for the life of the process
|
||||
by `Database.__init__`'s `atexit.register(self._cleanup_temp_file)`, which
|
||||
holds the Database, which holds the Datasette. That is pre-existing and
|
||||
unrelated to telemetry; what is tested here is that this registry adds no
|
||||
reference of its own.
|
||||
Registering an instance does not keep it alive. Uses a stand-in object
|
||||
because an atexit handler in Database.__init__ keeps a real Datasette alive.
|
||||
"""
|
||||
import gc
|
||||
import weakref
|
||||
|
|
@ -412,10 +375,8 @@ HISTOGRAM_PROBES = [
|
|||
),
|
||||
]
|
||||
|
||||
# One value inside each of six distinct registry buckets. Under OpenTelemetry's
|
||||
# default boundaries - [0, 5, 10, 25, ...], meant for milliseconds - the first
|
||||
# five of these all land in (0, 5] and only 7.0 lands elsewhere, so the
|
||||
# "occupies six buckets" assertion below fails if the advisory is ever dropped.
|
||||
# One value in each of six registry buckets. The SDK's default boundaries
|
||||
# would put the first five in the same bucket.
|
||||
SPREAD = [0.00005, 0.0003, 0.002, 0.03, 0.8, 7.0]
|
||||
|
||||
|
||||
|
|
@ -428,18 +389,8 @@ def test_histograms_spread_values_across_buckets(
|
|||
otel_metrics, instrument_name, metric_name, attributes
|
||||
):
|
||||
"""
|
||||
The registry's boundaries reach the SDK, and a realistic spread of
|
||||
seconds-scale durations occupies more than one bucket.
|
||||
|
||||
Recording onto the instrument directly rather than driving a workload is
|
||||
deliberate: real durations here are all tens of microseconds and would
|
||||
share a bucket no matter what the boundaries were, which is exactly the
|
||||
situation this test exists to detect.
|
||||
|
||||
`explicit_bounds` is compared against the registry rather than against the
|
||||
instrument's own configuration - the instrument is built *from* the
|
||||
registry, so that comparison would be a value against itself. What is
|
||||
checked here is that the advisory survived the trip through the SDK.
|
||||
The registry's bucket boundaries reach the SDK. Values are recorded
|
||||
directly because real test query durations would all share one bucket.
|
||||
"""
|
||||
from datasette.telemetry_registry import METRICS
|
||||
|
||||
|
|
@ -464,7 +415,7 @@ def test_histograms_spread_values_across_buckets(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operation_duration_histogram_records_execute_fn(otel_metrics):
|
||||
"Callback-style reads land in the same histogram as SQL-string reads."
|
||||
"execute_fn() reads are recorded in the same histogram as SQL reads."
|
||||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database("duration_fn_db")
|
||||
try:
|
||||
|
|
@ -487,7 +438,7 @@ async def test_operation_duration_histogram_records_execute_fn(otel_metrics):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operation_duration_histogram_records_execute_write_fn(otel_metrics):
|
||||
"Callback-style writes - the JSON write API's whole diet - are counted too."
|
||||
"execute_write_fn() writes are recorded in the same histogram."
|
||||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database("duration_write_fn_db")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,26 +1,6 @@
|
|||
"""
|
||||
Two-way conformance between `datasette/telemetry_registry.py` and what
|
||||
Datasette actually emits.
|
||||
|
||||
This is the test that makes the generated documentation trustworthy. cog
|
||||
guarantees the docs match the registry; this guarantees the registry matches
|
||||
the code. Without it, both could agree with each other and be wrong.
|
||||
|
||||
It checks both directions, and the second one is the one nothing else catches:
|
||||
|
||||
- **emitted but not registered** - instrumentation was added without
|
||||
documenting it, so the reference page silently omits it.
|
||||
- **registered but never emitted** - the reference page describes a span or
|
||||
attribute that no longer exists, which is worse than omitting it, because a
|
||||
reader will build a dashboard on it.
|
||||
|
||||
Both of those directions compare the code against the registry. Neither can
|
||||
catch a *rename*, because the call sites now take their names from the
|
||||
registry - move `DB_NAMESPACE` to `"db.namespace2"` and code and registry
|
||||
still agree with each other, while every existing dashboard breaks. So the
|
||||
literal names live here too, spelled out, and are asserted against both the
|
||||
registry and the wire. That is the one comparison in this file that is not
|
||||
made against a value derived from the registry itself.
|
||||
Tests that the spans, attributes and metrics Datasette emits match
|
||||
datasette/telemetry_registry.py, in both directions.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
|
@ -42,10 +22,8 @@ from datasette.database import QueryInterrupted
|
|||
from datasette.telemetry_testing import assert_metrics_conform, assert_metrics_covered
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
|
||||
# The names as they appear on the wire, written out rather than read from the
|
||||
# registry. If a change to the registry makes one of these fail, that change
|
||||
# is renaming something a user's dashboards and saved queries depend on -
|
||||
# which is a decision to take deliberately, here, not a line to re-derive.
|
||||
# Written out as literals rather than read from the registry, so renaming a
|
||||
# signal fails these tests.
|
||||
EXPECTED_ATTRIBUTES = {
|
||||
"db.query": {
|
||||
"db.system",
|
||||
|
|
@ -73,18 +51,8 @@ EXPECTED_ATTRIBUTES = {
|
|||
}
|
||||
EXPECTED_SPANS = set(EXPECTED_ATTRIBUTES)
|
||||
|
||||
# The HTTP request span is handled separately because its name is composed at
|
||||
# runtime - the request method, then the route it matched - 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 that name and the attribute keys.
|
||||
#
|
||||
# The route half is deliberately not spelled out as a literal: it is a core
|
||||
# route regex, and pinning those here would make an unrelated routing change
|
||||
# fail the telemetry conformance test. What is pinned instead is that the name
|
||||
# is exactly the method, a space, and the span's own `http.route` value - the
|
||||
# `{method} {route}` shape semantic conventions specify. 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.
|
||||
# The HTTP request span name is composed at runtime as "{method} {route}", so
|
||||
# it is checked by shape rather than as a literal. The workload only issues GETs.
|
||||
EXPECTED_HTTP_SPAN_NAME = "{http.request.method} {http.route}"
|
||||
EXPECTED_HTTP_METHOD_NAMES = {"GET"}
|
||||
EXPECTED_HTTP_ATTRIBUTES = {
|
||||
|
|
@ -99,16 +67,14 @@ EXPECTED_HTTP_ATTRIBUTES = {
|
|||
"datasette.internal_client",
|
||||
}
|
||||
|
||||
# The registry's own name for the request span is that template, not anything
|
||||
# that appears on the wire.
|
||||
# The registry uses the name template for the request span.
|
||||
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.
|
||||
# Named in-memory databases are shared between instances, so each workload
|
||||
# uses a unique name.
|
||||
_names = itertools.count()
|
||||
|
||||
|
||||
|
|
@ -117,14 +83,7 @@ def _unique(prefix):
|
|||
|
||||
|
||||
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.
|
||||
"""
|
||||
"A route that raises, producing a 500 and error.type on the request span."
|
||||
|
||||
__name__ = "TelemetryRegistryBoomPlugin"
|
||||
|
||||
|
|
@ -135,20 +94,13 @@ class _BoomPlugin:
|
|||
|
||||
async def exercise():
|
||||
"""
|
||||
Drive enough of Datasette to emit every span and attribute the registry
|
||||
claims exists.
|
||||
|
||||
Each call is here because it is the only thing that produces some span or
|
||||
attribute - see the comments. If you add instrumentation on a path this
|
||||
does not reach, add the path rather than loosening the assertions.
|
||||
|
||||
Returns the instance so the caller can close it; startup happens inside
|
||||
so that the `datasette.startup` span lands in the collected set.
|
||||
Drive enough of Datasette to emit every registered span and attribute,
|
||||
including datasette.startup. Returns the instance so the caller can close it.
|
||||
"""
|
||||
name = _unique("registry")
|
||||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database(name)
|
||||
# datasette.startup - and the internal catalog work nested under it
|
||||
# datasette.startup
|
||||
await ds.invoke_startup()
|
||||
db = ds.get_database(name)
|
||||
|
||||
|
|
@ -165,8 +117,7 @@ async def exercise():
|
|||
# datasette.isolated_connection=True
|
||||
await db.execute_isolated_fn(lambda conn: conn.execute("select 1").fetchone())
|
||||
|
||||
# datasette.callback, with named functions so the conformance run sees the
|
||||
# attribute's documented value shape (a qualname, not just "<lambda>")
|
||||
# datasette.callback, using named functions rather than lambdas
|
||||
def registry_read_callback(conn):
|
||||
return conn.execute("select count(*) from t").fetchone()
|
||||
|
||||
|
|
@ -181,14 +132,11 @@ async def exercise():
|
|||
await db.execute("select * from t where id > :n", {"n": 5})
|
||||
await db.execute("select * from t", truncate=True)
|
||||
|
||||
# datasette.sql_error_suppressed - the caller is probing and treats
|
||||
# failure as an expected answer
|
||||
# datasette.sql_error_suppressed
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
await db.execute("select nope from t", log_sql_errors=False)
|
||||
|
||||
# datasette.interrupted - only ever set when a query exceeds its time
|
||||
# limit, so the workload has to force one rather than exempt it. An
|
||||
# unbounded recursive CTE cannot finish, so 1ms is always exceeded.
|
||||
# datasette.interrupted: an unbounded recursive CTE always exceeds 1ms
|
||||
with pytest.raises(QueryInterrupted):
|
||||
await db.execute(
|
||||
"with recursive c(x) as (select 0 union all select x+1 from c) "
|
||||
|
|
@ -196,13 +144,11 @@ async def exercise():
|
|||
custom_time_limit=1,
|
||||
)
|
||||
|
||||
# These requests produce the HTTP request span and its
|
||||
# http.request.method / url.path / url.scheme / server.address /
|
||||
# user_agent.original / http.response.status_code attributes.
|
||||
# HTTP request spans and their 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
|
||||
# error.type on the request span, set by a 5xx response
|
||||
ds.pm.register(_BoomPlugin(), name="telemetry-registry-boom")
|
||||
try:
|
||||
response = await ds.client.get("/-/telemetry-registry-boom")
|
||||
|
|
@ -215,21 +161,13 @@ async def exercise():
|
|||
@pytest_asyncio.fixture
|
||||
async def emitted(otel_spans):
|
||||
"""
|
||||
Every (span name, span kind, attributes) 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. The attributes are
|
||||
carried as a mapping rather than a set of keys because the request span's
|
||||
name has to be checked against its own `http.route` value.
|
||||
Every (span name, span kind, attributes) triple emitted by exercise().
|
||||
The kind is needed to resolve the dynamically named request span.
|
||||
"""
|
||||
# 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"
|
||||
# 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.
|
||||
# str() so failure messages show plain strings, not registry instances
|
||||
collected = tuple(
|
||||
(
|
||||
str(span.name),
|
||||
|
|
@ -258,12 +196,7 @@ def _keys_by_span(records):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workload_emits_exactly_the_expected_names(emitted):
|
||||
"""
|
||||
The wire format, pinned to literals.
|
||||
|
||||
Not derived from the registry, so this is what catches a rename that the
|
||||
registry and the call sites make together.
|
||||
"""
|
||||
"Emitted span and attribute names match the expected literals."
|
||||
static, server = _partition(emitted)
|
||||
by_span = _keys_by_span(static)
|
||||
assert set(by_span) == EXPECTED_SPANS
|
||||
|
|
@ -275,9 +208,7 @@ async def test_workload_emits_exactly_the_expected_names(emitted):
|
|||
for name, _kind, attributes in server:
|
||||
union |= set(attributes)
|
||||
route = attributes.get("http.route")
|
||||
# Every request in the workload matches a route, so every one of these
|
||||
# names must be `{method} {route}`. A 404 would be a bare method - the
|
||||
# http_route tests cover that case with a real request.
|
||||
# Every request in the workload matches a route
|
||||
assert route, f"the request span {name!r} carries no http.route"
|
||||
method, _, name_route = name.partition(" ")
|
||||
assert name_route == route, (
|
||||
|
|
@ -290,7 +221,7 @@ async def test_workload_emits_exactly_the_expected_names(emitted):
|
|||
|
||||
|
||||
def test_registry_matches_the_expected_names():
|
||||
"The other half of the rename check: the registry against the same literals."
|
||||
"Registry names match the expected literals."
|
||||
assert {str(span) for span in reg.SPANS} == EXPECTED_REGISTRY_NAMES
|
||||
for span in reg.SPANS:
|
||||
assert {
|
||||
|
|
@ -329,12 +260,9 @@ async def test_every_emitted_attribute_is_registered(emitted):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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.
|
||||
"""
|
||||
# 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.
|
||||
"The docs should not describe a span that is no longer emitted."
|
||||
# Compare by identity: the request span's registry name never appears on
|
||||
# the wire.
|
||||
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, (
|
||||
|
|
@ -346,14 +274,8 @@ async def test_every_registered_span_is_emitted(emitted):
|
|||
@pytest.mark.asyncio
|
||||
async def test_every_registered_attribute_is_emitted(emitted):
|
||||
"""
|
||||
Every registered attribute, optional or not, must actually be set at least
|
||||
once by the workload.
|
||||
|
||||
`optional` describes whether a reader should expect it on every span, not
|
||||
whether the code still sets it - so an attribute deleted from the code but
|
||||
left in the docs has to fail here even when it is marked optional. If a
|
||||
new attribute only appears in some rare case, extend exercise() to reach
|
||||
that case.
|
||||
Every registered attribute, including optional ones, is emitted at least
|
||||
once. If a new attribute only appears in rare cases, extend exercise().
|
||||
"""
|
||||
by_entry = {}
|
||||
for name, kind, keys in emitted:
|
||||
|
|
@ -381,7 +303,7 @@ def test_registry_has_no_duplicate_names():
|
|||
|
||||
|
||||
def test_registry_entries_are_documented():
|
||||
"Every entry carries a description - the docs are generated from these."
|
||||
"Every entry has a description, used to generate the docs."
|
||||
for span in reg.SPANS:
|
||||
assert span.description.strip(), f"{span} has no description"
|
||||
for attribute in span.attributes:
|
||||
|
|
@ -389,7 +311,6 @@ def test_registry_entries_are_documented():
|
|||
|
||||
|
||||
def test_registry_entries_are_usable_as_plain_strings():
|
||||
"The str subclassing is the whole reason call sites need no wrapper API."
|
||||
assert isinstance(reg.DB_QUERY, str)
|
||||
assert isinstance(reg.DB_NAMESPACE, str)
|
||||
assert reg.DB_QUERY == "db.query"
|
||||
|
|
@ -399,30 +320,19 @@ def test_registry_entries_are_usable_as_plain_strings():
|
|||
|
||||
def test_registry_entries_survive_deepcopy_and_pickle():
|
||||
"""
|
||||
A copy of an entry is a plain `str`.
|
||||
|
||||
These are `str` subclasses whose `__new__` requires the metadata
|
||||
arguments, so without `__reduce__` `copy` cannot reconstruct one and
|
||||
raises. That is not academic: the SDK's `ConsoleMetricExporter` renders
|
||||
data points with `dataclasses.asdict()`, which deepcopies mappings, and
|
||||
core passes registry entries as metric attribute keys - see
|
||||
`test_console_metric_exporter_renders_core_metric_points`.
|
||||
A copied or unpickled entry is a plain str. ConsoleMetricExporter
|
||||
deepcopies metric attributes, which use registry entries as keys.
|
||||
"""
|
||||
for entry in (reg.DB_NAMESPACE, reg.DB_QUERY, reg.M_OPERATION_DURATION):
|
||||
assert copy.deepcopy({entry: 1}) == {str(entry): 1}
|
||||
assert type(copy.deepcopy(entry)) is str
|
||||
assert pickle.loads(pickle.dumps(entry)) == str(entry)
|
||||
# The metadata still lives on the registered instance itself, which
|
||||
# is the only place anything reads it.
|
||||
# The original entry keeps its metadata
|
||||
assert entry.description.strip()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_console_metric_exporter_renders_core_metric_points(otel_metrics):
|
||||
"""
|
||||
The end-to-end shape of the bug above: a console metrics dump of
|
||||
Datasette's own points has to survive `dataclasses.asdict()`.
|
||||
"""
|
||||
from opentelemetry.sdk.metrics.export import (
|
||||
ConsoleMetricExporter,
|
||||
MetricExportResult,
|
||||
|
|
@ -432,8 +342,7 @@ async def test_console_metric_exporter_renders_core_metric_points(otel_metrics):
|
|||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database(name)
|
||||
await ds.invoke_startup()
|
||||
# One real query, so the dump contains a db.client.operation.duration
|
||||
# point keyed by the DB_NAMESPACE registry entry.
|
||||
# Produces a db.client.operation.duration point keyed by DB_NAMESPACE
|
||||
await ds.get_database(name).execute("select 1")
|
||||
|
||||
data = otel_metrics.reader.get_metrics_data()
|
||||
|
|
@ -445,13 +354,8 @@ async def test_console_metric_exporter_renders_core_metric_points(otel_metrics):
|
|||
|
||||
def test_every_histogram_declares_bucket_boundaries():
|
||||
"""
|
||||
Every histogram must carry explicit boundaries, and only histograms may.
|
||||
|
||||
OpenTelemetry's default boundaries start at 5 and are meant for
|
||||
milliseconds, so a seconds-valued histogram that inherits them records
|
||||
everything into one bucket. This is a registry self-consistency check, not
|
||||
a check that the boundaries reached the SDK - for that see
|
||||
`test_histograms_spread_values_across_buckets` in test_telemetry_metrics.py.
|
||||
Every histogram declares bucket boundaries, and only histograms do.
|
||||
OpenTelemetry's defaults are meant for milliseconds, not seconds.
|
||||
"""
|
||||
for metric in reg.METRICS:
|
||||
if metric.kind == reg.HISTOGRAM:
|
||||
|
|
@ -468,13 +372,8 @@ def test_every_histogram_declares_bucket_boundaries():
|
|||
|
||||
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.
|
||||
dynamic=True entries such as the request span match on kind. They never
|
||||
match without a kind, and never override a registered name.
|
||||
"""
|
||||
assert reg.span_for("GET", SpanKind.SERVER) is reg.HTTP_REQUEST
|
||||
assert reg.span_for("POST /^/(?P<database>[^/]+)$", SpanKind.SERVER) is (
|
||||
|
|
@ -501,27 +400,15 @@ def test_span_and_attribute_lookup():
|
|||
@pytest_asyncio.fixture
|
||||
async def emitted_metrics(otel_metrics):
|
||||
"""
|
||||
Every (metric name, attribute key) pair produced by a broad workload,
|
||||
plus the raw set of metric names - the metric-side counterpart of the
|
||||
`emitted` span fixture above.
|
||||
|
||||
Metrics use DELTA temporality (see `otel_meter_provider` in datasette.telemetry_testing), and the
|
||||
function-scoped `otel_metrics` fixture drains any state left by an
|
||||
earlier test before yielding, so this collection is not polluted by
|
||||
other tests in the session - only by other *instances*, which is why the
|
||||
checks below key everything off attribute names rather than values.
|
||||
Metric names and (metric name, attribute key) pairs from a broad workload.
|
||||
Checks use attribute keys rather than values, since other Datasette
|
||||
instances in the session can also report points.
|
||||
"""
|
||||
# The span workload already reaches every synchronous metric except the
|
||||
# interrupted counter: reads and writes drive db.client.operation.duration
|
||||
# and datasette.write.queue_wait, and both the suppressed-error probe and
|
||||
# the custom_time_limit interrupt raise through record_operation_duration,
|
||||
# setting error.type.
|
||||
# Reaches every synchronous metric except datasette.sql.queries.interrupted
|
||||
ds = await exercise()
|
||||
|
||||
# datasette.sql.queries.interrupted counts only queries that exceed the
|
||||
# *configured* limit - a caller opting into a deliberately short budget
|
||||
# via custom_time_limit (as exercise() does) is excluded by design. So a
|
||||
# second instance whose configured limit is tiny provides the real thing.
|
||||
# datasette.sql.queries.interrupted ignores custom_time_limit timeouts, so
|
||||
# this needs an instance with a low sql_time_limit_ms.
|
||||
slow_name = _unique("registry_metrics_slow")
|
||||
slow = Datasette(memory=True, settings={"sql_time_limit_ms": 5})
|
||||
slow.add_memory_database(slow_name)
|
||||
|
|
@ -533,8 +420,7 @@ async def emitted_metrics(otel_metrics):
|
|||
"select * from c"
|
||||
)
|
||||
|
||||
# Collect while both instances are still registered, so the observable
|
||||
# gauges - which observe live instances at collection time - report.
|
||||
# Collect before closing the instances so the observable gauges report them
|
||||
otel_metrics.collect()
|
||||
snapshot = otel_metrics.snapshot
|
||||
assert snapshot, "no metrics captured - the fixture is not exercising anything"
|
||||
|
|
@ -551,11 +437,8 @@ async def emitted_metrics(otel_metrics):
|
|||
@pytest.mark.asyncio
|
||||
async def test_metrics_conform_to_the_registry(emitted_metrics):
|
||||
"""
|
||||
Emitted-but-unregistered, via the plugin kit's helper - consumed here
|
||||
exactly the way a plugin's suite would. Beyond names and attribute keys,
|
||||
this also asserts each instrument was created as the kind and unit its
|
||||
registry entry declares, and that `datasette.operation` only ever takes
|
||||
its declared enum values.
|
||||
Emitted metric names, kinds, units, attribute keys and enum values match
|
||||
the registry, using the plugin testing helper.
|
||||
"""
|
||||
assert_metrics_conform(
|
||||
reg.METRICS, emitted_metrics["collector"], scope_name="datasette"
|
||||
|
|
@ -564,7 +447,6 @@ async def test_metrics_conform_to_the_registry(emitted_metrics):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_registered_metric_is_emitted(emitted_metrics):
|
||||
"Registered-but-never-collected, via the plugin kit's helper."
|
||||
assert_metrics_covered(
|
||||
reg.METRICS, emitted_metrics["collector"], scope_name="datasette"
|
||||
)
|
||||
|
|
@ -572,23 +454,7 @@ async def test_every_registered_metric_is_emitted(emitted_metrics):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_registered_metric_attribute_is_emitted(emitted_metrics):
|
||||
"""
|
||||
The direction nothing else catches: the docs must not describe a metric
|
||||
attribute that no longer exists.
|
||||
|
||||
Unlike the span-side attribute check, this does not skip `optional`
|
||||
attributes. The only optional metric attribute is `error.type` on
|
||||
`db.client.operation.duration`, and the workload reaches it from two
|
||||
independent directions: the suppressed-error probe and the
|
||||
custom_time_limit interrupt in `exercise()`, both of which raise through
|
||||
`record_operation_duration`. So it is checked like any other attribute
|
||||
rather than exempted; marking something optional here would opt it out of
|
||||
verification entirely.
|
||||
|
||||
Gauges with no registered attributes (`datasette.sql.threads.limit` and
|
||||
`.queue_depth`) fall out correctly with no special case: their
|
||||
`metric.attributes` is empty, so the inner loop makes no assertion.
|
||||
"""
|
||||
"Every registered metric attribute, including optional ones, is emitted."
|
||||
emitted_keys_by_metric = {}
|
||||
for metric_name, key in emitted_metrics["pairs"]:
|
||||
emitted_keys_by_metric.setdefault(metric_name, set()).add(key)
|
||||
|
|
@ -596,8 +462,7 @@ async def test_every_registered_metric_attribute_is_emitted(emitted_metrics):
|
|||
missing = []
|
||||
for metric in reg.METRICS:
|
||||
if str(metric) not in emitted_metrics["names"]:
|
||||
# Not emitted at all - already reported by
|
||||
# test_every_registered_metric_is_emitted; do not double-report.
|
||||
# Reported by test_every_registered_metric_is_emitted
|
||||
continue
|
||||
emitted_keys = emitted_keys_by_metric.get(str(metric), set())
|
||||
for attribute in metric.attributes:
|
||||
|
|
@ -610,13 +475,7 @@ async def test_every_registered_metric_attribute_is_emitted(emitted_metrics):
|
|||
|
||||
|
||||
def test_prefix_span_lookup():
|
||||
"""
|
||||
`prefix=True` matching, exercised directly.
|
||||
|
||||
Core registers no prefix spans - the flag exists for plugin registries
|
||||
(e.g. a `chat {model}` span family) - so without this the branch in
|
||||
`span_for()` would be untested code the conformance tests never reach.
|
||||
"""
|
||||
"prefix=True matching, which core does not use but plugin registries can."
|
||||
hook = reg.SpanName("myplugin.hook.", "A hypothetical span family", prefix=True)
|
||||
spans = reg.SPANS + (hook,)
|
||||
assert reg.span_for("myplugin.hook.render_cell", spans=spans) is hook
|
||||
|
|
@ -626,7 +485,6 @@ def test_prefix_span_lookup():
|
|||
|
||||
|
||||
def test_exact_match_wins_over_prefix():
|
||||
"A prefix family can never shadow a span with a registered exact name."
|
||||
family = reg.SpanName("db.", "Greedy prefix", prefix=True)
|
||||
spans = (family,) + reg.SPANS
|
||||
assert reg.span_for("db.query", spans=spans) is reg.DB_QUERY
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""
|
||||
The plugin telemetry kit (`datasette.telemetry_testing` plus the public
|
||||
registry classes), exercised the way a third-party plugin would use it: a
|
||||
toy plugin registry, a toy tracer scope, and the kit's own fixtures and
|
||||
conformance helpers.
|
||||
Tests for datasette.telemetry_testing and the public registry classes, using
|
||||
a toy plugin registry and instrumentation scope.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
|
@ -54,8 +52,7 @@ def test_conformance_passes_for_a_conforming_workload(otel_spans):
|
|||
_run_workload()
|
||||
finished = otel_spans.get_finished_spans()
|
||||
assert_spans_conform(TOY_SPANS, finished, scope_name=SCOPE)
|
||||
# Coverage direction needs prefix families seen too - the chat span
|
||||
# resolves to the CHAT entry despite its variable suffix.
|
||||
# The chat span matches the CHAT prefix entry:
|
||||
assert_spans_covered(TOY_SPANS, finished, scope_name=SCOPE)
|
||||
|
||||
|
||||
|
|
@ -98,8 +95,7 @@ def test_coverage_catches_a_never_emitted_span(otel_spans):
|
|||
|
||||
|
||||
def test_scope_filter_ignores_other_scopes(otel_spans):
|
||||
# Core's own spans are in the exporter too; a plugin's conformance run
|
||||
# must not fail because of them.
|
||||
# Spans from other scopes, including Datasette's own, are ignored:
|
||||
other = otel_trace.get_tracer("someone-else", "1.0")
|
||||
with other.start_as_current_span("not.in.the.toy.registry"):
|
||||
pass
|
||||
|
|
@ -134,14 +130,9 @@ def test_linked_root_span_kwargs_with_no_current_span(otel_spans):
|
|||
|
||||
def test_kit_module_itself_never_imports_the_sdk():
|
||||
"""
|
||||
The kit imports the SDK lazily, so a plugin importing it at module
|
||||
level does not violate the api-only dependency rule.
|
||||
The kit imports the SDK lazily, so plugins can import it at module level.
|
||||
|
||||
conftest.py's pytest_collection_modifyitems() moves this test to the
|
||||
front of the run by name - if you rename it, rename it there too. Like
|
||||
every subprocess-spawning test in this suite, running it late crashes
|
||||
the interpreter on macOS/CPython 3.13 (SIGBUS in fork+exec once the
|
||||
process holds enough threads) - see the comment there.
|
||||
conftest.py runs this test first by name. Update it there if you rename it.
|
||||
"""
|
||||
assert_package_never_imports_sdk("datasette.telemetry_testing")
|
||||
|
||||
|
|
@ -159,8 +150,7 @@ from datasette.telemetry_testing import (
|
|||
|
||||
toy_meter = otel_metrics_api.get_meter(SCOPE, "0.1")
|
||||
|
||||
# Instrument names must be unique per meter for the SDK, so each test mints
|
||||
# its own via this counter rather than re-registering one name.
|
||||
# Gives each test a unique instrument name:
|
||||
_metric_ids = itertools.count()
|
||||
|
||||
|
||||
|
|
@ -251,14 +241,13 @@ def test_metrics_covered_skips_optional_attributes(otel_metrics):
|
|||
error_type = reg.Attribute("toyplugin.error", "Only on failure.", optional=True)
|
||||
registry = _toy_metric_registry(name, attributes=(OUTCOME, error_type))
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
counter.add(1, {OUTCOME: "ok"}) # no error attribute - and that is fine
|
||||
counter.add(1, {OUTCOME: "ok"}) # No error attribute
|
||||
otel_metrics.collect()
|
||||
assert_metrics_covered(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_scope_filter_ignores_other_scopes(otel_metrics):
|
||||
# Core's own metrics are in the reader too; a plugin's conformance run
|
||||
# must not fail because of them.
|
||||
# Metrics from other scopes, including Datasette's own, are ignored:
|
||||
name = f"toyplugin.scoped.{next(_metric_ids)}"
|
||||
registry = _toy_metric_registry(name)
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue