mirror of
https://github.com/simonw/datasette.git
synced 2026-09-15 13:04:06 +02:00
- Remove the registry's unused prefix=True slot, its span_for() branch, its doc-rendering case and its test - nothing in the stack sets it. - Stop promising a "later phase" query-duration metric dimension in the db.operation.name description; the cardinality rationale stands alone. - Replace baked-in benchmark numbers in the telemetry module docstring with the docs' own phrasing (below run-to-run variation). - Compact the duplicated copy_context() and enqueue-site comments in database.py to pointers at their canonical tellings. - Make the "catch people out" gotchas skimmable as a bullet list and give the changelog's "nothing is removed" line a clear antecedent. - Add a test that a result cut short by max_returned_rows records datasette.truncated=True - previously only ever asserted False. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
116 lines
4.5 KiB
Python
116 lines
4.5 KiB
Python
"""
|
|
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 ~58 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]"
|
|
|
|
|
|
# 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
|