mirror of
https://github.com/simonw/datasette.git
synced 2026-10-26 18:26:59 +01:00
Add OpenTelemetry metrics for SQL thread pool saturation and query latency
Spans describe requests that have finished. They structurally cannot answer "am I saturating my 3 SQL threads right now", because that is a level rather than an event - and with num_sql_threads defaulting to 3, it is usually the first thing worth knowing about a busy Datasette. This adds the metrics that answer it. Five observable gauges, computed only when something is collecting, so an instance with no MeterProvider installed does no work for them at all: datasette.sql.threads.limit num_sql_threads datasette.sql.threads.queue_depth queries waiting for a free thread datasette.sql.queries.pending in-flight reads, by db.namespace datasette.write.queue_depth writes behind the single write thread datasette.connections.open tracked file connections Three instruments recorded inline, which matters because metrics survive trace sampling and spans do not - an operator sampling 1% of traces still gets 100% of the latency distribution: db.client.operation.duration semconv histogram, with error.type datasette.write.queue_wait the metric twin of the existing span datasette.sql.queries.interrupted sql_time_limit_ms kills The interrupted counter closes a gap the plan called out as unanswerable: "how often are we killing queries at the limit" is a rate, and a rate cannot be recovered from sampled spans. Core still creates no provider of any kind, so the architecture is unchanged; `grep -rn 'opentelemetry.sdk' datasette/` stays empty. One real difference from tracing is worth recording: _ProxyMeter and its instruments forward to a provider installed after they were created, whereas ProxyTracer permanently caches the first concrete tracer it resolves. Module-level instruments are therefore safe and the test fixture has no ordering constraint. Live instances are tracked in a lock-guarded WeakSet so instrumenting an instance never keeps it alive. The pool gauges carry no attribute saying which Datasette produced them: production runs one instance per process, and adding an id to disambiguate the test suite's hundreds of instances would buy unbounded attribute cardinality to fix a case that does not occur. The collision is documented instead, and the gauge callbacks are plain generator functions so tests can assert exact values by calling them directly rather than through the SDK's last-value aggregation. demos/otel/metrics_demo.py fires 12 concurrent 40ms queries at a 3-thread pool and samples the gauges mid-flight: queue_depth peaks at exactly 9, and the duration histogram reads max=0.1695s for a query whose work is 40ms. That gap is the queue, and it is the thing traces alone will not show you. Also corrects the demo README's privacy section, which still claimed parameter values are never recorded - that stopped being unconditionally true when trace_sql_parameters landed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from 6ef0dd8c and adapted to the rebuilt phase-1 stack: attribute names now come from telemetry_registry where entries exist, the meter carries the instrumentation-scope version and schema URL, and the interrupted-queries counter skips expected timeouts - callers that opted into a deliberately short budget, like facet suggestion - matching how those are excluded from span error status. The internals.rst reference lands with the registry commit that follows.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2h9ANGZ7paWSpqs5DUAcG
This commit is contained in:
parent
9bce2eec7c
commit
90b727db73
6 changed files with 730 additions and 16 deletions
|
|
@ -17,7 +17,15 @@ from opentelemetry import context as otel_context_api
|
|||
from opentelemetry.trace import Link, Status, StatusCode, get_current_span
|
||||
|
||||
from .inspect import inspect_hash
|
||||
from .telemetry import callback_name, sql_attribute, sql_operation_name, tracer
|
||||
from .telemetry import (
|
||||
callback_name,
|
||||
record_operation_duration,
|
||||
record_query_interrupted,
|
||||
record_write_queue_wait,
|
||||
sql_attribute,
|
||||
sql_operation_name,
|
||||
tracer,
|
||||
)
|
||||
from .telemetry_registry import (
|
||||
CALLBACK,
|
||||
DB_NAMESPACE,
|
||||
|
|
@ -316,9 +324,10 @@ class Database:
|
|||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
||||
if params:
|
||||
span.set_attribute(PARAM_COUNT, len(params))
|
||||
results = await self._execute_write_fn(
|
||||
_inner, block=block, request=request, transaction=transaction
|
||||
)
|
||||
with record_operation_duration(self.name, "write"):
|
||||
results = await self._execute_write_fn(
|
||||
_inner, block=block, request=request, transaction=transaction
|
||||
)
|
||||
return results
|
||||
|
||||
async def execute_write_script(self, sql, block=True, request=None):
|
||||
|
|
@ -340,9 +349,10 @@ class Database:
|
|||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
||||
span.set_attribute(EXECUTESCRIPT, True)
|
||||
results = await self._execute_write_fn(
|
||||
_inner, block=block, transaction=False, request=request
|
||||
)
|
||||
with record_operation_duration(self.name, "write"):
|
||||
results = await self._execute_write_fn(
|
||||
_inner, block=block, transaction=False, request=request
|
||||
)
|
||||
return results
|
||||
|
||||
async def execute_write_many(self, sql, params_seq, block=True, request=None):
|
||||
|
|
@ -373,9 +383,10 @@ class Database:
|
|||
operation_name = sql_operation_name(sql)
|
||||
if operation_name:
|
||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
||||
results, count = await self._execute_write_fn(
|
||||
_inner, block=block, request=request
|
||||
)
|
||||
with record_operation_duration(self.name, "write"):
|
||||
results, count = await self._execute_write_fn(
|
||||
_inner, block=block, request=request
|
||||
)
|
||||
# count is the number of parameter *sets* consumed by
|
||||
# executemany(), not a row count - executemany returns no rows.
|
||||
span.set_attribute(PARAM_SETS, count)
|
||||
|
|
@ -665,11 +676,15 @@ class Database:
|
|||
# this span's duration is the time the task actually spent
|
||||
# waiting in the queue (enqueue -> dequeue), not the near-
|
||||
# zero time spent constructing/ending the span object here.
|
||||
dequeued_at_ns = time.time_ns()
|
||||
tracer.start_span(
|
||||
DB_WRITE_QUEUE_WAIT,
|
||||
start_time=task.enqueued_at_ns,
|
||||
**write_span_kwargs,
|
||||
).end(end_time=time.time_ns())
|
||||
).end(end_time=dequeued_at_ns)
|
||||
record_write_queue_wait(
|
||||
self.name, dequeued_at_ns - task.enqueued_at_ns
|
||||
)
|
||||
if conn_exception is not None:
|
||||
# fn never runs in this branch, so there is nothing to
|
||||
# wrap in a db.write.execute span.
|
||||
|
|
@ -915,7 +930,8 @@ class Database:
|
|||
if params:
|
||||
span.set_attribute(PARAM_COUNT, len(params))
|
||||
try:
|
||||
results = await self._execute_fn(sql_operation_in_thread)
|
||||
with record_operation_duration(self.name, "read"):
|
||||
results = await self._execute_fn(sql_operation_in_thread)
|
||||
except QueryInterrupted as e:
|
||||
# datasette.interrupted is set either way - it is the
|
||||
# signal worth having. Only the ERROR status is
|
||||
|
|
@ -924,6 +940,14 @@ class Database:
|
|||
if not timeout_expected:
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
span.record_exception(e)
|
||||
# A counter rather than only a span, because this is the
|
||||
# one thing an operator wants a rate and an alert on, and
|
||||
# spans under a 1% sampler cannot provide either. An
|
||||
# expected timeout - a caller that opted into a shorter
|
||||
# budget, like facet suggestion - is not counted, for the
|
||||
# same reason it is not a span error: it fires routinely
|
||||
# by design and would drown the signal this exists for.
|
||||
record_query_interrupted(self.name)
|
||||
raise
|
||||
except Exception as e:
|
||||
# log_sql_errors=False means the caller is probing and
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue