datasette/tests/test_telemetry.py
2026-09-24 13:52:29 -07:00

1023 lines
40 KiB
Python

import json
import sqlite3
import subprocess
import sys
import threading
import time
import pytest
import sqlite_utils
from opentelemetry import context as otel_context_api
from opentelemetry import trace as otel_trace
from opentelemetry.trace import SpanKind, StatusCode
from datasette.app import Datasette
from datasette.database import Database, QueryInterrupted
from datasette.telemetry import (
MAX_SQL_LENGTH,
SCHEMA_URL,
sql_attribute,
sql_operation_name,
tracer,
)
from datasette.version import __version__
SECRET_PARAM_VALUE = "SUPER_SECRET_PARAM_VALUE_XYZ_123"
INVALID_SQL = "select this_is_not_valid_sql from nowhere"
# Bounded so a broken time limit fails rather than hangs, but too slow to
# finish within the millisecond time limits used below.
SLOW_SQL = """
with recursive counter(x) as (
select 1 union all select x + 1 from counter where x < 50000000
)
select max(x) from counter
"""
def _db_query_spans(otel_spans):
return [span for span in otel_spans.get_finished_spans() if span.name == "db.query"]
def _spans_for_namespace(otel_spans, namespace):
"db.query spans for one database, excluding queries against the internal database."
return [
span
for span in _db_query_spans(otel_spans)
if span.attributes["db.namespace"] == namespace
]
def _children_named(otel_spans, name, parent_span_context):
"Finished spans called `name` that are direct children of `parent_span_context`."
return [
span
for span in otel_spans.get_finished_spans()
if span.name == name
and span.parent is not None
and span.parent.span_id == parent_span_context.span_id
and span.parent.trace_id == parent_span_context.trace_id
and span.context.trace_id == parent_span_context.trace_id
]
def _descends_from(span, ancestor_span_context, by_span_id):
"True if `span` reaches `ancestor_span_context` by walking parent links."
seen = set()
current = span
while current.parent is not None:
if current.parent.span_id == ancestor_span_context.span_id:
return current.parent.trace_id == ancestor_span_context.trace_id
if current.parent.span_id in seen:
return False
seen.add(current.parent.span_id)
current = by_span_id.get(current.parent.span_id)
if current is None:
return False
return False
def _all_attribute_values(otel_spans):
"Every attribute value on every finished span and span event."
values = []
for span in otel_spans.get_finished_spans():
values.extend((span.attributes or {}).values())
for event in span.events:
values.extend((event.attributes or {}).values())
return values
def test_datasette_package_never_imports_the_sdk():
"""
Importing datasette does not load the OpenTelemetry SDK.
conftest.py moves this test to the front of the run by name.
"""
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()}"
@pytest.mark.asyncio
async def test_db_query_span_basic_attributes(ds_client, otel_spans):
response = await ds_client.get("/fixtures/-/query.json?sql=select+1")
assert response.status_code == 200
spans = _db_query_spans(otel_spans)
assert spans, "expected at least one db.query span"
span = spans[-1]
assert span.attributes["db.system"] == "sqlite"
assert span.attributes["db.namespace"] == "fixtures"
assert span.attributes["db.query.text"] == "select 1"
assert span.attributes["datasette.rows_returned"] == 1
assert span.attributes["datasette.truncated"] is False
assert isinstance(span.attributes["datasette.time_limit_ms"], int)
assert span.status.status_code == StatusCode.UNSET
@pytest.mark.asyncio
async def test_truncated_result_sets_truncated_attribute(otel_spans):
"A result cut short by max_returned_rows records truncated=True."
ds = Datasette(memory=True, settings={"max_returned_rows": 5})
db = ds.add_memory_database("t04_truncated")
results = await db.execute(
"select value from json_each('[1,2,3,4,5,6,7,8,9,10]')", truncate=True
)
assert results.truncated
spans = _spans_for_namespace(otel_spans, "t04_truncated")
assert spans
span = spans[-1]
assert span.attributes["datasette.truncated"] is True
assert span.attributes["datasette.rows_returned"] == 5
@pytest.mark.asyncio
async def test_facetable_request_produces_db_query_spans(ds_client, otel_spans):
response = await ds_client.get("/fixtures/facetable.json")
assert response.status_code == 200
spans = _db_query_spans(otel_spans)
assert spans, "expected at least one db.query span"
assert all(span.attributes["db.system"] == "sqlite" for span in spans)
# Each span records the SQL or, for callback methods, the callback name:
assert all(
span.attributes.get("db.query.text")
or span.attributes.get("datasette.callback")
for span in spans
)
assert any(span.attributes.get("db.query.text") for span in spans)
# Rendering the page also queries the internal database, so only some of
# these spans belong to "fixtures".
assert any(span.attributes["db.namespace"] == "fixtures" for span in spans)
def test_sql_attribute_truncates_at_2048():
short_sql = "select 1"
assert sql_attribute(short_sql) == "select 1"
# Surrounding whitespace is stripped:
assert sql_attribute(" select 1\n") == "select 1"
long_sql = "select 1 -- " + ("x" * 3000)
truncated = sql_attribute(long_sql)
assert len(truncated) == MAX_SQL_LENGTH + len("…[truncated]")
assert truncated.startswith("select 1 -- ")
assert truncated.endswith("…[truncated]")
@pytest.mark.asyncio
async def test_db_query_text_is_truncated_in_real_span(ds_client, otel_spans):
# A long trailing comment keeps the SQL valid but over the 2048 character limit
long_sql = "select 1 -- " + ("x" * 3000)
response = await ds_client.get("/fixtures/-/query.json", params={"sql": long_sql})
assert response.status_code == 200
spans = _db_query_spans(otel_spans)
assert spans
assert any(len(span.attributes.get("db.query.text", "")) > 100 for span in spans), (
"expected the long query to reach a span - otherwise this test would "
"pass even if truncation were never applied"
)
for span in spans:
recorded = span.attributes.get("db.query.text", "")
assert len(recorded) <= MAX_SQL_LENGTH + len("…[truncated]")
@pytest.mark.asyncio
async def test_no_span_attribute_ever_contains_a_parameter_value(ds_client, otel_spans):
response = await ds_client.get(
"/fixtures/-/query.json",
params={"sql": "select :secret", "secret": SECRET_PARAM_VALUE},
)
assert response.status_code == 200
# Confirm the bound parameter value was used by the query:
assert SECRET_PARAM_VALUE in json.dumps(response.json())
for value in _all_attribute_values(otel_spans):
if isinstance(value, str):
assert SECRET_PARAM_VALUE not in value
elif isinstance(value, (list, tuple)):
for item in value:
if isinstance(item, str):
assert SECRET_PARAM_VALUE not in item
spans = _db_query_spans(otel_spans)
assert spans
span = spans[-1]
assert "select :secret" in span.attributes["db.query.text"]
assert span.attributes.get("datasette.param_count") == 1
@pytest.mark.asyncio
async def test_query_interrupted_sets_error_status(otel_spans):
"""
A query that exceeds the sql_time_limit_ms setting is a span error.
The limit comes from the setting because a shorter custom_time_limit
marks the timeout as expected.
"""
ds = Datasette(memory=True, settings={"sql_time_limit_ms": 20})
db = ds.add_memory_database("t09_instance_limit_timeout")
with pytest.raises(QueryInterrupted):
await db.execute(SLOW_SQL)
spans = _spans_for_namespace(otel_spans, "t09_instance_limit_timeout")
assert spans
span = spans[-1]
assert span.status.status_code == StatusCode.ERROR
assert span.attributes["datasette.interrupted"] is True
assert span.events
assert all(event.name == "exception" for event in span.events)
async def _expected_timeout_count_span(otel_spans, database_name):
"Make table_counts() time out and return its db.query span."
db = Datasette(memory=True).add_memory_database(database_name)
await db.execute_write("create table big (id integer primary key, t text)")
await db.execute_write_many(
"insert into big (t) values (?)", [["x" * 50] for _ in range(11000)]
)
# count_limit caps the scan at 10001 rows. Below 20ms sqlite_timelimit()
# checks the limit on every VM instruction, so this reliably exceeds 1ms.
counts = await db.table_counts(1)
assert counts == {
"big": None
}, "the count did not actually time out, so the rest of this test is vacuous"
spans = [
span
for span in _spans_for_namespace(otel_spans, database_name)
if "count(*)" in span.attributes.get("db.query.text", "")
]
assert len(spans) == 1
return spans[0]
@pytest.mark.asyncio
async def test_expected_timeout_is_not_a_span_error(otel_spans):
span = await _expected_timeout_count_span(otel_spans, "t09_expected_timeout")
# Recorded as interrupted, but not as an error:
assert span.attributes["datasette.interrupted"] is True
assert span.status.status_code != StatusCode.ERROR
assert not [event for event in span.events if event.name == "exception"]
@pytest.mark.asyncio
async def test_expected_timeout_does_not_error_the_inner_execute_span(otel_spans):
"The db.query.execute child span is not marked as an error either."
span = await _expected_timeout_count_span(otel_spans, "t09_expected_timeout_inner")
children = _children_named(otel_spans, "db.query.execute", span.context)
assert len(children) == 1
child = children[0]
assert child.status.status_code != StatusCode.ERROR
assert not [event for event in child.events if event.name == "exception"]
@pytest.mark.asyncio
async def test_unexpected_timeout_is_still_a_span_error(otel_spans):
"A timeout is an error if custom_time_limit is above sql_time_limit_ms."
ds = Datasette(memory=True, settings={"sql_time_limit_ms": 20})
db = ds.add_memory_database("t09_custom_limit_ignored")
with pytest.raises(QueryInterrupted):
await db.execute(SLOW_SQL, custom_time_limit=5000)
spans = _spans_for_namespace(otel_spans, "t09_custom_limit_ignored")
assert spans
span = spans[-1]
# The setting overrides the larger custom_time_limit:
assert span.attributes["datasette.time_limit_ms"] == 20
assert span.attributes["datasette.interrupted"] is True
assert span.status.status_code == StatusCode.ERROR
assert any(event.name == "exception" for event in span.events)
children = _children_named(otel_spans, "db.query.execute", span.context)
assert len(children) == 1
assert children[0].status.status_code == StatusCode.ERROR
@pytest.mark.asyncio
async def test_unsuppressed_sql_error_is_a_span_error(ds_client, otel_spans):
db = ds_client.ds.get_database("fixtures")
with pytest.raises(sqlite3.OperationalError):
await db.execute(INVALID_SQL)
spans = _db_query_spans(otel_spans)
assert spans
span = spans[-1]
assert span.status.status_code == StatusCode.ERROR
assert any(event.name == "exception" for event in span.events)
assert "datasette.sql_error_suppressed" not in span.attributes
@pytest.mark.asyncio
async def test_suppressed_sql_error_is_not_a_span_error(ds_client, otel_spans):
"With log_sql_errors=False the error is recorded as suppressed, not a span error."
db = ds_client.ds.get_database("fixtures")
with pytest.raises(sqlite3.OperationalError):
await db.execute(INVALID_SQL, log_sql_errors=False)
spans = _db_query_spans(otel_spans)
assert spans
span = spans[-1]
assert span.status.status_code == StatusCode.UNSET
assert span.attributes["datasette.sql_error_suppressed"] is True
assert not [event for event in span.events if event.name == "exception"]
@pytest.mark.asyncio
async def test_execute_write_produces_db_query_span(otel_spans):
# Named in-memory databases are shared, so each test uses a unique name.
db = Datasette(memory=True).add_memory_database("t03_write_span")
await db.execute_write("create table docs (id integer primary key, name text)")
await db.execute_write("insert into docs (id, name) values (?, ?)", [1, "one"])
spans = _spans_for_namespace(otel_spans, "t03_write_span")
assert spans, "expected db.query spans from execute_write()"
span = spans[-1]
assert span.attributes["db.system"] == "sqlite"
assert span.attributes["db.namespace"] == "t03_write_span"
assert span.attributes["db.query.text"] == (
"insert into docs (id, name) values (?, ?)"
)
assert span.attributes["datasette.param_count"] == 2
@pytest.mark.asyncio
async def test_execute_write_script_sets_executescript_attribute(otel_spans):
db = Datasette(memory=True).add_memory_database("t03_write_script_span")
await db.execute_write_script(
"create table docs (id integer primary key);\n"
"insert into docs (id) values (1);"
)
spans = _spans_for_namespace(otel_spans, "t03_write_script_span")
assert spans, "expected a db.query span from execute_write_script()"
span = spans[-1]
assert span.attributes["db.system"] == "sqlite"
assert span.attributes["datasette.executescript"] is True
assert "insert into docs" in span.attributes["db.query.text"]
@pytest.mark.asyncio
async def test_execute_write_many_records_param_sets_not_rows_returned(otel_spans):
db = Datasette(memory=True).add_memory_database("t03_write_many_span")
await db.execute_write("create table docs (id integer primary key)")
await db.execute_write_many(
"insert into docs (id) values (?)", [[i] for i in range(1, 6)]
)
spans = _spans_for_namespace(otel_spans, "t03_write_many_span")
many_spans = [
span for span in spans if span.attributes.get("datasette.executemany") is True
]
assert len(many_spans) == 1
span = many_spans[0]
assert span.attributes["datasette.param_sets"] == 5
assert "datasette.rows_returned" not in span.attributes
# --- Context propagation across thread boundaries --------------------------
#
# These tests check span parentage, not just that the spans exist.
@pytest.mark.asyncio
async def test_db_query_execute_parents_to_db_query(ds_client, otel_spans):
# execute_fn() submits to the executor, so db.query.execute is created on
# another thread.
response = await ds_client.get("/fixtures/-/query.json?sql=select+1")
assert response.status_code == 200
query_spans = [
span
for span in _spans_for_namespace(otel_spans, "fixtures")
if span.attributes.get("db.query.text") == "select 1"
]
assert query_spans, "expected a db.query span for 'select 1'"
query_span = query_spans[-1]
assert [
span
for span in otel_spans.get_finished_spans()
if span.name == "db.query.execute"
], "expected at least one db.query.execute span"
children = _children_named(otel_spans, "db.query.execute", query_span.context)
assert len(children) == 1, "expected exactly one db.query.execute child of db.query"
# db.query.execute runs within db.query; the gap is the thread pool wait.
assert query_span.start_time <= children[0].start_time
assert children[0].end_time <= query_span.end_time
@pytest.mark.asyncio
async def test_immutable_database_propagates_context(tmp_path, otel_spans):
# Immutable databases run execute_isolated_fn() on another thread using
# loop.run_in_executor(), not the write thread.
db_path = tmp_path / "t04_immutable.db"
sqlite_utils.Database(str(db_path))["t"].insert({"id": 1}, pk="id")
ds = Datasette()
db = Database(ds, path=str(db_path), is_mutable=False)
ds.add_database(db, name="t04_immutable")
def fn(conn):
with tracer.start_as_current_span("t04-child-in-isolated-worker"):
pass
try:
with tracer.start_as_current_span("t04-parent-on-event-loop") as parent:
parent_context = parent.get_span_context()
await db.execute_isolated_fn(fn)
finally:
ds.remove_database("t04_immutable")
assert [
span
for span in otel_spans.get_finished_spans()
if span.name == "t04-child-in-isolated-worker"
], "expected a span created inside execute_isolated_fn's worker thread"
# Expected chain: event loop parent -> db.query -> worker thread child
query_spans = _children_named(otel_spans, "db.query", parent_context)
assert len(query_spans) == 1
children = _children_named(
otel_spans, "t04-child-in-isolated-worker", query_spans[0].context
)
assert len(children) == 1
@pytest.mark.asyncio
async def test_write_spans_parent_to_db_query(otel_spans):
# execute_write() queues a WriteTask for the write thread.
# db.write.queue_wait and db.write.execute are both children of db.query.
db = Datasette(memory=True).add_memory_database("t04_write_spans")
await db.execute_write("create table docs (id integer primary key)")
query_spans = _spans_for_namespace(otel_spans, "t04_write_spans")
assert query_spans, "expected a db.query span from execute_write()"
query_span = query_spans[-1]
queue_wait_children = _children_named(
otel_spans, "db.write.queue_wait", query_span.context
)
execute_children = _children_named(
otel_spans, "db.write.execute", query_span.context
)
assert len(queue_wait_children) == 1
assert len(execute_children) == 1
execute_span = execute_children[0]
assert execute_span.attributes["datasette.isolated_connection"] is False
assert execute_span.attributes["datasette.transaction"] is True
# The queue wait ends before the write begins.
assert queue_wait_children[0].end_time <= execute_span.start_time
@pytest.mark.asyncio
async def test_write_queue_wait_duration_reflects_real_wait(otel_spans):
# db.write.queue_wait runs from task.enqueued_at_ns, captured on the event
# loop, to when the write thread dequeues the task.
ds = Datasette(memory=True)
db = ds.add_memory_database("t04_queue_wait")
await db.execute_write("create table docs (id integer primary key)")
def slow_write(conn):
time.sleep(0.1)
# Queue a slow write without waiting for it, then a second write behind it:
_, slow_future = await db._send_to_write_thread(slow_write, block=False)
await db.execute_write("insert into docs (id) values (1)")
await slow_future
query_spans = [
span
for span in _spans_for_namespace(otel_spans, "t04_queue_wait")
if span.attributes.get("db.query.text") == "insert into docs (id) values (1)"
]
assert query_spans, "expected a db.query span for the queued-behind insert"
queue_wait_children = _children_named(
otel_spans, "db.write.queue_wait", query_spans[-1].context
)
assert len(queue_wait_children) == 1
duration_ns = queue_wait_children[0].end_time - queue_wait_children[0].start_time
# The slow write sleeps for 100ms
assert duration_ns > 10_000_000, f"queue wait was only {duration_ns}ns"
async def _write_spans_from_one_enqueue(otel_spans, name, block):
"""
Run one write through the write thread inside a span, returning
(enqueueing span context, {span name: span}).
Uses _send_to_write_thread() because execute_write() would add its own
db.query span between the enqueueing span and the write spans.
"""
db = Datasette(memory=True).add_memory_database(name)
await db.execute_write("create table docs (id integer primary key)")
def insert(conn):
conn.execute("insert into docs (id) values (1)")
otel_spans.clear()
with tracer.start_as_current_span("enqueueing-span") as enqueuer:
enqueuer_context = enqueuer.get_span_context()
queued = await db._send_to_write_thread(insert, block=block)
if not block:
# Wait for the write after the enqueueing span has ended. The reply
# future resolves once both write spans have been exported.
_, reply_future = queued
await reply_future
spans = {}
for span in otel_spans.get_finished_spans():
if span.name in ("db.write.queue_wait", "db.write.execute"):
assert span.name not in spans, f"more than one {span.name} span"
spans[span.name] = span
assert set(spans) == {"db.write.queue_wait", "db.write.execute"}
return enqueuer_context, spans
@pytest.mark.asyncio
async def test_blocking_write_spans_still_parent_normally(otel_spans):
# block=True waits for the write, so its spans are children of the
# enqueueing span, with no links.
enqueuer_context, spans = await _write_spans_from_one_enqueue(
otel_spans, "t07_blocking_write", block=True
)
for name, span in spans.items():
assert span.parent is not None, f"{name} lost its parent"
assert span.parent.span_id == enqueuer_context.span_id, name
assert span.parent.trace_id == enqueuer_context.trace_id, name
assert span.context.trace_id == enqueuer_context.trace_id, name
assert span.links == (), f"{name} should be parented, not linked"
@pytest.mark.asyncio
async def test_nonblocking_write_spans_are_roots_with_a_link(otel_spans):
# block=False returns before the write runs, so the write spans are roots
# linked to the enqueueing span.
enqueuer_context, spans = await _write_spans_from_one_enqueue(
otel_spans, "t07_nonblocking_write", block=False
)
assert enqueuer_context.is_valid, "test's own enqueueing span was not recorded"
for name, span in spans.items():
assert span.parent is None, f"{name} is still parented"
# Each write span starts its own trace
assert span.context.trace_id != enqueuer_context.trace_id, name
assert len(span.links) == 1, f"{name} has links {span.links}"
link_context = span.links[0].context
assert link_context.trace_id == enqueuer_context.trace_id, name
assert link_context.span_id == enqueuer_context.span_id, name
# The two write spans are separate roots
assert (
spans["db.write.queue_wait"].context.trace_id
!= spans["db.write.execute"].context.trace_id
)
@pytest.mark.asyncio
async def test_nonblocking_write_link_has_no_attributes(otel_spans):
_, spans = await _write_spans_from_one_enqueue(
otel_spans, "t07_nonblocking_link_attrs", block=False
)
for name, span in spans.items():
assert len(span.links) == 1, name
assert dict(span.links[0].attributes or {}) == {}, name
@pytest.mark.asyncio
async def test_nonblocking_write_spans_ignore_the_write_threads_ambient_context(
otel_spans,
):
"""
block=False spans ignore any context left attached on the write thread.
A prepare_connection hook could attach a context and never detach it.
This test does that, then checks the write spans are still roots.
"""
ds = Datasette(memory=True)
db = ds.add_memory_database("t07_ambient_write_thread")
write_thread_name = "_execute_writes for database t07_ambient_write_thread"
real_prepare_connection = ds._prepare_connection
leaked = {}
def prepare_connection(conn, database):
if threading.current_thread().name == write_thread_name:
# Runs on the write thread before any task is dequeued, and never
# detaches.
span = tracer.start_span("leaked-write-thread-ambient-span")
leaked["span_id"] = span.get_span_context().span_id
otel_context_api.attach(otel_trace.set_span_in_context(span))
return real_prepare_connection(conn, database)
ds._prepare_connection = prepare_connection
try:
await db.execute_write("create table docs (id integer primary key)")
def insert(conn):
conn.execute("insert into docs (id) values (1)")
otel_spans.clear()
with tracer.start_as_current_span("enqueueing-span") as enqueuer:
enqueuer_context = enqueuer.get_span_context()
_, reply_future = await db._send_to_write_thread(insert, block=False)
await reply_future
finally:
ds._prepare_connection = real_prepare_connection
db.close()
assert "span_id" in leaked, "the ambient context was never leaked - test is vacuous"
write_spans = [
span
for span in otel_spans.get_finished_spans()
if span.name in ("db.write.queue_wait", "db.write.execute")
]
assert len(write_spans) == 2
for span in write_spans:
assert span.parent is None, (
f"{span.name} parented to the write thread's leftover ambient "
"context instead of being a root"
)
assert span.links[0].context.span_id == enqueuer_context.span_id
@pytest.mark.asyncio
async def test_suppressed_error_does_not_mark_execute_span(ds_client, otel_spans):
"The inner db.query.execute span also respects log_sql_errors=False."
db = ds_client.ds.get_database("fixtures")
with pytest.raises(sqlite3.OperationalError):
await db.execute(INVALID_SQL, log_sql_errors=False)
execute_spans = [
span
for span in otel_spans.get_finished_spans()
if span.name == "db.query.execute"
]
assert execute_spans
span = execute_spans[-1]
assert span.status.status_code == StatusCode.UNSET
assert not [event for event in span.events if event.name == "exception"]
@pytest.mark.asyncio
async def test_invoke_startup_produces_one_trace_not_dozens_of_orphans(otel_spans):
"Spans emitted by invoke_startup() share a single datasette.startup root span."
ds = Datasette(memory=True)
ds.add_memory_database("t05_startup_db")
# Ignore spans from constructing Datasette, which happens before startup
otel_spans.clear()
# No ambient span, as in the ASGI lifespan path where startup runs before
# any request.
assert (
not otel_trace.get_current_span().get_span_context().is_valid
), "this test must run with no ambient span"
await ds.invoke_startup()
spans = otel_spans.get_finished_spans()
assert len(spans) > 10, f"expected startup to emit many spans, got {len(spans)}"
startup_spans = [span for span in spans if span.name == "datasette.startup"]
assert len(startup_spans) == 1
startup = startup_spans[0]
assert startup.parent is None, "datasette.startup should be a root span"
trace_ids = {span.context.trace_id for span in spans}
assert trace_ids == {startup.context.trace_id}, (
f"startup produced {len(trace_ids)} distinct traces; every span it "
"causes should share the datasette.startup trace"
)
roots = [span for span in spans if span.parent is None]
assert [span.name for span in roots] == ["datasette.startup"]
by_span_id = {span.context.span_id: span for span in spans}
# Internal database reads:
internal_queries = [
span
for span in spans
if span.name == "db.query" and span.attributes["db.namespace"] == "__INTERNAL__"
]
assert internal_queries, "expected internal-catalog db.query spans during startup"
assert all(
_descends_from(span, startup.context, by_span_id) for span in internal_queries
)
# Internal database writes, which run on the write thread:
write_spans = [span for span in spans if span.name.startswith("db.write.")]
assert write_spans, "expected db.write.* spans during startup"
assert all(
_descends_from(span, startup.context, by_span_id) for span in write_spans
)
# --- Semantic conventions: span kind, scope, db.operation.name -------------
@pytest.mark.asyncio
async def test_db_query_is_client_kind_and_children_are_internal(otel_spans):
"""
db.query spans are CLIENT. Their child spans are INTERNAL because they are
parts of one query rather than separate database calls.
"""
db = Datasette(memory=True).add_memory_database("t06_span_kind")
# Call each of the four SQL string methods:
await db.execute_write("create table docs (id integer primary key)")
await db.execute_write_many(
"insert into docs (id) values (?)", [[i] for i in range(1, 4)]
)
await db.execute_write_script("insert into docs (id) values (99);")
await db.execute("select id from docs")
query_spans = _spans_for_namespace(otel_spans, "t06_span_kind")
assert len(query_spans) == 4, "expected a db.query span per entry point"
for span in query_spans:
text = span.attributes["db.query.text"]
assert span.kind == SpanKind.CLIENT, f"db.query for {text!r} should be CLIENT"
for name in ("db.query.execute", "db.write.execute", "db.write.queue_wait"):
children = [
span for span in otel_spans.get_finished_spans() if span.name == name
]
assert children, f"expected at least one {name} span"
for span in children:
assert span.kind == SpanKind.INTERNAL, f"{name} should be INTERNAL"
@pytest.mark.asyncio
async def test_instrumentation_scope_declares_version_and_schema_url(
ds_client, otel_spans
):
"The instrumentation scope includes the Datasette version and schema URL."
response = await ds_client.get("/fixtures/-/query.json?sql=select+1")
assert response.status_code == 200
spans = _db_query_spans(otel_spans)
assert spans, "expected at least one db.query span"
scope = spans[-1].instrumentation_scope
assert scope.name == "datasette"
assert scope.version == __version__
# Uses the literal URL so changing SCHEMA_URL requires updating this test
assert scope.schema_url == "https://opentelemetry.io/schemas/1.29.0"
assert SCHEMA_URL == "https://opentelemetry.io/schemas/1.29.0"
assert __version__, "the scope version must not be empty"
def test_db_operation_name_from_leading_keyword():
assert sql_operation_name("select 1") == "SELECT"
assert sql_operation_name(" insert into x (a) values (1)") == "INSERT"
# A leading CTE reports WITH, not the operation inside it
assert sql_operation_name("with foo as (select 1) select * from foo") == "WITH"
# Unrecognized leading keyword
assert sql_operation_name("gibberish 1") is None
# A parenthesized SELECT or a leading comment also returns None
assert sql_operation_name("(select 1) union select 2") is None
assert sql_operation_name("-- a comment\nselect 1") is None
assert sql_operation_name("") is None
@pytest.mark.asyncio
async def test_db_operation_name_on_real_span(ds_client, otel_spans):
response = await ds_client.get("/fixtures/-/query.json?sql=select+1")
assert response.status_code == 200
spans = [
span
for span in _spans_for_namespace(otel_spans, "fixtures")
if span.attributes.get("db.query.text") == "select 1"
]
assert spans, "expected a db.query span for 'select 1'"
assert spans[-1].attributes["db.operation.name"] == "SELECT"
@pytest.mark.asyncio
async def test_execute_write_sets_db_operation_name(otel_spans):
db = Datasette(memory=True).add_memory_database("t06_write_operation")
await db.execute_write("create table docs (id integer primary key)")
await db.execute_write_many(
"insert into docs (id) values (?)", [[i] for i in range(1, 4)]
)
spans = _spans_for_namespace(otel_spans, "t06_write_operation")
by_operation = {
span.attributes["db.query.text"]: span.attributes.get("db.operation.name")
for span in spans
}
assert by_operation["create table docs (id integer primary key)"] == "CREATE"
assert by_operation["insert into docs (id) values (?)"] == "INSERT"
@pytest.mark.asyncio
async def test_execute_write_script_has_no_operation_name(otel_spans):
"""
Scripts can contain several statements, so db.operation.name is omitted.
The script starts with `create`, which is on the allowlist, so this fails
if the operation name is extracted anyway.
"""
db = Datasette(memory=True).add_memory_database("t06_script_operation")
await db.execute_write_script(
"create table docs (id integer primary key);\n"
"insert into docs (id) values (1);"
)
spans = _spans_for_namespace(otel_spans, "t06_script_operation")
script_spans = [
span for span in spans if span.attributes.get("datasette.executescript") is True
]
assert len(script_spans) == 1
assert "db.operation.name" not in script_spans[0].attributes
# --- Callback-style calls: execute_fn / execute_write_fn / execute_isolated_fn
@pytest.mark.asyncio
async def test_execute_fn_produces_db_query_span(otel_spans):
db = Datasette(memory=True).add_memory_database("t16_execute_fn")
await db.execute_write("create table t (id integer primary key)")
def count_rows(conn):
return conn.execute("select count(*) from t").fetchone()[0]
otel_spans.clear()
assert await db.execute_fn(count_rows) == 0
spans = _spans_for_namespace(otel_spans, "t16_execute_fn")
assert len(spans) == 1
span = spans[0]
assert span.kind == SpanKind.CLIENT
assert span.attributes["db.system"] == "sqlite"
assert (
span.attributes["datasette.callback"]
== "test_execute_fn_produces_db_query_span.<locals>.count_rows"
)
# Callbacks have no SQL text to record or take an operation name from
assert "db.query.text" not in span.attributes
assert "db.operation.name" not in span.attributes
children = _children_named(otel_spans, "db.query.execute", span.context)
assert len(children) == 1
@pytest.mark.asyncio
async def test_execute_fn_lambda_reports_lambda(otel_spans):
db = Datasette(memory=True).add_memory_database("t16_lambda")
otel_spans.clear()
await db.execute_fn(lambda conn: conn.execute("select 1").fetchone())
spans = _spans_for_namespace(otel_spans, "t16_lambda")
assert len(spans) == 1
assert spans[0].attributes["datasette.callback"].endswith("<lambda>")
@pytest.mark.asyncio
async def test_execute_write_fn_produces_db_query_span(otel_spans):
db = Datasette(memory=True).add_memory_database("t16_write_fn")
def create_table(conn):
conn.execute("create table t (id integer primary key)")
otel_spans.clear()
await db.execute_write_fn(create_table)
spans = _spans_for_namespace(otel_spans, "t16_write_fn")
assert len(spans) == 1
span = spans[0]
assert span.kind == SpanKind.CLIENT
assert (
span.attributes["datasette.callback"]
== "test_execute_write_fn_produces_db_query_span.<locals>.create_table"
)
assert "db.query.text" not in span.attributes
# The write-thread spans are this span's children, same as execute_write()
for name in ("db.write.queue_wait", "db.write.execute"):
assert len(_children_named(otel_spans, name, span.context)) == 1, name
@pytest.mark.asyncio
async def test_execute_write_fn_callback_name_is_not_the_hook_wrapper(otel_spans):
# _wrap_fn_with_hooks() wraps callbacks that accept track_event
db = Datasette(memory=True).add_memory_database("t16_wrapper_name")
def create_with_events(conn, track_event):
conn.execute("create table t (id integer primary key)")
otel_spans.clear()
await db.execute_write_fn(create_with_events)
spans = _spans_for_namespace(otel_spans, "t16_wrapper_name")
assert len(spans) == 1
assert spans[0].attributes["datasette.callback"] == (
"test_execute_write_fn_callback_name_is_not_the_hook_wrapper"
".<locals>.create_with_events"
)
@pytest.mark.asyncio
async def test_execute_write_fn_nonblocking_spans_link_to_the_new_span(otel_spans):
# With block=False the write thread spans link to the db.query span from
# execute_write_fn(), not to the span that was current when it was called.
db = Datasette(memory=True).add_memory_database("t16_nonblocking")
await db.execute_write("create table docs (id integer primary key)")
def insert(conn):
conn.execute("insert into docs (id) values (1)")
otel_spans.clear()
with tracer.start_as_current_span("t16-enqueueing-span") as enqueuer:
enqueuer_context = enqueuer.get_span_context()
await db.execute_write_fn(insert, block=False)
# Writes run in order, so this waits for the non-blocking write to finish
await db.execute_write("insert into docs (id) values (2)")
query_spans = [
span
for span in _spans_for_namespace(otel_spans, "t16_nonblocking")
if span.attributes.get("datasette.callback")
]
assert len(query_spans) == 1
fn_span_context = query_spans[0].context
linked = [
span
for span in otel_spans.get_finished_spans()
if span.name in ("db.write.queue_wait", "db.write.execute") and span.links
]
assert len(linked) == 2
for span in linked:
assert span.parent is None, f"{span.name} is still parented"
assert span.links[0].context.span_id == fn_span_context.span_id, span.name
assert span.links[0].context.span_id != enqueuer_context.span_id, span.name
@pytest.mark.asyncio
async def test_execute_does_not_double_wrap(otel_spans):
# execute() and the SQL string write methods call the private
# _execute_fn() and _execute_write_fn(), so they create one db.query span.
db = Datasette(memory=True).add_memory_database("t16_no_double_wrap")
otel_spans.clear()
await db.execute_write("create table t (id integer primary key)")
assert len(_spans_for_namespace(otel_spans, "t16_no_double_wrap")) == 1
otel_spans.clear()
await db.execute("select * from t")
spans = _spans_for_namespace(otel_spans, "t16_no_double_wrap")
assert len(spans) == 1
assert len(_children_named(otel_spans, "db.query.execute", spans[0].context)) == 1
@pytest.mark.asyncio
async def test_execute_isolated_fn_span_on_mutable_and_immutable(tmp_path, otel_spans):
def read_one(conn):
return conn.execute("select 1").fetchone()[0]
mutable = Datasette(memory=True).add_memory_database("t16_isolated_mutable")
otel_spans.clear()
assert await mutable.execute_isolated_fn(read_one) == 1
spans = _spans_for_namespace(otel_spans, "t16_isolated_mutable")
assert len(spans) == 1
assert spans[0].attributes["datasette.callback"].endswith("read_one")
# Mutable databases route through the write thread, so the write spans
# appear as children; immutable ones run on the pool and get none.
assert _children_named(otel_spans, "db.write.execute", spans[0].context)
db_path = tmp_path / "t16_isolated_immutable.db"
sqlite_utils.Database(str(db_path))["t"].insert({"id": 1})
ds = Datasette()
immutable = Database(ds, path=str(db_path), is_mutable=False)
ds.add_database(immutable, name="t16_isolated_immutable")
try:
otel_spans.clear()
assert await immutable.execute_isolated_fn(read_one) == 1
finally:
ds.remove_database("t16_isolated_immutable")
spans = _spans_for_namespace(otel_spans, "t16_isolated_immutable")
assert len(spans) == 1
assert spans[0].attributes["datasette.callback"].endswith("read_one")
assert not _children_named(otel_spans, "db.write.execute", spans[0].context)
@pytest.mark.asyncio
async def test_execute_fn_exception_marks_span_error(otel_spans):
# execute_fn() has no log_sql_errors option, so exceptions are span errors
db = Datasette(memory=True).add_memory_database("t16_fn_error")
def boom(conn):
raise ValueError("callback failed")
otel_spans.clear()
with pytest.raises(ValueError):
await db.execute_fn(boom)
spans = _spans_for_namespace(otel_spans, "t16_fn_error")
assert len(spans) == 1
assert spans[0].status.status_code == StatusCode.ERROR
assert any(event.name == "exception" for event in spans[0].events)