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
2026-07-30 09:43:30 -07:00
|
|
|
"""
|
2026-09-24 13:52:29 -07:00
|
|
|
Tests for the OpenTelemetry metrics emitted by Datasette. Gauge callbacks are
|
|
|
|
|
called directly, since the pool gauges have no attributes to tell instances apart.
|
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
2026-07-30 09:43:30 -07:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-09-02 11:31:12 -07:00
|
|
|
import threading
|
|
|
|
|
import weakref
|
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
2026-07-30 09:43:30 -07:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from datasette import telemetry
|
|
|
|
|
from datasette.app import Datasette
|
|
|
|
|
from datasette.database import Database
|
|
|
|
|
from datasette.utils.sqlite import sqlite3
|
|
|
|
|
|
|
|
|
|
pytestmark = pytest.mark.filterwarnings("ignore::ResourceWarning")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def observations(callback, datasette=None):
|
|
|
|
|
"""
|
|
|
|
|
Run a gauge callback, optionally keeping only observations produced by one
|
|
|
|
|
Datasette's databases. Returns a list of (attributes dict, value).
|
|
|
|
|
"""
|
|
|
|
|
results = []
|
|
|
|
|
names = None
|
|
|
|
|
if datasette is not None:
|
|
|
|
|
names = {db.name for db in telemetry._databases_of(datasette)}
|
|
|
|
|
for observation in callback():
|
|
|
|
|
attributes = dict(observation.attributes or {})
|
|
|
|
|
namespace = attributes.get("db.namespace")
|
|
|
|
|
if names is not None and namespace is not None and namespace not in names:
|
|
|
|
|
continue
|
|
|
|
|
results.append((attributes, observation.value))
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def metrics_ds():
|
|
|
|
|
"A Datasette with a distinctive thread count and a uniquely named database."
|
|
|
|
|
ds = Datasette(
|
|
|
|
|
memory=True,
|
|
|
|
|
settings={"num_sql_threads": 7},
|
|
|
|
|
)
|
|
|
|
|
ds.add_memory_database("metrics_test_db")
|
|
|
|
|
try:
|
|
|
|
|
yield ds
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)]
|
2026-09-24 13:52:29 -07:00
|
|
|
# Other Datasette instances may also be reporting:
|
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
2026-07-30 09:43:30 -07:00
|
|
|
assert 7 in values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_no_thread_gauges_in_non_threaded_mode():
|
2026-09-24 13:52:29 -07:00
|
|
|
"Pool gauges skip instances with num_sql_threads=0, which have no pool."
|
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
2026-07-30 09:43:30 -07:00
|
|
|
ds = Datasette(memory=True, settings={"num_sql_threads": 0})
|
|
|
|
|
try:
|
|
|
|
|
assert ds.executor is None
|
2026-09-24 13:52:29 -07:00
|
|
|
# Pool gauges have no attributes, so observe only this instance:
|
2026-09-02 11:31:12 -07:00
|
|
|
original = telemetry._live_datasettes
|
|
|
|
|
telemetry._live_datasettes = weakref.WeakSet([ds])
|
|
|
|
|
try:
|
|
|
|
|
assert list(telemetry.observe_sql_thread_limit()) == []
|
|
|
|
|
assert list(telemetry.observe_sql_thread_queue_depth()) == []
|
|
|
|
|
finally:
|
|
|
|
|
telemetry._live_datasettes = original
|
2026-09-24 13:52:29 -07:00
|
|
|
# Per-database gauges do not depend on the pool:
|
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
2026-07-30 09:43:30 -07:00
|
|
|
assert observations(telemetry.observe_pending_queries, ds)
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 11:31:12 -07:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_thread_queue_depth_gauge_reports_saturation():
|
|
|
|
|
"""
|
2026-09-24 13:52:29 -07:00
|
|
|
Queue depth is above zero when reads queue behind num_sql_threads. Also
|
|
|
|
|
fails if the private ThreadPoolExecutor._work_queue attribute goes away.
|
2026-09-02 11:31:12 -07:00
|
|
|
"""
|
|
|
|
|
ds = Datasette(memory=True, settings={"num_sql_threads": 1})
|
|
|
|
|
db = ds.add_memory_database("metrics_saturation_db")
|
|
|
|
|
entered = threading.Event()
|
|
|
|
|
release = threading.Event()
|
|
|
|
|
|
|
|
|
|
def blocker(conn):
|
|
|
|
|
entered.set()
|
|
|
|
|
assert release.wait(timeout=10)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
first = asyncio.ensure_future(db.execute_fn(blocker))
|
2026-09-24 13:52:29 -07:00
|
|
|
# Wait until the blocker is using the only thread:
|
2026-09-02 11:31:12 -07:00
|
|
|
await asyncio.get_running_loop().run_in_executor(None, entered.wait, 10)
|
|
|
|
|
second = asyncio.ensure_future(db.execute_fn(lambda conn: 2))
|
2026-09-24 13:52:29 -07:00
|
|
|
# The second query is queued on a later event loop turn, so poll:
|
2026-09-02 11:31:12 -07:00
|
|
|
depths = []
|
|
|
|
|
for _ in range(500):
|
|
|
|
|
depths = [
|
|
|
|
|
value
|
|
|
|
|
for _, value in observations(telemetry.observe_sql_thread_queue_depth)
|
|
|
|
|
]
|
|
|
|
|
if any(value >= 1 for value in depths):
|
|
|
|
|
break
|
|
|
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
assert any(value >= 1 for value in depths), depths
|
|
|
|
|
release.set()
|
|
|
|
|
assert await first == 1
|
|
|
|
|
assert await second == 2
|
|
|
|
|
finally:
|
|
|
|
|
release.set()
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
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
2026-07-30 09:43:30 -07:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_pending_queries_gauge_tracks_in_flight_queries(metrics_ds):
|
|
|
|
|
db = metrics_ds.get_database("metrics_test_db")
|
|
|
|
|
attributes = {"db.namespace": "metrics_test_db"}
|
|
|
|
|
|
|
|
|
|
def value():
|
|
|
|
|
points = [
|
|
|
|
|
v
|
|
|
|
|
for a, v in observations(telemetry.observe_pending_queries, metrics_ds)
|
|
|
|
|
if a == attributes
|
|
|
|
|
]
|
|
|
|
|
assert len(points) == 1
|
|
|
|
|
return points[0]
|
|
|
|
|
|
|
|
|
|
assert value() == 0
|
|
|
|
|
|
2026-09-24 13:52:29 -07:00
|
|
|
# Hold the worker thread until release is set:
|
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
2026-07-30 09:43:30 -07:00
|
|
|
release = asyncio.Event()
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
entered = asyncio.Event()
|
|
|
|
|
|
|
|
|
|
def blocking_fn(conn):
|
|
|
|
|
loop.call_soon_threadsafe(entered.set)
|
|
|
|
|
asyncio.run_coroutine_threadsafe(release.wait(), loop).result()
|
|
|
|
|
return "done"
|
|
|
|
|
|
|
|
|
|
task = asyncio.ensure_future(db.execute_fn(blocking_fn))
|
|
|
|
|
await entered.wait()
|
|
|
|
|
assert value() == 1, "a query occupying a pool thread must be counted as pending"
|
|
|
|
|
release.set()
|
|
|
|
|
assert await task == "done"
|
|
|
|
|
assert value() == 0, "the count must drop once the query completes"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_write_queue_depth_gauge(metrics_ds):
|
|
|
|
|
db = metrics_ds.get_database("metrics_test_db")
|
|
|
|
|
attributes = {"db.namespace": "metrics_test_db"}
|
|
|
|
|
|
|
|
|
|
def depths():
|
|
|
|
|
return [
|
|
|
|
|
v
|
|
|
|
|
for a, v in observations(telemetry.observe_write_queue_depth, metrics_ds)
|
|
|
|
|
if a == attributes
|
|
|
|
|
]
|
|
|
|
|
|
2026-09-24 13:52:29 -07:00
|
|
|
# No observation until the write queue has been created:
|
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
2026-07-30 09:43:30 -07:00
|
|
|
assert depths() == []
|
|
|
|
|
|
|
|
|
|
await db.execute_write("create table t (id integer primary key)")
|
|
|
|
|
assert depths() == [0], "an idle write queue reports zero, not nothing"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_open_connections_gauge(metrics_ds, tmp_path):
|
|
|
|
|
path = str(tmp_path / "conns.db")
|
|
|
|
|
sqlite3.connect(path).execute("create table t (id integer primary key)")
|
|
|
|
|
db = metrics_ds.add_database(Database(metrics_ds, path=path), name="conns_db")
|
|
|
|
|
attributes = {"db.namespace": "conns_db"}
|
|
|
|
|
|
|
|
|
|
def open_connections():
|
|
|
|
|
points = [
|
|
|
|
|
v
|
|
|
|
|
for a, v in observations(telemetry.observe_open_connections, metrics_ds)
|
|
|
|
|
if a == attributes
|
|
|
|
|
]
|
|
|
|
|
assert len(points) == 1
|
|
|
|
|
return points[0]
|
|
|
|
|
|
|
|
|
|
assert open_connections() == 0
|
|
|
|
|
await db.execute("select 1")
|
|
|
|
|
assert open_connections() >= 1, "executing a query opens a tracked connection"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_operation_duration_histogram_read(otel_metrics):
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("duration_read_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("duration_read_db")
|
|
|
|
|
await db.execute("select 1")
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "duration_read_db", "datasette.operation": "read"},
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 1
|
|
|
|
|
assert point.sum > 0
|
|
|
|
|
assert dict(point.attributes)["db.system"] == "sqlite"
|
|
|
|
|
assert "error.type" not in dict(point.attributes)
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_operation_duration_histogram_write(otel_metrics):
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("duration_write_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("duration_write_db")
|
|
|
|
|
await db.execute_write("create table t (id integer primary key)")
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "duration_write_db", "datasette.operation": "write"},
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 1
|
|
|
|
|
assert point.sum > 0
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_operation_duration_records_error_type(otel_metrics):
|
|
|
|
|
"A failed query is still timed, and is separable from a successful one."
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("duration_error_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("duration_error_db")
|
|
|
|
|
with pytest.raises(sqlite3.OperationalError):
|
|
|
|
|
await db.execute("select * from nope")
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "duration_error_db", "datasette.operation": "read"},
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 1
|
|
|
|
|
assert dict(point.attributes)["error.type"] == "OperationalError"
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 11:31:12 -07:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_operation_duration_records_write_error_type(otel_metrics):
|
2026-09-24 13:52:29 -07:00
|
|
|
"A failed write is still timed and records error.type."
|
2026-09-02 11:31:12 -07:00
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("duration_write_error_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("duration_write_error_db")
|
|
|
|
|
with pytest.raises(sqlite3.OperationalError):
|
|
|
|
|
await db.execute_write("insert into nope values (1)")
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "duration_write_error_db", "datasette.operation": "write"},
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 1
|
|
|
|
|
assert dict(point.attributes)["error.type"] == "OperationalError"
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
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
2026-07-30 09:43:30 -07:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_write_queue_wait_histogram(otel_metrics):
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("queue_wait_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("queue_wait_db")
|
|
|
|
|
await db.execute_write("create table t (id integer primary key)")
|
|
|
|
|
await db.execute_write("insert into t (id) values (1)")
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"datasette.write.queue_wait", {"db.namespace": "queue_wait_db"}
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 2, "one measurement per write dequeued"
|
|
|
|
|
assert point.sum >= 0
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_interrupted_queries_counter(otel_metrics):
|
2026-09-24 13:52:29 -07:00
|
|
|
"Queries cancelled by sql_time_limit_ms are counted."
|
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
2026-07-30 09:43:30 -07:00
|
|
|
ds = Datasette(memory=True, settings={"sql_time_limit_ms": 1})
|
|
|
|
|
ds.add_memory_database("interrupted_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("interrupted_db")
|
|
|
|
|
from datasette.database import QueryInterrupted
|
|
|
|
|
|
|
|
|
|
with pytest.raises(QueryInterrupted):
|
|
|
|
|
await db.execute("""
|
|
|
|
|
with recursive counter(x) as (
|
|
|
|
|
select 0 union all select x + 1 from counter
|
|
|
|
|
)
|
|
|
|
|
select * from counter
|
|
|
|
|
""")
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"datasette.sql.queries.interrupted", {"db.namespace": "interrupted_db"}
|
|
|
|
|
)
|
|
|
|
|
assert point.value == 1
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_metrics_are_reported_through_the_sdk_for_gauges(otel_metrics):
|
2026-09-24 13:52:29 -07:00
|
|
|
"Gauge callbacks reach the metric reader as data points."
|
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
2026-07-30 09:43:30 -07:00
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("gauge_pipeline_db")
|
|
|
|
|
try:
|
|
|
|
|
await ds.get_database("gauge_pipeline_db").execute("select 1")
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"datasette.sql.queries.pending", {"db.namespace": "gauge_pipeline_db"}
|
|
|
|
|
)
|
|
|
|
|
assert point.value == 0
|
|
|
|
|
assert otel_metrics.points("datasette.sql.threads.limit")
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_closed_datasette_stops_being_observed():
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("closed_db")
|
|
|
|
|
assert observations(telemetry.observe_pending_queries, ds)
|
|
|
|
|
ds.close()
|
|
|
|
|
names = [
|
|
|
|
|
attributes.get("db.namespace")
|
|
|
|
|
for attributes, _ in observations(telemetry.observe_pending_queries)
|
|
|
|
|
]
|
|
|
|
|
assert "closed_db" not in names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_holds_instances_weakly():
|
|
|
|
|
"""
|
2026-09-24 13:52:29 -07:00
|
|
|
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.
|
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
2026-07-30 09:43:30 -07:00
|
|
|
"""
|
|
|
|
|
import gc
|
|
|
|
|
import weakref
|
|
|
|
|
|
|
|
|
|
class FakeDatasette:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
fake = FakeDatasette()
|
|
|
|
|
telemetry.register_datasette(fake)
|
|
|
|
|
assert fake in telemetry._live_instances()
|
|
|
|
|
ref = weakref.ref(fake)
|
|
|
|
|
del fake
|
|
|
|
|
gc.collect()
|
|
|
|
|
assert ref() is None
|
|
|
|
|
assert not any(isinstance(ds, FakeDatasette) for ds in telemetry._live_instances())
|
2026-09-01 12:24:01 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
HISTOGRAM_PROBES = [
|
|
|
|
|
# (instrument attribute on telemetry, metric name, isolating attributes)
|
|
|
|
|
(
|
|
|
|
|
"sql_operation_duration",
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "bucket_probe_operation"},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"write_queue_wait",
|
|
|
|
|
"datasette.write.queue_wait",
|
|
|
|
|
{"db.namespace": "bucket_probe_queue_wait"},
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
2026-09-24 13:52:29 -07:00
|
|
|
# One value in each of six registry buckets. The SDK's default boundaries
|
|
|
|
|
# would put the first five in the same bucket.
|
2026-09-01 12:24:01 -07:00
|
|
|
SPREAD = [0.00005, 0.0003, 0.002, 0.03, 0.8, 7.0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"instrument_name,metric_name,attributes",
|
|
|
|
|
HISTOGRAM_PROBES,
|
|
|
|
|
ids=[metric for _, metric, _ in HISTOGRAM_PROBES],
|
|
|
|
|
)
|
|
|
|
|
def test_histograms_spread_values_across_buckets(
|
|
|
|
|
otel_metrics, instrument_name, metric_name, attributes
|
|
|
|
|
):
|
|
|
|
|
"""
|
2026-09-24 13:52:29 -07:00
|
|
|
The registry's bucket boundaries reach the SDK. Values are recorded
|
|
|
|
|
directly because real test query durations would all share one bucket.
|
2026-09-01 12:24:01 -07:00
|
|
|
"""
|
|
|
|
|
from datasette.telemetry_registry import METRICS
|
|
|
|
|
|
|
|
|
|
metric = next(m for m in METRICS if m == metric_name)
|
|
|
|
|
instrument = getattr(telemetry, instrument_name)
|
|
|
|
|
for value in SPREAD:
|
|
|
|
|
instrument.record(value, attributes)
|
|
|
|
|
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(metric_name, attributes)
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
tuple(point.explicit_bounds) == metric.buckets
|
|
|
|
|
), "the registry's boundaries did not reach the SDK"
|
|
|
|
|
assert point.count == len(SPREAD)
|
|
|
|
|
occupied = [count for count in point.bucket_counts if count]
|
|
|
|
|
assert len(occupied) == len(SPREAD), (
|
|
|
|
|
f"expected each of {SPREAD} in its own bucket, got bucket counts "
|
|
|
|
|
f"{list(point.bucket_counts)} for bounds {list(point.explicit_bounds)}"
|
|
|
|
|
)
|
2026-09-02 11:51:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_operation_duration_histogram_records_execute_fn(otel_metrics):
|
2026-09-24 13:52:29 -07:00
|
|
|
"execute_fn() reads are recorded in the same histogram as SQL reads."
|
2026-09-02 11:51:03 -07:00
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("duration_fn_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("duration_fn_db")
|
|
|
|
|
|
|
|
|
|
def read_one(conn):
|
|
|
|
|
return conn.execute("select 1").fetchone()[0]
|
|
|
|
|
|
|
|
|
|
assert await db.execute_fn(read_one) == 1
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "duration_fn_db", "datasette.operation": "read"},
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 1
|
|
|
|
|
assert point.sum > 0
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_operation_duration_histogram_records_execute_write_fn(otel_metrics):
|
2026-09-24 13:52:29 -07:00
|
|
|
"execute_write_fn() writes are recorded in the same histogram."
|
2026-09-02 11:51:03 -07:00
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("duration_write_fn_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("duration_write_fn_db")
|
|
|
|
|
|
|
|
|
|
def create_table(conn):
|
|
|
|
|
conn.execute("create table t (id integer primary key)")
|
|
|
|
|
|
|
|
|
|
await db.execute_write_fn(create_table)
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "duration_write_fn_db", "datasette.operation": "write"},
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 1
|
|
|
|
|
assert point.sum > 0
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_operation_duration_records_callback_error_type(otel_metrics):
|
|
|
|
|
"A callback that raises is still timed, with error.type from the exception."
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database("duration_fn_error_db")
|
|
|
|
|
try:
|
|
|
|
|
db = ds.get_database("duration_fn_error_db")
|
|
|
|
|
|
|
|
|
|
def boom(conn):
|
|
|
|
|
raise ValueError("callback failed")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
await db.execute_fn(boom)
|
|
|
|
|
otel_metrics.collect()
|
|
|
|
|
point = otel_metrics.point(
|
|
|
|
|
"db.client.operation.duration",
|
|
|
|
|
{"db.namespace": "duration_fn_error_db", "datasette.operation": "read"},
|
|
|
|
|
)
|
|
|
|
|
assert point.count == 1
|
|
|
|
|
assert dict(point.attributes)["error.type"] == "ValueError"
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|