mirror of
https://github.com/simonw/datasette.git
synced 2026-09-02 14:44:07 +02:00
Add opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing hand-rolled tracer. This commit only lays the groundwork - no span is emitted yet. Core takes a runtime dependency on opentelemetry-api and nothing more. It deliberately never creates a TracerProvider, configures an exporter, or touches sampling: that belongs to whoever runs Datasette, normally via an opentelemetry-instrument agent. Owning a provider in core was tried in an earlier design and produced a cross-request span leak, a process-global provider that tests could not tear down, and a sampling env var that silently blanked output. With no provider installed every span is a NonRecordingSpan and costs approximately nothing. datasette/telemetry.py exposes the module-level tracer plus sql_attribute(), which truncates SQL to 2048 characters. On a public instance the SQL is attacker-controlled and unbounded - someone can paste a 10MB query into ?sql= - so it must never reach a telemetry pipeline verbatim. opentelemetry-sdk goes in the dev dependency group only, because the test suite needs it to assert on spans while the package itself must not import it. tests/test_telemetry.py enforces that by importing datasette in a fresh interpreter and inspecting sys.modules, which catches a lazy import inside a function body that a grep would miss. conftest.py gains a session-scoped autouse fixture installing an SDK provider with an InMemorySpanExporter. It has to be session-scoped because set_tracer_provider() is effectively once-per-process - a second call logs a warning and is ignored. SimpleSpanProcessor rather than BatchSpanProcessor, so assertions made right after a request never race a background export thread. The otel_spans fixture that later tickets assert against is added here too. test_datasette_package_never_imports_the_sdk is moved to the front of the run. Late in a serial run the pytest process holds enough threads that the fork half of subprocess' fork+exec segfaults the interpreter on macOS/CPython 3.13. That reproduces with any subprocess call in that position on an unmodified tree, so it is a pre-existing hazard rather than something this commit introduces; the repo already moves its other subprocess-spawning tests to the front for related reasons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
bdc9731740
commit
8194cb5a1d
4 changed files with 113 additions and 0 deletions
24
datasette/telemetry.py
Normal file
24
datasette/telemetry.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""
|
||||
OpenTelemetry integration for Datasette core.
|
||||
|
||||
Core depends on `opentelemetry-api` only. It never creates a
|
||||
`TracerProvider`, never configures an exporter, and never touches
|
||||
sampling - that is the responsibility of whoever is running Datasette
|
||||
(an `opentelemetry-instrument` agent, a future plugin, or a test
|
||||
harness). With no provider installed every span produced here is a
|
||||
`NonRecordingSpan` and costs approximately nothing.
|
||||
"""
|
||||
|
||||
from opentelemetry import trace as otel_trace
|
||||
|
||||
tracer = otel_trace.get_tracer("datasette")
|
||||
|
||||
MAX_SQL_LENGTH = 2048
|
||||
|
||||
|
||||
def sql_attribute(sql: str) -> str:
|
||||
"Truncate SQL text so it is safe to attach to a span as an attribute."
|
||||
sql = sql.strip()
|
||||
if len(sql) <= MAX_SQL_LENGTH:
|
||||
return sql
|
||||
return sql[:MAX_SQL_LENGTH] + "…[truncated]"
|
||||
|
|
@ -40,6 +40,7 @@ dependencies = [
|
|||
"setuptools",
|
||||
"pip",
|
||||
"pydantic>=2",
|
||||
"opentelemetry-api>=1.37",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
|
@ -70,6 +71,7 @@ dev = [
|
|||
"cogapp>=3.3.0",
|
||||
"multipart-form-data-conformance==0.1a0",
|
||||
"ruff>=0.16.0",
|
||||
"opentelemetry-sdk>=1.37",
|
||||
# docs
|
||||
"Sphinx==7.4.7",
|
||||
"furo==2025.9.25",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,62 @@ def find_free_port():
|
|||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
_otel_span_exporter = None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _otel_provider():
|
||||
"""
|
||||
Install a real OTel SDK TracerProvider + InMemorySpanExporter exactly
|
||||
once, before any span is ever created in this process.
|
||||
|
||||
This has to be session-scoped and autouse because
|
||||
`opentelemetry.trace.set_tracer_provider()` is effectively
|
||||
once-per-process: a second call logs a warning and is ignored. So the
|
||||
install must happen exactly once, before anything asserts on spans.
|
||||
|
||||
`datasette.telemetry.tracer` is a module-level `ProxyTracer`. Once a
|
||||
provider exists, the first span it starts resolves a concrete tracer
|
||||
and caches it permanently. It does *not* cache the no-op tracer, so
|
||||
any span started before this fixture runs is merely lost rather than
|
||||
poisoning the tracer for the rest of the process. If the SDK isn't
|
||||
installed, do nothing: core spans stay no-op `NonRecordingSpan`s and
|
||||
the rest of the suite is unaffected.
|
||||
"""
|
||||
global _otel_span_exporter
|
||||
try:
|
||||
from opentelemetry import trace as otel_trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
||||
InMemorySpanExporter,
|
||||
)
|
||||
except ImportError:
|
||||
return
|
||||
exporter = InMemorySpanExporter()
|
||||
provider = TracerProvider()
|
||||
# SimpleSpanProcessor exports synchronously on span end - no background
|
||||
# batching thread, so assertions immediately after a request never race.
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
otel_trace.set_tracer_provider(provider)
|
||||
_otel_span_exporter = exporter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def otel_spans():
|
||||
"""
|
||||
Function-scoped access to the finished-spans exporter: clears any spans
|
||||
left over from previous tests, then yields the exporter so a test can
|
||||
call `.get_finished_spans()` after making requests. Skips (rather than
|
||||
fails) if the OTel SDK is not installed.
|
||||
"""
|
||||
pytest.importorskip("opentelemetry.sdk")
|
||||
if _otel_span_exporter is None:
|
||||
pytest.skip("OpenTelemetry SDK provider was not installed")
|
||||
_otel_span_exporter.clear()
|
||||
yield _otel_span_exporter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bare_ds():
|
||||
"""
|
||||
|
|
@ -168,6 +224,12 @@ 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.
|
||||
move_to_front(items, "test_datasette_package_never_imports_the_sdk")
|
||||
|
||||
|
||||
def move_to_front(items, test_name):
|
||||
|
|
|
|||
25
tests/test_telemetry.py
Normal file
25
tests/test_telemetry.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_datasette_package_never_imports_the_sdk():
|
||||
"""
|
||||
Core depends on opentelemetry-api only. The SDK is a test dependency.
|
||||
|
||||
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.
|
||||
"""
|
||||
code = (
|
||||
"import datasette.app, datasette.database, datasette.telemetry, sys; "
|
||||
"print([m for m in sys.modules if m.startswith('opentelemetry.sdk')])"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], capture_output=True, text=True, check=True
|
||||
)
|
||||
assert (
|
||||
result.stdout.strip() == "[]"
|
||||
), f"datasette imported the OpenTelemetry SDK: {result.stdout.strip()}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue