mirror of
https://github.com/simonw/datasette.git
synced 2026-09-03 07:04:16 +02:00
Mark in-process datasette.client requests on their SERVER span
An internal datasette.client request runs the full ASGI stack, so it emits a second SERVER span nested inside the outer request's - which double-counts requests in any dashboard that counts by span kind. Rather than downgrading the inner span to INTERNAL (which would diverge from how httpx-ASGI instrumentation behaves and break the registry's kind-based dynamic-name matching), the span now carries an optional datasette.internal_client=True attribute for dashboards to filter on. The in_datasette_client ContextVar moves to telemetry.py so the middleware can read it without a circular import; its writers and the in_client() accessor stay in app.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
This commit is contained in:
parent
4aaf20355b
commit
1e44c3dbec
6 changed files with 60 additions and 2 deletions
|
|
@ -51,6 +51,7 @@ from .renderer import json_renderer
|
|||
from .resources import DatabaseResource, TableResource
|
||||
from .telemetry import (
|
||||
TelemetryMiddleware,
|
||||
_in_datasette_client,
|
||||
clamp_http_method,
|
||||
request_span,
|
||||
tracer,
|
||||
|
|
@ -171,8 +172,9 @@ app_root = Path(__file__).parent.parent
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Context variable to track when code is executing within a datasette.client request
|
||||
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
||||
# _in_datasette_client itself lives in telemetry.py so the request span
|
||||
# middleware can read it without a circular import; its writers
|
||||
# (_DatasetteClientContext) and reader (in_client()) both live here.
|
||||
|
||||
|
||||
class _DatasetteClientContext:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ run-to-run variation. Installing an SDK provider is what costs
|
|||
something measurable.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import re
|
||||
|
||||
from opentelemetry import trace as otel_trace
|
||||
|
|
@ -25,6 +26,7 @@ from .telemetry_registry import (
|
|||
ERROR_TYPE,
|
||||
HTTP_REQUEST_METHOD,
|
||||
HTTP_RESPONSE_STATUS_CODE,
|
||||
INTERNAL_CLIENT,
|
||||
SERVER_ADDRESS,
|
||||
URL_PATH,
|
||||
URL_SCHEME,
|
||||
|
|
@ -32,6 +34,14 @@ from .telemetry_registry import (
|
|||
)
|
||||
from .version import __version__
|
||||
|
||||
# True while code is executing within a datasette.client request. Defined
|
||||
# here rather than in app.py (which owns its writers and the in_client()
|
||||
# accessor) so TelemetryMiddleware can read it without a circular import:
|
||||
# an in-process sub-request runs the full ASGI stack, so it emits a second,
|
||||
# nested SERVER span - datasette.internal_client marks those so kind-based
|
||||
# dashboards can filter the double-count out.
|
||||
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
||||
|
||||
# The semantic-convention version whose spellings this instrumentation
|
||||
# actually emits. Deliberately NOT the latest release.
|
||||
#
|
||||
|
|
@ -301,6 +311,8 @@ class TelemetryMiddleware:
|
|||
user_agent = _first_header(headers, b"user-agent")
|
||||
if user_agent:
|
||||
span.set_attribute(USER_AGENT_ORIGINAL, user_agent)
|
||||
if _in_datasette_client.get():
|
||||
span.set_attribute(INTERNAL_CLIENT, True)
|
||||
|
||||
# A copy, not a mutation: the scope belongs to the server, and
|
||||
# every other layer in Datasette extends it the same way.
|
||||
|
|
|
|||
|
|
@ -136,6 +136,16 @@ USER_AGENT_ORIGINAL = Attribute(
|
|||
"The ``User-Agent`` header, verbatim. Omitted if the client sent none.",
|
||||
optional=True,
|
||||
)
|
||||
INTERNAL_CLIENT = Attribute(
|
||||
"datasette.internal_client",
|
||||
"``True`` when the request was made in-process through "
|
||||
"``datasette.client`` rather than arriving over the network. Such a "
|
||||
"sub-request runs the full ASGI stack, so it emits its own nested "
|
||||
"``SERVER`` span inside the outer request's - filter on this attribute "
|
||||
"to keep kind-based dashboards from double-counting requests. Omitted "
|
||||
"for real inbound requests.",
|
||||
optional=True,
|
||||
)
|
||||
ERROR_TYPE = Attribute(
|
||||
"error.type",
|
||||
"Set when the request failed: the exception class name if one escaped the "
|
||||
|
|
@ -283,6 +293,7 @@ HTTP_REQUEST = SpanName(
|
|||
USER_AGENT_ORIGINAL,
|
||||
HTTP_RESPONSE_STATUS_CODE,
|
||||
ERROR_TYPE,
|
||||
INTERNAL_CLIENT,
|
||||
),
|
||||
dynamic=True,
|
||||
kind=SpanKind.SERVER,
|
||||
|
|
|
|||
|
|
@ -2397,6 +2397,7 @@ That is the route's compiled regular expression, not a prettified ``/{database}/
|
|||
- ``user_agent.original`` *(optional)* - The ``User-Agent`` header, verbatim. Omitted if the client sent none.
|
||||
- ``http.response.status_code`` *(optional)* - The status of the response, read from the ASGI ``http.response.start`` message rather than from a :ref:`internals_response` object - several views, including static files, file downloads and streaming CSV, send that message themselves and never build one. Omitted if the connection closed before anything was sent.
|
||||
- ``error.type`` *(optional)* - Set when the request failed: the exception class name if one escaped the application, otherwise the status code as a string for a 5xx response. A 4xx does **not** set this and does not set an error status - per semantic conventions a client error is not a server span's failure.
|
||||
- ``datasette.internal_client`` *(optional)* - ``True`` when the request was made in-process through ``datasette.client`` rather than arriving over the network. Such a sub-request runs the full ASGI stack, so it emits its own nested ``SERVER`` span inside the outer request's - filter on this attribute to keep kind-based dashboards from double-counting requests. Omitted for real inbound requests.
|
||||
|
||||
``db.query``
|
||||
A SQL operation issued by Datasette, covering the full round trip including any time spent queued for a thread. Callback-style calls - ``execute_fn()``, ``execute_write_fn()`` and ``execute_isolated_fn()`` - appear here too, distinguished by ``datasette.callback`` in place of ``db.query.text``.
|
||||
|
|
|
|||
|
|
@ -856,3 +856,34 @@ def test_no_provider_takes_the_fast_path():
|
|||
)
|
||||
# Same fast path, other observable: nothing is stashed 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.
|
||||
"""
|
||||
otel_spans.clear()
|
||||
assert (await ds.client.get("/")).status_code == 200
|
||||
server = _server_spans(otel_spans)
|
||||
assert server
|
||||
assert all(
|
||||
span.attributes.get("datasette.internal_client") is True for span in server
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
||||
transport = httpx.ASGITransport(app=ds.app())
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://localhost"
|
||||
) as client:
|
||||
otel_spans.clear()
|
||||
assert (await client.get("/")).status_code == 200
|
||||
server = _server_spans(otel_spans)
|
||||
assert server
|
||||
assert all("datasette.internal_client" not in span.attributes for span in server)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ EXPECTED_HTTP_ATTRIBUTES = {
|
|||
"user_agent.original",
|
||||
"http.response.status_code",
|
||||
"error.type",
|
||||
"datasette.internal_client",
|
||||
}
|
||||
|
||||
# The registry's own name for the request span is that template, not anything
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue