mirror of
https://github.com/simonw/datasette.git
synced 2026-09-27 04:14:25 +02:00
parent
5f1b8f3453
commit
daf1989ebe
13 changed files with 2782 additions and 147 deletions
110
datasette/app.py
110
datasette/app.py
|
|
@ -50,6 +50,8 @@ from .events import Event
|
|||
from .plugins import DEFAULT_PLUGINS, get_plugins, pm
|
||||
from .renderer import json_renderer
|
||||
from .resources import DatabaseResource, TableResource
|
||||
from .telemetry import tracer
|
||||
from .telemetry_registry import STARTUP
|
||||
from .tokens import TokenInvalid
|
||||
from .tracer import AsgiTracer
|
||||
from .url_builder import Urls
|
||||
|
|
@ -783,57 +785,69 @@ class Datasette:
|
|||
# This must be called for Datasette to be in a usable state
|
||||
if self._startup_invoked:
|
||||
return
|
||||
# Register event classes
|
||||
event_classes = []
|
||||
for hook in pm.hook.register_events(datasette=self):
|
||||
extra_classes = await await_me_maybe(hook)
|
||||
if extra_classes:
|
||||
event_classes.extend(extra_classes)
|
||||
self.event_classes = tuple(event_classes)
|
||||
# invoke_startup() runs before any request exists, so every span its
|
||||
# children create - the register_* hook dispatches, the internal
|
||||
# catalog's db.query/db.write spans, and the prepare_connection
|
||||
# warm-up of the read connections those touch - would otherwise be
|
||||
# its own orphan root trace: around twenty of them on a fresh
|
||||
# instance. Bracketing the whole thing gives them somewhere to belong.
|
||||
# A connection warmed lazily later, by a request touching a new
|
||||
# database for the first time, nests under that request instead:
|
||||
# this span has already ended by then.
|
||||
with tracer.start_as_current_span(STARTUP):
|
||||
# Register event classes
|
||||
event_classes = []
|
||||
for hook in pm.hook.register_events(datasette=self):
|
||||
extra_classes = await await_me_maybe(hook)
|
||||
if extra_classes:
|
||||
event_classes.extend(extra_classes)
|
||||
self.event_classes = tuple(event_classes)
|
||||
|
||||
# Register actions, but watch out for duplicate name/abbr
|
||||
action_names = {}
|
||||
action_abbrs = {}
|
||||
for hook in pm.hook.register_actions(datasette=self):
|
||||
if hook:
|
||||
for action in hook:
|
||||
if (
|
||||
action.name in action_names
|
||||
and action != action_names[action.name]
|
||||
):
|
||||
raise StartupError(f"Duplicate action name: {action.name}")
|
||||
if (
|
||||
action.abbr
|
||||
and action.abbr in action_abbrs
|
||||
and action != action_abbrs[action.abbr]
|
||||
):
|
||||
raise StartupError(f"Duplicate action abbr: {action.abbr}")
|
||||
action_names[action.name] = action
|
||||
if action.abbr:
|
||||
action_abbrs[action.abbr] = action
|
||||
self.actions[action.name] = action
|
||||
# Register actions, but watch out for duplicate name/abbr
|
||||
action_names = {}
|
||||
action_abbrs = {}
|
||||
for hook in pm.hook.register_actions(datasette=self):
|
||||
if hook:
|
||||
for action in hook:
|
||||
if (
|
||||
action.name in action_names
|
||||
and action != action_names[action.name]
|
||||
):
|
||||
raise StartupError(f"Duplicate action name: {action.name}")
|
||||
if (
|
||||
action.abbr
|
||||
and action.abbr in action_abbrs
|
||||
and action != action_abbrs[action.abbr]
|
||||
):
|
||||
raise StartupError(f"Duplicate action abbr: {action.abbr}")
|
||||
action_names[action.name] = action
|
||||
if action.abbr:
|
||||
action_abbrs[action.abbr] = action
|
||||
self.actions[action.name] = action
|
||||
|
||||
# Register column types (classes, not instances)
|
||||
self._column_types = {}
|
||||
for hook in pm.hook.register_column_types(datasette=self):
|
||||
if hook:
|
||||
for ct_cls in hook:
|
||||
if ct_cls.name in self._column_types:
|
||||
raise StartupError(f"Duplicate column type name: {ct_cls.name}")
|
||||
self._column_types[ct_cls.name] = ct_cls
|
||||
# Register column types (classes, not instances)
|
||||
self._column_types = {}
|
||||
for hook in pm.hook.register_column_types(datasette=self):
|
||||
if hook:
|
||||
for ct_cls in hook:
|
||||
if ct_cls.name in self._column_types:
|
||||
raise StartupError(
|
||||
f"Duplicate column type name: {ct_cls.name}"
|
||||
)
|
||||
self._column_types[ct_cls.name] = ct_cls
|
||||
|
||||
for hook in pm.hook.prepare_jinja2_environment(
|
||||
env=self._jinja_env, datasette=self
|
||||
):
|
||||
await await_me_maybe(hook)
|
||||
# Ensure internal tables and metadata are populated before startup hooks
|
||||
await self._refresh_schemas()
|
||||
await self._save_queries_from_config()
|
||||
# Load column_types from config into internal DB
|
||||
await self._apply_column_types_config()
|
||||
for hook in pm.hook.startup(datasette=self):
|
||||
await await_me_maybe(hook)
|
||||
self._startup_invoked = True
|
||||
for hook in pm.hook.prepare_jinja2_environment(
|
||||
env=self._jinja_env, datasette=self
|
||||
):
|
||||
await await_me_maybe(hook)
|
||||
# Ensure internal tables and metadata are populated before startup hooks
|
||||
await self._refresh_schemas()
|
||||
await self._save_queries_from_config()
|
||||
# Load column_types from config into internal DB
|
||||
await self._apply_column_types_config()
|
||||
for hook in pm.hook.startup(datasette=self):
|
||||
await await_me_maybe(hook)
|
||||
self._startup_invoked = True
|
||||
|
||||
def sign(self, value, namespace="default"):
|
||||
return URLSafeSerializer(self._secret, namespace).dumps(value)
|
||||
|
|
|
|||
|
|
@ -157,7 +157,11 @@ async def inspect_(files, sqlite_extensions):
|
|||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
data = {}
|
||||
for name, database in app.databases.items():
|
||||
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
|
||||
|
||||
def _inspect_tables(conn):
|
||||
return inspect_tables(conn, {})
|
||||
|
||||
tables = await database.execute_fn(_inspect_tables)
|
||||
data[name] = {
|
||||
"hash": database.hash,
|
||||
"size": database.size,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,45 @@
|
|||
import asyncio
|
||||
import atexit
|
||||
import contextvars
|
||||
import inspect
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
|
||||
import sqlite_utils
|
||||
from opentelemetry import context as otel_context_api
|
||||
from opentelemetry.trace import Link, Status, StatusCode, get_current_span
|
||||
|
||||
from .inspect import inspect_hash
|
||||
from .telemetry import callback_name, sql_attribute, sql_operation_name, tracer
|
||||
from .telemetry_registry import (
|
||||
CALLBACK,
|
||||
DB_NAMESPACE,
|
||||
DB_OPERATION_NAME,
|
||||
DB_QUERY,
|
||||
DB_QUERY_EXECUTE,
|
||||
DB_QUERY_TEXT,
|
||||
DB_SYSTEM,
|
||||
DB_WRITE_EXECUTE,
|
||||
DB_WRITE_QUEUE_WAIT,
|
||||
EXECUTEMANY,
|
||||
EXECUTESCRIPT,
|
||||
INTERRUPTED,
|
||||
ISOLATED_CONNECTION,
|
||||
PARAM_COUNT,
|
||||
PARAM_SETS,
|
||||
ROWS_RETURNED,
|
||||
SQL_ERROR_SUPPRESSED,
|
||||
TIME_LIMIT_MS,
|
||||
TRANSACTION,
|
||||
TRUNCATED,
|
||||
)
|
||||
from .tracer import trace
|
||||
from .utils import (
|
||||
call_with_supported_arguments,
|
||||
|
|
@ -274,10 +301,24 @@ class Database:
|
|||
raise QueryInterrupted(e, sql, params)
|
||||
raise
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(
|
||||
_inner, block=block, request=request, transaction=transaction
|
||||
)
|
||||
# SIM117 wants these two context managers merged. They are kept nested
|
||||
# deliberately: the hand-rolled tracer's wrapper is on its way out, and
|
||||
# nesting makes removing it a single-line deletion.
|
||||
with trace( # noqa: SIM117
|
||||
"sql", database=self.name, sql=sql.strip(), params=params
|
||||
):
|
||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
||||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
||||
operation_name = sql_operation_name(sql)
|
||||
if operation_name:
|
||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
||||
if params:
|
||||
span.set_attribute(PARAM_COUNT, len(params))
|
||||
results = await self._execute_write_fn(
|
||||
_inner, block=block, request=request, transaction=transaction
|
||||
)
|
||||
return results
|
||||
|
||||
async def execute_write_script(self, sql, block=True, request=None):
|
||||
|
|
@ -286,10 +327,22 @@ class Database:
|
|||
def _inner(conn):
|
||||
return conn.executescript(sql)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
|
||||
results = await self.execute_write_fn(
|
||||
_inner, block=block, transaction=False, request=request
|
||||
)
|
||||
# Nested on purpose - see the note in execute_write().
|
||||
with trace( # noqa: SIM117
|
||||
"sql", database=self.name, sql=sql.strip(), executescript=True
|
||||
):
|
||||
# No db.operation.name here, deliberately: executescript() runs
|
||||
# several semicolon-separated statements, and semantic conventions
|
||||
# say the attribute should not be extracted from query text that
|
||||
# can hold more than one operation - see sql_operation_name().
|
||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
||||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
||||
span.set_attribute(EXECUTESCRIPT, True)
|
||||
results = await self._execute_write_fn(
|
||||
_inner, block=block, transaction=False, request=request
|
||||
)
|
||||
return results
|
||||
|
||||
async def execute_write_many(self, sql, params_seq, block=True, request=None):
|
||||
|
|
@ -306,12 +359,26 @@ class Database:
|
|||
|
||||
return conn.executemany(sql, count_params(params_seq)), count
|
||||
|
||||
# Nested on purpose - see the note in execute_write().
|
||||
with trace(
|
||||
"sql", database=self.name, sql=sql.strip(), executemany=True
|
||||
) as kwargs:
|
||||
results, count = await self.execute_write_fn(
|
||||
_inner, block=block, request=request
|
||||
)
|
||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
||||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
||||
span.set_attribute(EXECUTEMANY, True)
|
||||
# A single statement run with many parameter sets, so unlike
|
||||
# execute_write_script() there is exactly one operation to name.
|
||||
operation_name = sql_operation_name(sql)
|
||||
if operation_name:
|
||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
||||
results, count = await self._execute_write_fn(
|
||||
_inner, block=block, request=request
|
||||
)
|
||||
# count is the number of parameter *sets* consumed by
|
||||
# executemany(), not a row count - executemany returns no rows.
|
||||
span.set_attribute(PARAM_SETS, count)
|
||||
kwargs["count"] = count
|
||||
return results
|
||||
|
||||
|
|
@ -333,26 +400,65 @@ class Database:
|
|||
# May already have been cleared by close().
|
||||
pass
|
||||
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
return _run()
|
||||
if not write:
|
||||
# Immutable database - no writes can ever occur, so there is no
|
||||
# write queue to block; run against a fresh read-only connection
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
self.ds.executor, _run
|
||||
)
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
# One db.query span here, like execute_fn() / execute_write_fn().
|
||||
# The wrap must NOT move into _send_to_write_thread(): that is the
|
||||
# shared tail for every write, and for block=False it is where the
|
||||
# link back to this span is captured - a span opened there would be
|
||||
# the link target for its own children.
|
||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
||||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(CALLBACK, callback_name(fn))
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
return _run()
|
||||
if not write:
|
||||
# Immutable database - no writes can ever occur, so there is
|
||||
# no write queue to block; run against a fresh read-only
|
||||
# connection. copy_context() carries the caller's otel context
|
||||
# onto the worker thread - see the notes in _execute_fn() for
|
||||
# why it must be a fresh copy per submit and why carrying
|
||||
# every ContextVar is safe.
|
||||
ctx = contextvars.copy_context()
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
self.ds.executor, ctx.run, _run
|
||||
)
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
|
||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
||||
self._check_not_closed()
|
||||
|
||||
return await self.execute_isolated_fn(
|
||||
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
||||
)
|
||||
def _analyze_sql(conn):
|
||||
return analyze_sql_tables(conn, sql, params, database_name=self.name)
|
||||
|
||||
return await self.execute_isolated_fn(_analyze_sql)
|
||||
|
||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||
"""Run `fn(conn)` on the write connection, traced as one database call.
|
||||
|
||||
The public entry point for callback-style writes. Instrumented like
|
||||
`execute_write()`: one `db.query` span (with `datasette.callback` in
|
||||
place of `db.query.text`) above the `db.write.queue_wait` and
|
||||
`db.write.execute` spans the write thread emits. The SQL-string write
|
||||
methods call `_execute_write_fn()` directly, so they never get a
|
||||
second span. For `block=False` this span ends at enqueue and the
|
||||
write-thread spans become roots carrying a link back to it, exactly
|
||||
as for `execute_write(block=False)`.
|
||||
"""
|
||||
self._check_not_closed()
|
||||
# The raw fn's name, before _wrap_fn_with_hooks() replaces it with a
|
||||
# wrapper - otherwise every write would report the wrapper's name.
|
||||
name = callback_name(fn)
|
||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
||||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(CALLBACK, name)
|
||||
return await self._execute_write_fn(
|
||||
fn, block=block, transaction=transaction, request=request
|
||||
)
|
||||
|
||||
async def _execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||
self._check_not_closed()
|
||||
pending_events = []
|
||||
|
||||
|
|
@ -454,8 +560,21 @@ class Database:
|
|||
task_id = uuid.uuid4()
|
||||
loop = asyncio.get_running_loop()
|
||||
reply_future = loop.create_future()
|
||||
# The otel Context and enqueue timestamp are captured here, on the
|
||||
# event loop, for the db.write.queue_wait span built at dequeue time.
|
||||
# `block` travels too - it decides parent vs. link; see `_execute_writes`.
|
||||
self._write_queue.put(
|
||||
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
|
||||
WriteTask(
|
||||
fn,
|
||||
task_id,
|
||||
loop,
|
||||
reply_future,
|
||||
isolated_connection,
|
||||
transaction,
|
||||
otel_context_api.get_current(),
|
||||
time.time_ns(),
|
||||
block,
|
||||
)
|
||||
)
|
||||
if block:
|
||||
return await reply_future
|
||||
|
|
@ -469,6 +588,16 @@ class Database:
|
|||
conn = None
|
||||
try:
|
||||
conn = self.connect(write=True)
|
||||
# This warm-up runs before any write has ever been queued, so
|
||||
# there is no captured caller context to attach - and a raw
|
||||
# threading.Thread does not inherit the context of whoever started
|
||||
# it. Spans created by plugin hooks here are therefore roots even
|
||||
# when the write thread is started from inside invoke_startup():
|
||||
# its datasette.startup span is current on the event loop but does
|
||||
# not cross this thread boundary. Read connections differ - they
|
||||
# warm up inside executor tasks submitted with copy_context(), so
|
||||
# their prepare_connection spans do nest under whoever triggered
|
||||
# them.
|
||||
self.ds._prepare_connection(conn, self.name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Stored and re-raised to whoever queues the next write
|
||||
|
|
@ -483,42 +612,148 @@ class Database:
|
|||
# Best-effort close as the write thread exits
|
||||
pass
|
||||
return
|
||||
exception = None
|
||||
result = None
|
||||
if conn_exception is not None:
|
||||
exception = conn_exception
|
||||
elif task.isolated_connection:
|
||||
try:
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = task.fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# May already have been cleared by close().
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Write thread must survive any task failure or the database wedges
|
||||
sys.stderr.write(f"{e}\n")
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
# `task.block` decides how this task's spans relate to the
|
||||
# context captured at enqueue time:
|
||||
#
|
||||
# - block=True: the caller genuinely awaits the reply, so
|
||||
# containment is accurate. Restore that context as current
|
||||
# (attach below) so db.write.queue_wait/db.write.execute parent
|
||||
# normally to the request that queued them. The token must be
|
||||
# detached below in `finally` - a leaked token silently
|
||||
# poisons this thread's ambient context for every write
|
||||
# processed after it, and a *wrong*-token detach only logs a
|
||||
# warning rather than raising, so this pairing is load-bearing
|
||||
# and easy to get wrong silently.
|
||||
# - block=False: the caller returned already without awaiting,
|
||||
# so the enqueueing span may already have closed (and
|
||||
# exported) before this task's spans even start - parenting to
|
||||
# it would make a child appear to outlive its already-closed
|
||||
# parent, which OTel allows but which renders badly in most
|
||||
# trace UIs. The enqueueing request *caused* this write
|
||||
# without *containing* it, so nothing is attached here -
|
||||
# instead each write span is started as its own root (explicit
|
||||
# empty `context=`, so the write thread's ambient context
|
||||
# cannot supply a parent either) carrying one `Link` back to
|
||||
# the enqueueing span's context, built once into
|
||||
# `write_span_kwargs` and spread into every start_span call
|
||||
# below.
|
||||
token = None
|
||||
write_span_kwargs = {}
|
||||
if task.block:
|
||||
token = otel_context_api.attach(task.otel_context)
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.stderr.write(f"{e}\n")
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
_deliver_write_result(task, result, exception)
|
||||
enqueueing_span_context = get_current_span(
|
||||
task.otel_context
|
||||
).get_span_context()
|
||||
# No attributes on the link: there is only one kind of link
|
||||
# here, so naming the relationship would be a constant that
|
||||
# carries no information a consumer does not already have
|
||||
# from the link's existence.
|
||||
links = (
|
||||
[Link(enqueueing_span_context)]
|
||||
if enqueueing_span_context.is_valid
|
||||
else []
|
||||
)
|
||||
write_span_kwargs = {
|
||||
"context": otel_context_api.Context(),
|
||||
"links": links,
|
||||
}
|
||||
try:
|
||||
exception = None
|
||||
result = None
|
||||
# Explicit start_time/end_time rather than a `with` block:
|
||||
# this span's duration is the time the task actually spent
|
||||
# waiting in the queue (enqueue -> dequeue), not the near-
|
||||
# zero time spent constructing/ending the span object here.
|
||||
tracer.start_span(
|
||||
DB_WRITE_QUEUE_WAIT,
|
||||
start_time=task.enqueued_at_ns,
|
||||
**write_span_kwargs,
|
||||
).end(end_time=time.time_ns())
|
||||
if conn_exception is not None:
|
||||
# fn never runs in this branch, so there is nothing to
|
||||
# wrap in a db.write.execute span.
|
||||
exception = conn_exception
|
||||
elif task.isolated_connection:
|
||||
try:
|
||||
with tracer.start_as_current_span(
|
||||
DB_WRITE_EXECUTE, **write_span_kwargs
|
||||
) as span:
|
||||
span.set_attribute(
|
||||
ISOLATED_CONNECTION,
|
||||
task.isolated_connection,
|
||||
)
|
||||
span.set_attribute(TRANSACTION, task.transaction)
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = task.fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# May already have been cleared by close().
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Write thread must survive any task failure or the database wedges
|
||||
sys.stderr.write(f"{e}\n")
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
else:
|
||||
try:
|
||||
with tracer.start_as_current_span(
|
||||
DB_WRITE_EXECUTE, **write_span_kwargs
|
||||
) as span:
|
||||
span.set_attribute(
|
||||
ISOLATED_CONNECTION,
|
||||
task.isolated_connection,
|
||||
)
|
||||
span.set_attribute(TRANSACTION, task.transaction)
|
||||
if task.transaction:
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.stderr.write(f"{e}\n")
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
_deliver_write_result(task, result, exception)
|
||||
finally:
|
||||
if token is not None:
|
||||
otel_context_api.detach(token)
|
||||
|
||||
async def execute_fn(self, fn):
|
||||
"""Run `fn(conn)` on a read connection, traced as one database call.
|
||||
|
||||
The public entry point for callback-style reads - plugins and core
|
||||
both use it to run arbitrary Python against a connection. It is
|
||||
instrumented exactly like `execute()`: one `db.query` span (with
|
||||
`datasette.callback` in place of `db.query.text`, since there is no
|
||||
SQL string to record) and a `db.query.execute` child covering the
|
||||
time actually spent on the worker thread. `execute()` itself calls
|
||||
`_execute_fn()` directly, so a SQL read never gets a second span.
|
||||
"""
|
||||
self._check_not_closed()
|
||||
|
||||
def fn_in_execute_span(conn):
|
||||
# Created on the worker thread; parents to the db.query span via
|
||||
# the copy_context() propagation in _execute_fn(). The gap
|
||||
# between the two spans is time spent waiting for a free thread.
|
||||
with tracer.start_as_current_span(DB_QUERY_EXECUTE):
|
||||
return fn(conn)
|
||||
|
||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
||||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(CALLBACK, callback_name(fn))
|
||||
# Default exception handling applies, unlike execute(): there is
|
||||
# no log_sql_errors=False probing caller and no expected-timeout
|
||||
# budget on this path, so a raised exception is an error.
|
||||
return await self._execute_fn(fn_in_execute_span)
|
||||
|
||||
async def _execute_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
|
|
@ -538,7 +773,29 @@ class Database:
|
|||
|
||||
with self._pending_execute_futures_lock:
|
||||
self._check_not_closed()
|
||||
future = self.ds.executor.submit(in_thread)
|
||||
# A fresh copy_context() is required per submit (not one shared
|
||||
# copy reused across calls): concurrent execution of the same
|
||||
# Context raises "RuntimeError: cannot enter context ...
|
||||
# already entered". This propagates the caller's otel context
|
||||
# (e.g. the enclosing db.query span) onto the worker thread.
|
||||
#
|
||||
# copy_context() is not selective: it also carries Datasette's own
|
||||
# ContextVars - _skip_permission_checks and _permission_check_cache
|
||||
# (datasette/permissions.py), _in_datasette_client (app.py) and,
|
||||
# until the hand-rolled tracer goes, trace_task_id (tracer.py) -
|
||||
# into worker threads, where they previously took their defaults.
|
||||
# That is safe, for two reasons. Nothing reads them on a worker
|
||||
# thread: the permission code that reads the first two is async and
|
||||
# only ever runs on the event loop. And Context.run() restores the
|
||||
# thread's previous context when the callable returns, so a value
|
||||
# cannot outlive the submit that carried it and reach the next task
|
||||
# on this shared pool - "skip permission checks" in particular can
|
||||
# never bleed from one request into another's query. Where a value
|
||||
# would be read - a plugin calling datasette.in_client() or trace()
|
||||
# from inside an execute_fn callable - seeing the submitting
|
||||
# request's value is the more accurate answer, not a leak.
|
||||
ctx = contextvars.copy_context()
|
||||
future = self.ds.executor.submit(ctx.run, in_thread)
|
||||
self._pending_execute_futures.add(future)
|
||||
future.add_done_callback(self._remove_pending_execute_future)
|
||||
return await asyncio.wrap_future(future)
|
||||
|
|
@ -555,44 +812,135 @@ class Database:
|
|||
"""Executes sql against db_name in a thread"""
|
||||
self._check_not_closed()
|
||||
page_size = page_size or self.ds.page_size
|
||||
time_limit_ms = self.ds.sql_time_limit_ms
|
||||
# A caller that hands in a budget shorter than the instance-wide
|
||||
# sql_time_limit_ms is saying "this may not finish, and that is an
|
||||
# answer I can use" - and every such caller in core does treat the
|
||||
# timeout as normal: table_counts() stores None per table, facet
|
||||
# suggestion moves on to the next column, autocomplete falls back to a
|
||||
# prefix query. Those timeouts are therefore not span errors. Without
|
||||
# this, the homepage alone emits one red span per table (it counts
|
||||
# every table under a 10ms budget) on every single hit.
|
||||
#
|
||||
# A query that runs out the instance-wide limit is a different event -
|
||||
# nobody asked for a short budget, so it stays an error.
|
||||
timeout_expected = bool(custom_time_limit) and custom_time_limit < time_limit_ms
|
||||
if timeout_expected:
|
||||
time_limit_ms = custom_time_limit
|
||||
|
||||
def sql_operation_in_thread(conn):
|
||||
time_limit_ms = self.ds.sql_time_limit_ms
|
||||
if custom_time_limit and custom_time_limit < time_limit_ms:
|
||||
time_limit_ms = custom_time_limit
|
||||
|
||||
with sqlite_timelimit(conn, time_limit_ms):
|
||||
# This span is created inside the worker thread. Its parent is
|
||||
# resolved from the ambient otel context, which was propagated
|
||||
# onto this thread via copy_context() at the executor.submit()
|
||||
# boundary in _execute_fn() (or run_in_executor() for immutable
|
||||
# databases) - so it parents correctly to the enclosing
|
||||
# db.query span despite running on a different thread.
|
||||
#
|
||||
# Exception handling is explicit rather than left to the context
|
||||
# manager's flags, which apply to every exception type alike. This
|
||||
# span needs to tell two apart: an expected timeout is never an
|
||||
# error, while a genuine SQL failure is one unless the caller
|
||||
# passed log_sql_errors=False, meaning it was probing and treats
|
||||
# failure as an expected answer. Without the latter, facet
|
||||
# suggestion marks two spans per text column as failed on every
|
||||
# table page; without the former, so does every homepage hit.
|
||||
with tracer.start_as_current_span(
|
||||
DB_QUERY_EXECUTE,
|
||||
record_exception=False,
|
||||
set_status_on_exception=False,
|
||||
) as execute_span:
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params if params is not None else {})
|
||||
max_returned_rows = self.ds.max_returned_rows
|
||||
if max_returned_rows == page_size:
|
||||
max_returned_rows += 1
|
||||
if max_returned_rows and truncate:
|
||||
rows = cursor.fetchmany(max_returned_rows + 1)
|
||||
truncated = len(rows) > max_returned_rows
|
||||
rows = rows[:max_returned_rows]
|
||||
else:
|
||||
rows = cursor.fetchall()
|
||||
truncated = False
|
||||
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
|
||||
if e.args == ("interrupted",):
|
||||
raise QueryInterrupted(e, sql, params)
|
||||
with sqlite_timelimit(conn, time_limit_ms):
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params if params is not None else {})
|
||||
max_returned_rows = self.ds.max_returned_rows
|
||||
if max_returned_rows == page_size:
|
||||
max_returned_rows += 1
|
||||
if max_returned_rows and truncate:
|
||||
rows = cursor.fetchmany(max_returned_rows + 1)
|
||||
truncated = len(rows) > max_returned_rows
|
||||
rows = rows[:max_returned_rows]
|
||||
else:
|
||||
rows = cursor.fetchall()
|
||||
truncated = False
|
||||
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
|
||||
if e.args == ("interrupted",):
|
||||
raise QueryInterrupted(e, sql, params)
|
||||
if log_sql_errors:
|
||||
sys.stderr.write(
|
||||
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
raise
|
||||
except QueryInterrupted as e:
|
||||
if not timeout_expected:
|
||||
execute_span.record_exception(e)
|
||||
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
raise
|
||||
except Exception as e:
|
||||
if log_sql_errors:
|
||||
sys.stderr.write(
|
||||
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
execute_span.record_exception(e)
|
||||
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
raise
|
||||
|
||||
if truncate:
|
||||
return Results(rows, truncated, cursor.description)
|
||||
if truncate:
|
||||
return Results(rows, truncated, cursor.description)
|
||||
|
||||
else:
|
||||
return Results(rows, False, cursor.description)
|
||||
else:
|
||||
return Results(rows, False, cursor.description)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_fn(sql_operation_in_thread)
|
||||
# SIM117 wants these two context managers merged. They are kept nested
|
||||
# deliberately: the hand-rolled tracer's wrapper is on its way out, and
|
||||
# nesting makes removing it a single-line deletion.
|
||||
with trace( # noqa: SIM117
|
||||
"sql", database=self.name, sql=sql.strip(), params=params
|
||||
):
|
||||
# Exception handling is explicit rather than left to the context
|
||||
# manager's defaults, so that callers passing log_sql_errors=False
|
||||
# can be honoured - see the comment on the generic handler below.
|
||||
with tracer.start_as_current_span(
|
||||
DB_QUERY,
|
||||
kind=DB_QUERY.kind,
|
||||
record_exception=False,
|
||||
set_status_on_exception=False,
|
||||
) as span:
|
||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
||||
span.set_attribute(DB_NAMESPACE, self.name)
|
||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
||||
span.set_attribute(TIME_LIMIT_MS, time_limit_ms)
|
||||
operation_name = sql_operation_name(sql)
|
||||
if operation_name:
|
||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
||||
if params:
|
||||
span.set_attribute(PARAM_COUNT, len(params))
|
||||
try:
|
||||
results = await self._execute_fn(sql_operation_in_thread)
|
||||
except QueryInterrupted as e:
|
||||
# datasette.interrupted is set either way - it is the
|
||||
# signal worth having. Only the ERROR status is
|
||||
# conditional; see the timeout_expected comment above.
|
||||
span.set_attribute(INTERRUPTED, True)
|
||||
if not timeout_expected:
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
span.record_exception(e)
|
||||
raise
|
||||
except Exception as e:
|
||||
# log_sql_errors=False means the caller is probing and
|
||||
# treats failure as an expected answer, not an error.
|
||||
# Facet suggestion is the big one: it runs json_type()
|
||||
# against every column precisely to find out which ones
|
||||
# raise, so a table with N text columns would otherwise
|
||||
# mark N queries per page as failed - burying real errors
|
||||
# and setting off any alerting based on span status.
|
||||
if log_sql_errors:
|
||||
span.record_exception(e)
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
else:
|
||||
span.set_attribute(SQL_ERROR_SUPPRESSED, True)
|
||||
raise
|
||||
span.set_attribute(TRUNCATED, results.truncated)
|
||||
span.set_attribute(ROWS_RETURNED, len(results.rows))
|
||||
return results
|
||||
|
||||
@property
|
||||
|
|
@ -683,17 +1031,34 @@ class Database:
|
|||
)
|
||||
return [r[0] for r in results.rows]
|
||||
|
||||
# These callbacks are named functions rather than lambdas so that their
|
||||
# db.query spans carry a greppable datasette.callback - exactly the
|
||||
# guidance the plugin telemetry docs give, applied to core's own
|
||||
# highest-frequency introspection calls.
|
||||
|
||||
async def table_columns(self, table):
|
||||
return await self.execute_fn(lambda conn: table_columns(conn, table))
|
||||
def _table_columns(conn):
|
||||
return table_columns(conn, table)
|
||||
|
||||
return await self.execute_fn(_table_columns)
|
||||
|
||||
async def table_column_details(self, table):
|
||||
return await self.execute_fn(lambda conn: table_column_details(conn, table))
|
||||
def _table_column_details(conn):
|
||||
return table_column_details(conn, table)
|
||||
|
||||
return await self.execute_fn(_table_column_details)
|
||||
|
||||
async def primary_keys(self, table):
|
||||
return await self.execute_fn(lambda conn: detect_primary_keys(conn, table))
|
||||
def _primary_keys(conn):
|
||||
return detect_primary_keys(conn, table)
|
||||
|
||||
return await self.execute_fn(_primary_keys)
|
||||
|
||||
async def fts_table(self, table):
|
||||
return await self.execute_fn(lambda conn: detect_fts(conn, table))
|
||||
def _fts_table(conn):
|
||||
return detect_fts(conn, table)
|
||||
|
||||
return await self.execute_fn(_fts_table)
|
||||
|
||||
async def label_column_for_table(self, table):
|
||||
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
||||
|
|
@ -891,16 +1256,28 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
|||
|
||||
class WriteTask:
|
||||
__slots__ = (
|
||||
"block",
|
||||
"enqueued_at_ns",
|
||||
"fn",
|
||||
"isolated_connection",
|
||||
"loop",
|
||||
"otel_context",
|
||||
"reply_future",
|
||||
"task_id",
|
||||
"transaction",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, fn, task_id, loop, reply_future, isolated_connection, transaction
|
||||
self,
|
||||
fn,
|
||||
task_id,
|
||||
loop,
|
||||
reply_future,
|
||||
isolated_connection,
|
||||
transaction,
|
||||
otel_context,
|
||||
enqueued_at_ns,
|
||||
block,
|
||||
):
|
||||
self.fn = fn
|
||||
self.task_id = task_id
|
||||
|
|
@ -908,6 +1285,14 @@ class WriteTask:
|
|||
self.reply_future = reply_future
|
||||
self.isolated_connection = isolated_connection
|
||||
self.transaction = transaction
|
||||
self.otel_context = otel_context
|
||||
self.enqueued_at_ns = enqueued_at_ns
|
||||
# Whether the enqueueing caller awaits the reply future. Decides how
|
||||
# `_execute_writes` relates this task's spans to `otel_context`:
|
||||
# parent (block=True) or span-link target (block=False). See the
|
||||
# comment at the WriteTask construction site in
|
||||
# `_send_to_write_thread`.
|
||||
self.block = block
|
||||
|
||||
|
||||
def _deliver_write_result(task, result, exception):
|
||||
|
|
|
|||
126
datasette/telemetry.py
Normal file
126
datasette/telemetry.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""
|
||||
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`. That is not free - a table page emits ~100 spans -
|
||||
but end-to-end page benchmarks put the overhead below their own
|
||||
run-to-run variation. Installing an SDK provider is what costs
|
||||
something measurable.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from opentelemetry import trace as otel_trace
|
||||
|
||||
from .version import __version__
|
||||
|
||||
# The semantic-convention version whose spellings this instrumentation
|
||||
# actually emits. Deliberately NOT the latest release.
|
||||
#
|
||||
# A schema URL is a machine-readable claim: a consumer doing schema
|
||||
# translation replays the renames between the declared version and the one
|
||||
# it wants, so the claim has to name the version whose spellings are on the
|
||||
# wire. A wrong one makes translation wrong rather than merely uninformative.
|
||||
#
|
||||
# Datasette emits `db.system`, which was renamed to `db.system.name` in
|
||||
# semconv 1.30.0. Everything else it emits (`db.namespace`, `db.query.text`,
|
||||
# `db.operation.name`, `db.collection.name`) has been current since 1.26.0.
|
||||
# So 1.29.0 is the highest version at which every name emitted here is the
|
||||
# current spelling. Everything under `datasette.*` is Datasette's own and
|
||||
# outside semconv, so it is unaffected either way.
|
||||
#
|
||||
# Declaring 1.43.0 would be false about `db.system`, and would actively STOP
|
||||
# a consumer translating it forward, because it asserts the rename already
|
||||
# happened. Bump this deliberately, in the same commit as the attribute
|
||||
# renames it implies - it is a claim about the names, not decoration.
|
||||
SCHEMA_URL = "https://opentelemetry.io/schemas/1.29.0"
|
||||
|
||||
tracer = otel_trace.get_tracer("datasette", __version__, schema_url=SCHEMA_URL)
|
||||
|
||||
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]"
|
||||
|
||||
|
||||
def callback_name(fn) -> str:
|
||||
"""
|
||||
The name recorded as `datasette.callback` for a callback-style call.
|
||||
|
||||
`functools.partial` objects (and other callables) have no `__qualname__`,
|
||||
so fall back to the type's name rather than fail the query over telemetry.
|
||||
"""
|
||||
return getattr(fn, "__qualname__", type(fn).__name__)
|
||||
|
||||
|
||||
# db.operation.name is the leading keyword of a statement matched against a
|
||||
# fixed allowlist - deliberately not a parse.
|
||||
#
|
||||
# This runs against arbitrary user-supplied SQL (the `?sql=` query string,
|
||||
# canned queries, anything typed into the query editor), and the attribute is
|
||||
# a candidate dimension on a query-duration metric in a later phase. A metric
|
||||
# series is keyed by its attribute values, so echoing back an arbitrary first
|
||||
# token would let one visitor's typo mint a new, permanent series. The
|
||||
# allowlist bounds that at a fixed, small set regardless of what anyone sends.
|
||||
DB_OPERATION_ALLOWLIST = frozenset(
|
||||
{
|
||||
"SELECT",
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"CREATE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"PRAGMA",
|
||||
"EXPLAIN",
|
||||
"REPLACE",
|
||||
"VACUUM",
|
||||
"ANALYZE",
|
||||
"WITH",
|
||||
}
|
||||
)
|
||||
|
||||
_LEADING_KEYWORD = re.compile(r"^\s*([A-Za-z]+)")
|
||||
|
||||
|
||||
def sql_operation_name(sql: str) -> str | None:
|
||||
"""
|
||||
The statement's leading keyword, if it is one we recognise.
|
||||
|
||||
Returns None - never a guess - for anything not on the allowlist,
|
||||
including a statement that opens with a comment or with punctuation such
|
||||
as the "(" of a parenthesised SELECT.
|
||||
|
||||
Known limitation: a statement beginning with a CTE reports `WITH` rather
|
||||
than the operation inside it, and a substantial share of Datasette's own
|
||||
reads take that form. Extracting more than the leading keyword means
|
||||
handling comment stripping, parenthesised `(SELECT ...) UNION` and
|
||||
compound names like `CREATE TABLE` - each a special case a hand-rolled
|
||||
matcher would accrete and eventually get wrong. Omitting a name beats
|
||||
guessing at one.
|
||||
|
||||
Only safe to call with a single statement: `execute_write_script()` runs
|
||||
several separated by semicolons, and semantic conventions say
|
||||
`db.operation.name` "SHOULD NOT be extracted from db.query.text, when the
|
||||
database system supports query text with multiple operations in non-batch
|
||||
operations" - so that call site does not use this at all rather than
|
||||
reporting only the first statement's operation.
|
||||
"""
|
||||
match = _LEADING_KEYWORD.match(sql)
|
||||
if not match:
|
||||
return None
|
||||
keyword = match.group(1).upper()
|
||||
if keyword in DB_OPERATION_ALLOWLIST:
|
||||
return keyword
|
||||
return None
|
||||
272
datasette/telemetry_registry.py
Normal file
272
datasette/telemetry_registry.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""
|
||||
The single source of truth for every span and span attribute that Datasette
|
||||
core emits.
|
||||
|
||||
Three things read this module, which is the point of it existing:
|
||||
|
||||
1. **The instrumentation itself.** `Attribute` and `SpanName` subclass `str`,
|
||||
so a registry entry *is* the string OpenTelemetry wants. Call sites pass
|
||||
`DB_NAMESPACE` where they used to pass `"db.namespace"` - no wrapper API
|
||||
over the OTel calls, no parallel structure to keep in step, and a typo is
|
||||
now an `ImportError` instead of a silently misnamed attribute.
|
||||
|
||||
2. **The documentation.** `docs/telemetry_doc.py` renders the span reference
|
||||
in `docs/internals.rst` from these definitions using cog, and
|
||||
`cog --check` runs in CI - so the docs cannot drift from the code.
|
||||
|
||||
3. **A conformance test.** `tests/test_telemetry_registry.py` makes real
|
||||
requests, collects every span and attribute actually emitted, and compares
|
||||
both directions: emitted-but-unregistered catches instrumentation added
|
||||
without documentation, registered-but-never-emitted catches documentation
|
||||
describing something that no longer exists. Neither the type system nor
|
||||
the generated docs can catch that second case.
|
||||
"""
|
||||
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
|
||||
class Attribute(str):
|
||||
"""
|
||||
A span attribute key, carrying its own documentation.
|
||||
|
||||
Subclasses `str` so it can be handed straight to `set_attribute()`.
|
||||
"""
|
||||
|
||||
__slots__ = ("description", "optional")
|
||||
|
||||
def __new__(cls, name, description, optional=False):
|
||||
self = super().__new__(cls, name)
|
||||
self.description = description
|
||||
self.optional = optional
|
||||
return self
|
||||
|
||||
def __repr__(self):
|
||||
return f"Attribute({str(self)!r})"
|
||||
|
||||
|
||||
class SpanName(str):
|
||||
"A span name, carrying its documentation and the attributes it may set."
|
||||
|
||||
__slots__ = ("attributes", "description", "kind")
|
||||
|
||||
def __new__(cls, name, description, attributes=(), kind=SpanKind.INTERNAL):
|
||||
self = super().__new__(cls, name)
|
||||
self.description = description
|
||||
self.attributes = tuple(attributes)
|
||||
# SpanKind.INTERNAL by default - every span Datasette emits describes
|
||||
# its own internal work. db.query is the one exception: it is a real
|
||||
# database call, so semantic conventions (and trace UIs, which key
|
||||
# their database styling off this) expect SpanKind.CLIENT.
|
||||
self.kind = kind
|
||||
return self
|
||||
|
||||
def __repr__(self):
|
||||
return f"SpanName({str(self)!r})"
|
||||
|
||||
|
||||
# --- Attributes -----------------------------------------------------------
|
||||
#
|
||||
# Shared attributes are defined once and referenced by every span that sets
|
||||
# them, so "which spans carry db.namespace?" is answerable by grep.
|
||||
|
||||
DB_SYSTEM = Attribute("db.system", "Always ``sqlite``.")
|
||||
DB_NAMESPACE = Attribute("db.namespace", "Name of the database being queried.")
|
||||
DB_QUERY_TEXT = Attribute(
|
||||
"db.query.text",
|
||||
"The SQL, truncated to 2048 characters. Never the parameter values. "
|
||||
"Absent for a callback-style call (``execute_fn()`` and friends), where "
|
||||
"there is no SQL string to record - ``datasette.callback`` is set "
|
||||
"instead.",
|
||||
optional=True,
|
||||
)
|
||||
CALLBACK = Attribute(
|
||||
"datasette.callback",
|
||||
"The qualified name of the Python callable passed to ``execute_fn()``, "
|
||||
"``execute_write_fn()`` or ``execute_isolated_fn()`` - for example "
|
||||
"``TableInsertView.post.<locals>.insert_or_upsert_rows``. Set instead of "
|
||||
"``db.query.text``, which does not exist for a callback: the SQL is "
|
||||
"whatever the function chooses to run. A lambda reports ``<lambda>``, "
|
||||
"which is why callers wanting a recognisable span should pass a named "
|
||||
"function. Bounded cardinality: the set of callables is fixed by the "
|
||||
"installed code, not by request input.",
|
||||
optional=True,
|
||||
)
|
||||
DB_OPERATION_NAME = Attribute(
|
||||
"db.operation.name",
|
||||
"The statement's leading keyword - ``SELECT``, ``INSERT``, ``CREATE``, and "
|
||||
"so on - matched against a small fixed allowlist. Omitted rather than set "
|
||||
"to an arbitrary value: the attribute must stay safe to use as a metric "
|
||||
"dimension, and echoing an unrecognised first token from user-supplied "
|
||||
"SQL would be an unbounded-cardinality hazard. Also omitted for "
|
||||
"``execute_write_script()``, which runs multiple statements - per "
|
||||
"semantic conventions, the operation name should not be extracted from "
|
||||
"query text that can contain more than one operation. Note that a "
|
||||
"statement beginning with a CTE reports ``WITH``, not the operation "
|
||||
"inside it - a substantial share of Datasette's own reads take that "
|
||||
"form. Resolving it further would mean parsing.",
|
||||
optional=True,
|
||||
)
|
||||
PARAM_COUNT = Attribute(
|
||||
"datasette.param_count",
|
||||
"Number of bound parameters. Recorded instead of the values themselves.",
|
||||
optional=True,
|
||||
)
|
||||
PARAM_SETS = Attribute(
|
||||
"datasette.param_sets",
|
||||
"Number of parameter sets consumed by ``execute_write_many()``. Not a row "
|
||||
"count - ``executemany()`` returns no rows. The parameter values "
|
||||
"themselves are never recorded: that sequence can hold thousands of rows.",
|
||||
optional=True,
|
||||
)
|
||||
TIME_LIMIT_MS = Attribute(
|
||||
"datasette.time_limit_ms",
|
||||
"The :ref:`setting_sql_time_limit_ms` value this query ran under. Set on "
|
||||
"reads, which are the queries that time limit applies to.",
|
||||
optional=True,
|
||||
)
|
||||
ROWS_RETURNED = Attribute(
|
||||
"datasette.rows_returned",
|
||||
"Number of rows a read returned. Set on the read path only, and only when "
|
||||
"the read succeeded.",
|
||||
optional=True,
|
||||
)
|
||||
TRUNCATED = Attribute(
|
||||
"datasette.truncated",
|
||||
"True if the result was cut short by :ref:`setting_max_returned_rows`.",
|
||||
optional=True,
|
||||
)
|
||||
INTERRUPTED = Attribute(
|
||||
"datasette.interrupted",
|
||||
"True if the query was cancelled for exceeding the time limit. The span "
|
||||
"status is also set to ``ERROR``, unless the caller asked for a budget "
|
||||
"shorter than :ref:`setting_sql_time_limit_ms` - as table counts, facet "
|
||||
"suggestion and autocomplete all do - in which case running out of time "
|
||||
"is an expected answer rather than a failure and the status is left "
|
||||
"unset.",
|
||||
optional=True,
|
||||
)
|
||||
SQL_ERROR_SUPPRESSED = Attribute(
|
||||
"datasette.sql_error_suppressed",
|
||||
"True when the query failed but the caller passed ``log_sql_errors=False``, "
|
||||
"meaning it was probing and treats failure as an expected answer. Facet "
|
||||
"suggestion does this against every column.",
|
||||
optional=True,
|
||||
)
|
||||
EXECUTESCRIPT = Attribute(
|
||||
"datasette.executescript",
|
||||
"True for ``execute_write_script()``, which runs multiple statements.",
|
||||
optional=True,
|
||||
)
|
||||
EXECUTEMANY = Attribute(
|
||||
"datasette.executemany",
|
||||
"True for ``execute_write_many()``, which runs one statement against many "
|
||||
"parameter sets.",
|
||||
optional=True,
|
||||
)
|
||||
ISOLATED_CONNECTION = Attribute(
|
||||
"datasette.isolated_connection",
|
||||
"True if the write ran on its own connection rather than the shared write "
|
||||
"connection.",
|
||||
)
|
||||
TRANSACTION = Attribute(
|
||||
"datasette.transaction",
|
||||
"False for statements such as ``VACUUM`` that cannot run inside a transaction.",
|
||||
)
|
||||
|
||||
|
||||
# --- Spans ----------------------------------------------------------------
|
||||
|
||||
DB_QUERY = SpanName(
|
||||
"db.query",
|
||||
"A SQL operation issued by Datasette, covering the full round trip "
|
||||
"including any time spent queued for a thread. Callback-style calls - "
|
||||
"``execute_fn()``, ``execute_write_fn()`` and ``execute_isolated_fn()`` - "
|
||||
"appear here too, distinguished by ``datasette.callback`` in place of "
|
||||
"``db.query.text``.",
|
||||
(
|
||||
DB_SYSTEM,
|
||||
DB_NAMESPACE,
|
||||
DB_QUERY_TEXT,
|
||||
CALLBACK,
|
||||
DB_OPERATION_NAME,
|
||||
PARAM_COUNT,
|
||||
PARAM_SETS,
|
||||
TIME_LIMIT_MS,
|
||||
ROWS_RETURNED,
|
||||
TRUNCATED,
|
||||
INTERRUPTED,
|
||||
SQL_ERROR_SUPPRESSED,
|
||||
EXECUTESCRIPT,
|
||||
EXECUTEMANY,
|
||||
),
|
||||
kind=SpanKind.CLIENT,
|
||||
)
|
||||
|
||||
DB_QUERY_EXECUTE = SpanName(
|
||||
"db.query.execute",
|
||||
"The read executing inside a SQL worker thread. Child of ``db.query``; the "
|
||||
"gap between the two is time spent waiting for a thread.",
|
||||
)
|
||||
|
||||
DB_WRITE_QUEUE_WAIT = SpanName(
|
||||
"db.write.queue_wait",
|
||||
"Time a write spent waiting in its database's write queue before the write "
|
||||
"thread picked it up. Child of ``db.query`` for a ``block=True`` write, "
|
||||
"where the caller awaits the write and containment is accurate. For a "
|
||||
"``block=False`` write the caller does not await it - the enqueueing "
|
||||
"request *caused* the write without *containing* it, and the write's "
|
||||
"spans can outlive the request's own - so this is a root span instead, "
|
||||
"carrying an OpenTelemetry link back to the enqueueing span rather than "
|
||||
"a parent. A link records causation without asserting containment, which "
|
||||
"is exactly the distinction here.",
|
||||
)
|
||||
|
||||
DB_WRITE_EXECUTE = SpanName(
|
||||
"db.write.execute",
|
||||
"The write executing on the write thread. Child of ``db.query`` for a "
|
||||
"``block=True`` write; for ``block=False`` a root span with a link back "
|
||||
"to the enqueueing span instead - see ``db.write.queue_wait`` above.",
|
||||
(ISOLATED_CONNECTION, TRANSACTION),
|
||||
)
|
||||
|
||||
STARTUP = SpanName(
|
||||
"datasette.startup",
|
||||
"``invoke_startup()`` running: ``register_events``, ``register_actions``, "
|
||||
"``register_column_types``, ``prepare_jinja2_environment``, internal-database "
|
||||
"schema catalog refresh (including the ``prepare_connection`` warm-up this "
|
||||
"triggers for each database touched for the first time), saved queries, "
|
||||
"column type config and the ``startup`` hook. Runs once per process, before "
|
||||
"any request exists, so without this span every child it creates would be "
|
||||
"its own orphan root trace. A connection warmed later - lazily, the first "
|
||||
"time a *request* touches a new database or thread - nests under that "
|
||||
"request's own span instead, not under this one, since this span has "
|
||||
"already ended by then.",
|
||||
)
|
||||
|
||||
SPANS = (
|
||||
DB_QUERY,
|
||||
DB_QUERY_EXECUTE,
|
||||
DB_WRITE_QUEUE_WAIT,
|
||||
DB_WRITE_EXECUTE,
|
||||
STARTUP,
|
||||
)
|
||||
|
||||
|
||||
def span_for(emitted_name):
|
||||
"""
|
||||
Resolve an emitted span name to its registry entry, or None.
|
||||
|
||||
The lookup is what the conformance test calls, so it lives here rather
|
||||
than in the test.
|
||||
"""
|
||||
for span in SPANS:
|
||||
if emitted_name == span:
|
||||
return span
|
||||
return None
|
||||
|
||||
|
||||
def attribute_allowed(span, emitted_key):
|
||||
"Whether `emitted_key` is a registered attribute of `span`."
|
||||
if span is None:
|
||||
return False
|
||||
return emitted_key in span.attributes
|
||||
|
|
@ -4,6 +4,15 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
.. _unreleased:
|
||||
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
- Datasette's database layer now emits `OpenTelemetry <https://opentelemetry.io/>`__ spans: one per query, covering the full round trip including time spent waiting for a SQL worker thread, plus separate child spans for the execution itself and for time spent in the write queue. Callback-style calls - :ref:`db.execute_fn() <database_execute_fn>`, :ref:`db.execute_write_fn() <database_execute_write_fn>` and ``db.execute_isolated_fn()``, the documented way for plugins to run arbitrary SQL - are covered too, carrying ``datasette.callback`` in place of the SQL text. Datasette core depends on ``opentelemetry-api`` only and never installs an SDK provider, an exporter or a sampler, so there is no effect and no measurable overhead unless tracing is switched on externally - normally with the standard ``opentelemetry-instrument`` agent. See :ref:`internals_telemetry`. (:issue:`1730`)
|
||||
|
||||
Nothing is removed by the OpenTelemetry work: the ``?_trace=1`` query string parameter, the ``trace_debug`` setting and the :ref:`internals_tracer` module all continue to work as before.
|
||||
|
||||
.. _v1_0_a40:
|
||||
|
||||
1.0a40 (2026-09-16)
|
||||
|
|
|
|||
|
|
@ -2174,6 +2174,8 @@ Example usage:
|
|||
|
||||
version = await db.execute_fn(get_version)
|
||||
|
||||
The call is traced as a ``db.query`` OpenTelemetry span carrying ``datasette.callback`` (the function's qualified name) rather than ``db.query.text``, since the SQL is whatever the function chooses to run - see :ref:`internals_telemetry`. Passing a named function gives the span a readable identity; a lambda reports ``<lambda>``.
|
||||
|
||||
.. _database_execute_write:
|
||||
|
||||
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True, time_limit_ms=2000)
|
||||
|
|
@ -2257,6 +2259,8 @@ This method works like ``.execute_write()``, but instead of a SQL statement you
|
|||
|
||||
The function can then perform multiple actions, safe in the knowledge that it has exclusive access to the single writable connection for as long as it is executing.
|
||||
|
||||
Like ``execute_fn()``, the call is traced as a ``db.query`` OpenTelemetry span carrying ``datasette.callback`` rather than ``db.query.text``, above the write-queue spans - see :ref:`internals_telemetry`. A named function gives the span a readable identity; a lambda reports ``<lambda>``.
|
||||
|
||||
.. warning::
|
||||
|
||||
``fn`` needs to be a regular function, not an ``async def`` function.
|
||||
|
|
@ -2491,6 +2495,133 @@ The ``Database`` class also provides properties and methods for introspecting th
|
|||
}
|
||||
}
|
||||
|
||||
.. _internals_telemetry:
|
||||
|
||||
OpenTelemetry
|
||||
=============
|
||||
|
||||
Datasette core depends on `opentelemetry-api <https://pypi.org/project/opentelemetry-api/>`__ only. It never creates a ``TracerProvider``, never configures an exporter and never sets a sampler. With no OpenTelemetry SDK provider installed, every span described below is a no-op ``NonRecordingSpan``: nothing is recorded, nothing is exported, and the cost does not show up in page latency. Benchmarking a table page with and without this instrumentation, the median moved by less than the run-to-run variation of the benchmark itself.
|
||||
|
||||
Turning tracing on is entirely an operational decision made outside of Datasette itself: run Datasette under the standard ``opentelemetry-instrument`` agent, or embed Datasette inside a host application that installs its own provider.
|
||||
|
||||
Everything Datasette emits carries the instrumentation scope ``datasette``, versioned with the running Datasette version and declaring the `semantic conventions schema <https://opentelemetry.io/docs/specs/otel/schemas/>`__ its attribute names follow.
|
||||
|
||||
This is separate from, and does not replace, the built-in :ref:`internals_tracer` mechanism behind ``?_trace=1`` and the :ref:`setting_trace_debug` setting. Both continue to work exactly as before.
|
||||
|
||||
.. _internals_telemetry_turning_on:
|
||||
|
||||
Turning tracing on
|
||||
------------------
|
||||
|
||||
Install an OpenTelemetry SDK, an exporter and the instrumentation agent, then launch Datasette through ``opentelemetry-instrument``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install opentelemetry-distro opentelemetry-exporter-otlp
|
||||
OTEL_SERVICE_NAME=datasette \
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
|
||||
OTEL_METRICS_EXPORTER=none \
|
||||
OTEL_LOGS_EXPORTER=none \
|
||||
opentelemetry-instrument datasette mydb.db
|
||||
|
||||
Point ``OTEL_EXPORTER_OTLP_ENDPOINT`` at whichever tracing backend you use. To print spans straight to the terminal instead, with no backend at all, drop that variable and set ``OTEL_TRACES_EXPORTER=console`` in its place.
|
||||
|
||||
A few things catch people out the first time:
|
||||
|
||||
.. warning::
|
||||
``OTEL_TRACES_EXPORTER=console datasette mydb.db`` produces **nothing**. That environment variable is read by the OpenTelemetry SDK's auto-configuration, which only runs when the ``opentelemetry-instrument`` agent wraps the process. Datasette core installs no provider, so a plain ``datasette`` process emits nothing at all, whatever ``OTEL_`` variables are set.
|
||||
|
||||
- **Spans do not appear immediately.** The SDK's default ``BatchSpanProcessor`` flushes on a timer, every 5 seconds. Either wait, or stop the process - shutdown triggers a final flush - or set ``OTEL_BSP_SCHEDULE_DELAY=1000`` while you are experimenting. That last one is for demos, not for production.
|
||||
|
||||
- **Always set** ``OTEL_SERVICE_NAME``. Without it the SDK's default resource reports a ``service.name`` of ``unknown_service``, and your traces will be filed under that instead of under a name you can search for.
|
||||
|
||||
- **Setting** ``OTEL_METRICS_EXPORTER=none`` **and** ``OTEL_LOGS_EXPORTER=none`` is worth doing unless your backend accepts those signals too - ``opentelemetry-distro`` defaults every signal to OTLP, and a traces-only backend will reject the other two noisily. Datasette itself emits no metrics and no logs through OpenTelemetry.
|
||||
|
||||
Span reference
|
||||
--------------
|
||||
|
||||
Datasette emits five spans. Four of them describe the database layer - one per query, one for the work that query does inside a SQL worker thread, and two more for the write queue - and the fifth covers startup. Attribute names use the ``datasette.*`` prefix for Datasette-specific data, alongside standard OpenTelemetry attributes such as ``db.system``.
|
||||
|
||||
This reference is generated from ``datasette/telemetry_registry.py``, the single source of truth for every span and attribute Datasette emits. A conformance test makes real requests and compares what is actually emitted against that registry in both directions, so nothing here is hand-maintained and nothing can silently drift out of date.
|
||||
|
||||
Spans are ``SpanKind.INTERNAL`` unless a kind is listed below. Only ``db.query`` is ``CLIENT``: it is the one span that represents a call to a database rather than Datasette's own work, and trace UIs use the kind to decide whether to render a span as a database call. Its children stay ``INTERNAL`` because they are Datasette's decomposition of that one query - marking them ``CLIENT`` too would make a single query look like several database calls to anything counting by kind.
|
||||
|
||||
.. [[[cog
|
||||
from telemetry_doc import spans
|
||||
spans(cog)
|
||||
.. ]]]
|
||||
|
||||
``db.query``
|
||||
A SQL operation issued by Datasette, covering the full round trip including any time spent queued for a thread. Callback-style calls - ``execute_fn()``, ``execute_write_fn()`` and ``execute_isolated_fn()`` - appear here too, distinguished by ``datasette.callback`` in place of ``db.query.text``.
|
||||
|
||||
Kind: ``CLIENT``.
|
||||
|
||||
Attributes:
|
||||
|
||||
- ``db.system`` - Always ``sqlite``.
|
||||
- ``db.namespace`` - Name of the database being queried.
|
||||
- ``db.query.text`` *(optional)* - The SQL, truncated to 2048 characters. Never the parameter values. Absent for a callback-style call (``execute_fn()`` and friends), where there is no SQL string to record - ``datasette.callback`` is set instead.
|
||||
- ``datasette.callback`` *(optional)* - The qualified name of the Python callable passed to ``execute_fn()``, ``execute_write_fn()`` or ``execute_isolated_fn()`` - for example ``TableInsertView.post.<locals>.insert_or_upsert_rows``. Set instead of ``db.query.text``, which does not exist for a callback: the SQL is whatever the function chooses to run. A lambda reports ``<lambda>``, which is why callers wanting a recognisable span should pass a named function. Bounded cardinality: the set of callables is fixed by the installed code, not by request input.
|
||||
- ``db.operation.name`` *(optional)* - The statement's leading keyword - ``SELECT``, ``INSERT``, ``CREATE``, and so on - matched against a small fixed allowlist. Omitted rather than set to an arbitrary value: the attribute must stay safe to use as a metric dimension, and echoing an unrecognised first token from user-supplied SQL would be an unbounded-cardinality hazard. Also omitted for ``execute_write_script()``, which runs multiple statements - per semantic conventions, the operation name should not be extracted from query text that can contain more than one operation. Note that a statement beginning with a CTE reports ``WITH``, not the operation inside it - a substantial share of Datasette's own reads take that form. Resolving it further would mean parsing.
|
||||
- ``datasette.param_count`` *(optional)* - Number of bound parameters. Recorded instead of the values themselves.
|
||||
- ``datasette.param_sets`` *(optional)* - Number of parameter sets consumed by ``execute_write_many()``. Not a row count - ``executemany()`` returns no rows. The parameter values themselves are never recorded: that sequence can hold thousands of rows.
|
||||
- ``datasette.time_limit_ms`` *(optional)* - The :ref:`setting_sql_time_limit_ms` value this query ran under. Set on reads, which are the queries that time limit applies to.
|
||||
- ``datasette.rows_returned`` *(optional)* - Number of rows a read returned. Set on the read path only, and only when the read succeeded.
|
||||
- ``datasette.truncated`` *(optional)* - True if the result was cut short by :ref:`setting_max_returned_rows`.
|
||||
- ``datasette.interrupted`` *(optional)* - True if the query was cancelled for exceeding the time limit. The span status is also set to ``ERROR``, unless the caller asked for a budget shorter than :ref:`setting_sql_time_limit_ms` - as table counts, facet suggestion and autocomplete all do - in which case running out of time is an expected answer rather than a failure and the status is left unset.
|
||||
- ``datasette.sql_error_suppressed`` *(optional)* - True when the query failed but the caller passed ``log_sql_errors=False``, meaning it was probing and treats failure as an expected answer. Facet suggestion does this against every column.
|
||||
- ``datasette.executescript`` *(optional)* - True for ``execute_write_script()``, which runs multiple statements.
|
||||
- ``datasette.executemany`` *(optional)* - True for ``execute_write_many()``, which runs one statement against many parameter sets.
|
||||
|
||||
``db.query.execute``
|
||||
The read executing inside a SQL worker thread. Child of ``db.query``; the gap between the two is time spent waiting for a thread.
|
||||
|
||||
No attributes.
|
||||
|
||||
``db.write.queue_wait``
|
||||
Time a write spent waiting in its database's write queue before the write thread picked it up. Child of ``db.query`` for a ``block=True`` write, where the caller awaits the write and containment is accurate. For a ``block=False`` write the caller does not await it - the enqueueing request *caused* the write without *containing* it, and the write's spans can outlive the request's own - so this is a root span instead, carrying an OpenTelemetry link back to the enqueueing span rather than a parent. A link records causation without asserting containment, which is exactly the distinction here.
|
||||
|
||||
No attributes.
|
||||
|
||||
``db.write.execute``
|
||||
The write executing on the write thread. Child of ``db.query`` for a ``block=True`` write; for ``block=False`` a root span with a link back to the enqueueing span instead - see ``db.write.queue_wait`` above.
|
||||
|
||||
Attributes:
|
||||
|
||||
- ``datasette.isolated_connection`` - True if the write ran on its own connection rather than the shared write connection.
|
||||
- ``datasette.transaction`` - False for statements such as ``VACUUM`` that cannot run inside a transaction.
|
||||
|
||||
``datasette.startup``
|
||||
``invoke_startup()`` running: ``register_events``, ``register_actions``, ``register_column_types``, ``prepare_jinja2_environment``, internal-database schema catalog refresh (including the ``prepare_connection`` warm-up this triggers for each database touched for the first time), saved queries, column type config and the ``startup`` hook. Runs once per process, before any request exists, so without this span every child it creates would be its own orphan root trace. A connection warmed later - lazily, the first time a *request* touches a new database or thread - nests under that request's own span instead, not under this one, since this span has already ended by then.
|
||||
|
||||
No attributes.
|
||||
|
||||
.. [[[end]]]
|
||||
|
||||
.. _internals_telemetry_privacy:
|
||||
|
||||
Privacy and safety
|
||||
------------------
|
||||
|
||||
Spans leave your infrastructure whenever you configure an exporter, so what goes into them is a security decision. Datasette's rules are:
|
||||
|
||||
- **SQL text is truncated to 2048 characters.** On a public instance the SQL is supplied by visitors and is unbounded in length, so ``db.query.text`` is cut off - with a ``…[truncated]`` marker - rather than allowed to set the size of a span.
|
||||
- **SQL parameter values are never recorded.** Only ``datasette.param_count``, a count. Parameter values are the part of a query most likely to hold something sensitive, and separating them from the SQL is the reason bound parameters exist.
|
||||
- **No actor identifiers are recorded.** No actor ID, no actor JSON, no client IP address. Nothing on a span identifies who made the request.
|
||||
|
||||
The SQL itself, though, *is* recorded, and on a public instance that means anything a visitor types into the query editor or passes as ``?sql=`` will be exported along with the span. That is the trade-off tracing a query engine makes.
|
||||
|
||||
.. _internals_telemetry_limitations:
|
||||
|
||||
Known limitations
|
||||
-----------------
|
||||
|
||||
- **Datasette does not create a span for the HTTP request itself.** Every span listed above is therefore a root span unless something above Datasette - an ASGI instrumentation layer, or the web framework embedding it - has already started one for the request, in which case Datasette's spans nest underneath it correctly.
|
||||
- **Two plugin hooks run outside the** ``datasette.startup`` **span.** ``register_output_renderer`` is dispatched from ``Datasette.__init__()`` and ``asgi_wrapper`` from ``Datasette.app()``, both of which happen before ``invoke_startup()``. Datasette itself queries no database in either, so a default install emits nothing there - but a plugin that does will produce a root trace. Covering these would mean holding a span open across object construction, which is worse than the orphan.
|
||||
- ``db.operation.name`` **reports** ``WITH`` **for a statement that opens with a common table expression**, rather than the operation inside it, and a substantial share of Datasette's own reads take that form. The attribute is a leading-keyword match against a fixed allowlist, deliberately not a parse.
|
||||
- **Query spans do not carry** ``db.collection.name``. Nothing records which table a query is about. Datasette will not derive it from the SQL - that would mean parsing, and on an instance where visitors can create tables the set of possible values has no ceiling - so it can only come from callers that already know, which needs an API that does not exist yet.
|
||||
- **Spans emitted before a provider is installed are not recorded.** If you are embedding Datasette in a host application, install your ``TracerProvider`` before serving traffic. This is ordinary OpenTelemetry behaviour rather than anything Datasette controls; nothing is permanently affected, those particular spans are simply dropped.
|
||||
|
||||
.. _internals_csrf:
|
||||
|
||||
CSRF protection
|
||||
|
|
|
|||
36
docs/telemetry_doc.py
Normal file
36
docs/telemetry_doc.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""
|
||||
Render the span reference in ``internals.rst`` from
|
||||
``datasette/telemetry_registry.py``.
|
||||
|
||||
Driven by cog, and ``cog --check docs/*.rst`` runs in CI - so adding a span
|
||||
without documenting it, or documenting one that no longer exists, is a build
|
||||
failure rather than something a reader discovers later.
|
||||
"""
|
||||
|
||||
|
||||
def _attribute_lines(cog, attributes):
|
||||
if not attributes:
|
||||
cog.out(" No attributes.\n\n")
|
||||
return
|
||||
cog.out(" Attributes:\n\n")
|
||||
for attribute in attributes:
|
||||
suffix = " *(optional)*" if attribute.optional else ""
|
||||
cog.out(f" - ``{attribute}``{suffix} - {attribute.description}\n")
|
||||
cog.out("\n")
|
||||
|
||||
|
||||
def spans(cog):
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
from datasette.telemetry_registry import SPANS
|
||||
|
||||
cog.out("\n")
|
||||
for span in SPANS:
|
||||
cog.out(f"``{span}``\n")
|
||||
cog.out(f" {span.description}\n\n")
|
||||
# INTERNAL is the default and the overwhelming majority of spans -
|
||||
# printing it on every one would be noise. Only the exceptional case,
|
||||
# a real database call, is worth calling out.
|
||||
if span.kind != SpanKind.INTERNAL:
|
||||
cog.out(f" Kind: ``{span.kind.name}``.\n\n")
|
||||
_attribute_lines(cog, span.attributes)
|
||||
|
|
@ -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.12.19",
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
@ -171,6 +227,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):
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ Tests for the datasette.database.Database class
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlite_utils
|
||||
from opentelemetry import context as otel_context_api
|
||||
|
||||
from datasette.app import Datasette
|
||||
from datasette.database import (
|
||||
|
|
@ -1319,3 +1321,110 @@ async def test_close_releases_memory_connections(num_sql_threads, named):
|
|||
for conn in (read_connection, write_connection):
|
||||
with pytest.raises(sqlite3.ProgrammingError, match="closed"):
|
||||
conn.execute("select 1")
|
||||
|
||||
|
||||
_CONTEXT_LEAK_MARKER_KEY = "otel-context-leak-marker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("num_sql_threads", (0, 1))
|
||||
async def test_write_thread_context_is_detached_between_tasks(
|
||||
tmp_path, monkeypatch, num_sql_threads
|
||||
):
|
||||
"""
|
||||
The write thread attaches each task's otel Context and must detach it
|
||||
again before picking up the next task. The thread is persistent and
|
||||
shared, so a leaked token would grow that thread's context stack for the
|
||||
rest of the process - and a *wrong*-token detach only logs a warning
|
||||
rather than raising, so "does it throw" cannot catch either mistake.
|
||||
|
||||
Two things are asserted, because neither alone is sufficient:
|
||||
|
||||
1. Each task observes the context value that was current on the event
|
||||
loop when it was queued. This is what fails if the Context is not
|
||||
carried on WriteTask, or is never attached. It does *not* catch a
|
||||
missing detach: attach() replaces the current Context wholesale, so a
|
||||
leftover one from a previous task is simply overwritten.
|
||||
2. The write thread's attach depth is identical at the same point in
|
||||
every task. This is what fails if detach is missing - the stack grows
|
||||
by one per task - and it holds across a task that raises, because the
|
||||
detach lives in a `finally`.
|
||||
|
||||
An otel context value is used rather than a plain contextvars.ContextVar:
|
||||
a plain var set on the event loop never crosses into the write thread, so
|
||||
the probe would read None every time and the test could not fail.
|
||||
"""
|
||||
name = f"context_leak_test_{num_sql_threads}"
|
||||
db_path = tmp_path / f"{name}.db"
|
||||
sqlite3.connect(db_path).close()
|
||||
ds = Datasette([str(db_path)], settings={"num_sql_threads": num_sql_threads})
|
||||
db = ds.get_database(name)
|
||||
await db.execute_write("create table t (id integer primary key)")
|
||||
|
||||
write_thread_name = f"_execute_writes for database {name}"
|
||||
depth = {"value": 0}
|
||||
real_attach = otel_context_api.attach
|
||||
real_detach = otel_context_api.detach
|
||||
|
||||
def counting_attach(context):
|
||||
token = real_attach(context)
|
||||
if threading.current_thread().name == write_thread_name:
|
||||
depth["value"] += 1
|
||||
return token
|
||||
|
||||
def counting_detach(token):
|
||||
real_detach(token)
|
||||
if threading.current_thread().name == write_thread_name:
|
||||
depth["value"] -= 1
|
||||
|
||||
# Patched on the opentelemetry.context module itself, which is what both
|
||||
# database.py and opentelemetry.trace.use_span() look the functions up on.
|
||||
monkeypatch.setattr(otel_context_api, "attach", counting_attach)
|
||||
monkeypatch.setattr(otel_context_api, "detach", counting_detach)
|
||||
|
||||
seen_markers = []
|
||||
seen_depths = []
|
||||
|
||||
def probe(conn):
|
||||
seen_markers.append(otel_context_api.get_value(_CONTEXT_LEAK_MARKER_KEY))
|
||||
seen_depths.append(depth["value"])
|
||||
|
||||
def failing_probe(conn):
|
||||
probe(conn)
|
||||
# Exercises the write thread's exception path: the detach still has
|
||||
# to happen, which is why it lives in a `finally`.
|
||||
raise ValueError("deliberate failure inside a write task")
|
||||
|
||||
try:
|
||||
for i in range(5):
|
||||
ctx = otel_context_api.set_value(_CONTEXT_LEAK_MARKER_KEY, f"marker-{i}")
|
||||
token = real_attach(ctx)
|
||||
try:
|
||||
if i == 2:
|
||||
with pytest.raises(ValueError):
|
||||
await db.execute_write_fn(failing_probe)
|
||||
else:
|
||||
await db.execute_write_fn(probe)
|
||||
finally:
|
||||
real_detach(token)
|
||||
|
||||
# Sanity check: no marker is active in *this* (event loop) context
|
||||
# right now, so the final probe is a fair test of the write thread's
|
||||
# own state rather than something this test forgot to clean up.
|
||||
assert otel_context_api.get_value(_CONTEXT_LEAK_MARKER_KEY) is None
|
||||
await db.execute_write_fn(probe)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert seen_markers == [
|
||||
"marker-0",
|
||||
"marker-1",
|
||||
"marker-2",
|
||||
"marker-3",
|
||||
"marker-4",
|
||||
None,
|
||||
]
|
||||
assert len(set(seen_depths)) == 1, (
|
||||
f"write thread context stack grew across tasks: {seen_depths} - "
|
||||
"a token was attached without being detached"
|
||||
)
|
||||
|
|
|
|||
1188
tests/test_telemetry.py
Normal file
1188
tests/test_telemetry.py
Normal file
File diff suppressed because it is too large
Load diff
297
tests/test_telemetry_registry.py
Normal file
297
tests/test_telemetry_registry.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""
|
||||
Two-way conformance between `datasette/telemetry_registry.py` and what
|
||||
Datasette actually emits.
|
||||
|
||||
This is the test that makes the generated documentation trustworthy. cog
|
||||
guarantees the docs match the registry; this guarantees the registry matches
|
||||
the code. Without it, both could agree with each other and be wrong.
|
||||
|
||||
It checks both directions, and the second one is the one nothing else catches:
|
||||
|
||||
- **emitted but not registered** - instrumentation was added without
|
||||
documenting it, so the reference page silently omits it.
|
||||
- **registered but never emitted** - the reference page describes a span or
|
||||
attribute that no longer exists, which is worse than omitting it, because a
|
||||
reader will build a dashboard on it.
|
||||
|
||||
Both of those directions compare the code against the registry. Neither can
|
||||
catch a *rename*, because the call sites now take their names from the
|
||||
registry - move `DB_NAMESPACE` to `"db.namespace2"` and code and registry
|
||||
still agree with each other, while every existing dashboard breaks. So the
|
||||
literal names live here too, spelled out, and are asserted against both the
|
||||
registry and the wire. That is the one comparison in this file that is not
|
||||
made against a value derived from the registry itself.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip("opentelemetry.sdk")
|
||||
|
||||
from datasette import telemetry_registry as reg
|
||||
from datasette.app import Datasette
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
|
||||
# The names as they appear on the wire, written out rather than read from the
|
||||
# registry. If a change to the registry makes one of these fail, that change
|
||||
# is renaming something a user's dashboards and saved queries depend on -
|
||||
# which is a decision to take deliberately, here, not a line to re-derive.
|
||||
EXPECTED_ATTRIBUTES = {
|
||||
"db.query": {
|
||||
"db.system",
|
||||
"db.namespace",
|
||||
"db.query.text",
|
||||
"datasette.callback",
|
||||
"db.operation.name",
|
||||
"datasette.param_count",
|
||||
"datasette.param_sets",
|
||||
"datasette.time_limit_ms",
|
||||
"datasette.rows_returned",
|
||||
"datasette.truncated",
|
||||
"datasette.interrupted",
|
||||
"datasette.sql_error_suppressed",
|
||||
"datasette.executescript",
|
||||
"datasette.executemany",
|
||||
},
|
||||
"db.query.execute": set(),
|
||||
"db.write.queue_wait": set(),
|
||||
"db.write.execute": {
|
||||
"datasette.isolated_connection",
|
||||
"datasette.transaction",
|
||||
},
|
||||
"datasette.startup": set(),
|
||||
}
|
||||
EXPECTED_SPANS = set(EXPECTED_ATTRIBUTES)
|
||||
|
||||
# Named in-memory databases are shared-cache, so two Datasette instances using
|
||||
# the same name share one SQLite database - and the second `create table`
|
||||
# fails. Every workload below therefore gets its own name.
|
||||
_names = itertools.count()
|
||||
|
||||
|
||||
def _unique(prefix):
|
||||
return f"{prefix}{next(_names)}"
|
||||
|
||||
|
||||
async def exercise():
|
||||
"""
|
||||
Drive enough of Datasette to emit every span and attribute the registry
|
||||
claims exists.
|
||||
|
||||
Each call is here because it is the only thing that produces some span or
|
||||
attribute - see the comments. If you add instrumentation on a path this
|
||||
does not reach, add the path rather than loosening the assertions.
|
||||
|
||||
Returns the instance so the caller can close it; startup happens inside
|
||||
so that the `datasette.startup` span lands in the collected set.
|
||||
"""
|
||||
name = _unique("registry")
|
||||
ds = Datasette(memory=True)
|
||||
ds.add_memory_database(name)
|
||||
# datasette.startup - and the internal catalog work nested under it
|
||||
await ds.invoke_startup()
|
||||
db = ds.get_database(name)
|
||||
|
||||
# Writes: db.write.queue_wait, db.write.execute, db.query
|
||||
await db.execute_write("create table t (id integer primary key, v text)")
|
||||
# datasette.executemany, datasette.param_sets
|
||||
await db.execute_write_many(
|
||||
"insert into t (id, v) values (?, ?)", [[i, f"v{i}"] for i in range(30)]
|
||||
)
|
||||
# datasette.executescript
|
||||
await db.execute_write_script("create table t2 (id integer); drop table t2;")
|
||||
# datasette.transaction=False - VACUUM cannot run inside a transaction
|
||||
await db.execute_write("vacuum", transaction=False)
|
||||
# datasette.isolated_connection=True
|
||||
await db.execute_isolated_fn(lambda conn: conn.execute("select 1").fetchone())
|
||||
|
||||
# datasette.callback, with named functions so the conformance run sees the
|
||||
# attribute's documented value shape (a qualname, not just "<lambda>")
|
||||
def registry_read_callback(conn):
|
||||
return conn.execute("select count(*) from t").fetchone()
|
||||
|
||||
def registry_write_callback(conn):
|
||||
conn.execute("insert into t (id, v) values (100, 'callback')")
|
||||
|
||||
await db.execute_fn(registry_read_callback)
|
||||
await db.execute_write_fn(registry_write_callback)
|
||||
|
||||
# Reads: db.query.execute, datasette.rows_returned, datasette.truncated,
|
||||
# datasette.param_count, datasette.time_limit_ms
|
||||
await db.execute("select * from t where id > :n", {"n": 5})
|
||||
await db.execute("select * from t", truncate=True)
|
||||
|
||||
# datasette.sql_error_suppressed - the caller is probing and treats
|
||||
# failure as an expected answer
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
await db.execute("select nope from t", log_sql_errors=False)
|
||||
|
||||
# datasette.interrupted - only ever set when a query exceeds its time
|
||||
# limit, so the workload has to force one rather than exempt it. An
|
||||
# unbounded recursive CTE cannot finish, so 1ms is always exceeded.
|
||||
with pytest.raises(QueryInterrupted):
|
||||
await db.execute(
|
||||
"with recursive c(x) as (select 0 union all select x+1 from c) "
|
||||
"select * from c",
|
||||
custom_time_limit=1,
|
||||
)
|
||||
|
||||
return ds
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def emitted(otel_spans):
|
||||
"Every span name and (span name, attribute key) pair a broad workload emits."
|
||||
# otel_spans has already cleared the exporter, and nothing is cleared
|
||||
# after this point: the workload's own startup emits datasette.startup.
|
||||
ds = await exercise()
|
||||
spans = otel_spans.get_finished_spans()
|
||||
assert spans, "no spans captured - the fixture is not exercising anything"
|
||||
names = set()
|
||||
pairs = set()
|
||||
for span in spans:
|
||||
# str() because span.name is the registry's SpanName instance, and a
|
||||
# set of those would compare equal to literals but read confusingly
|
||||
# in a failure message.
|
||||
names.add(str(span.name))
|
||||
for key in span.attributes or {}:
|
||||
pairs.add((str(span.name), str(key)))
|
||||
ds.close()
|
||||
return {"names": names, "pairs": pairs}
|
||||
|
||||
|
||||
def _keys_by_span(pairs):
|
||||
by_span = {}
|
||||
for span_name, key in pairs:
|
||||
by_span.setdefault(span_name, set()).add(key)
|
||||
return by_span
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workload_emits_exactly_the_expected_names(emitted):
|
||||
"""
|
||||
The wire format, pinned to literals.
|
||||
|
||||
Not derived from the registry, so this is what catches a rename that the
|
||||
registry and the call sites make together.
|
||||
"""
|
||||
assert emitted["names"] == EXPECTED_SPANS
|
||||
by_span = _keys_by_span(emitted["pairs"])
|
||||
assert {name: by_span.get(name, set()) for name in emitted["names"]} == (
|
||||
EXPECTED_ATTRIBUTES
|
||||
)
|
||||
|
||||
|
||||
def test_registry_matches_the_expected_names():
|
||||
"The other half of the rename check: the registry against the same literals."
|
||||
assert {str(span) for span in reg.SPANS} == EXPECTED_SPANS
|
||||
for span in reg.SPANS:
|
||||
assert {str(attribute) for attribute in span.attributes} == EXPECTED_ATTRIBUTES[
|
||||
str(span)
|
||||
], f"{span} attributes have drifted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_emitted_span_is_registered(emitted):
|
||||
"A span added without a registry entry would be missing from the docs."
|
||||
unregistered = sorted(
|
||||
name for name in emitted["names"] if reg.span_for(name) is None
|
||||
)
|
||||
assert (
|
||||
not unregistered
|
||||
), f"these spans are emitted but not in telemetry_registry.SPANS: {unregistered}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_emitted_attribute_is_registered(emitted):
|
||||
"An attribute added without a registry entry would be missing from the docs."
|
||||
unregistered = sorted(
|
||||
f"{span_name} -> {key}"
|
||||
for span_name, key in emitted["pairs"]
|
||||
if not reg.attribute_allowed(reg.span_for(span_name), key)
|
||||
)
|
||||
assert (
|
||||
not unregistered
|
||||
), "these span attributes are emitted but not registered: " + ", ".join(
|
||||
unregistered
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_registered_span_is_emitted(emitted):
|
||||
"""
|
||||
The direction nothing else catches: the docs must not describe a span that
|
||||
no longer exists.
|
||||
"""
|
||||
missing = sorted(
|
||||
str(span)
|
||||
for span in reg.SPANS
|
||||
if not any(reg.span_for(name) is span for name in emitted["names"])
|
||||
)
|
||||
assert not missing, (
|
||||
f"these spans are documented but never emitted by the workload: {missing}. "
|
||||
"Either the instrumentation was removed, or exercise() no longer reaches it."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_registered_attribute_is_emitted(emitted):
|
||||
"""
|
||||
Every registered attribute, optional or not, must actually be set at least
|
||||
once by the workload.
|
||||
|
||||
`optional` describes whether a reader should expect it on every span, not
|
||||
whether the code still sets it - so an attribute deleted from the code but
|
||||
left in the docs has to fail here even when it is marked optional. If a
|
||||
new attribute only appears in some rare case, extend exercise() to reach
|
||||
that case.
|
||||
"""
|
||||
by_span = _keys_by_span(emitted["pairs"])
|
||||
missing = []
|
||||
for span in reg.SPANS:
|
||||
emitted_keys = by_span.get(str(span), set())
|
||||
for attribute in span.attributes:
|
||||
if attribute not in emitted_keys:
|
||||
missing.append(f"{span} -> {attribute}")
|
||||
assert not missing, (
|
||||
"these attributes are documented but never emitted by the workload: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
|
||||
|
||||
def test_registry_has_no_duplicate_names():
|
||||
assert len(set(reg.SPANS)) == len(reg.SPANS)
|
||||
for span in reg.SPANS:
|
||||
assert len(set(span.attributes)) == len(
|
||||
span.attributes
|
||||
), f"{span} lists an attribute twice"
|
||||
|
||||
|
||||
def test_registry_entries_are_documented():
|
||||
"Every entry carries a description - the docs are generated from these."
|
||||
for span in reg.SPANS:
|
||||
assert span.description.strip(), f"{span} has no description"
|
||||
for attribute in span.attributes:
|
||||
assert attribute.description.strip(), f"{span} -> {attribute} has none"
|
||||
|
||||
|
||||
def test_registry_entries_are_usable_as_plain_strings():
|
||||
"The str subclassing is the whole reason call sites need no wrapper API."
|
||||
assert isinstance(reg.DB_QUERY, str)
|
||||
assert isinstance(reg.DB_NAMESPACE, str)
|
||||
assert reg.DB_QUERY == "db.query"
|
||||
assert reg.DB_NAMESPACE == "db.namespace"
|
||||
assert f"{reg.DB_QUERY}.execute" == "db.query.execute"
|
||||
|
||||
|
||||
def test_span_and_attribute_lookup():
|
||||
assert reg.span_for("db.query") is reg.DB_QUERY
|
||||
assert reg.span_for("datasette.startup") is reg.STARTUP
|
||||
assert reg.span_for("not.a.datasette.span") is None
|
||||
assert reg.attribute_allowed(reg.DB_QUERY, "db.namespace")
|
||||
assert not reg.attribute_allowed(reg.DB_QUERY, "db.namespace.extra")
|
||||
assert not reg.attribute_allowed(reg.DB_QUERY, "datasette.isolated_connection")
|
||||
assert not reg.attribute_allowed(None, "db.namespace")
|
||||
Loading…
Add table
Add a link
Reference in a new issue