diff --git a/datasette/telemetry.py b/datasette/telemetry.py new file mode 100644 index 00000000..6f5ed093 --- /dev/null +++ b/datasette/telemetry.py @@ -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]" diff --git a/pyproject.toml b/pyproject.toml index e658955f..9dd231d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/conftest.py b/tests/conftest.py index 12dce417..b01f111d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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): diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 00000000..b71be504 --- /dev/null +++ b/tests/test_telemetry.py @@ -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()}"