1"""Trace capture and analysis for the `plain request` CLI command.
2
3Captures the OpenTelemetry spans emitted while handling a request and groups
4them — by statement, by trace — for human and agent inspection. Spans come
5back as a flat list per trace; `cli.py` turns that into the printed tree.
6Internal to `plain request`; not public API.
7
8This reports, it does not diagnose. Repeated statements are counted and their
9call sites recorded, but nothing here decides that a repeat is an N+1 — the
10reader has the trace and better judgment about the loop that produced it.
11
12One request is one trace, so a followed redirect chain produces several. They
13are analyzed separately and never merged: counting a once-per-request query
14across three hops would read as a 3x repeat that no one can fix.
15"""
16
17from contextlib import contextmanager
18from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
19
20from opentelemetry import trace
21from opentelemetry.semconv.attributes.code_attributes import (
22 CODE_FILE_PATH,
23 CODE_FUNCTION_NAME,
24 CODE_LINE_NUMBER,
25 CODE_STACKTRACE,
26)
27from opentelemetry.semconv.attributes.db_attributes import (
28 DB_OPERATION_NAME,
29 DB_QUERY_TEXT,
30)
31from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
32from opentelemetry.semconv.attributes.exception_attributes import (
33 EXCEPTION_MESSAGE,
34 EXCEPTION_STACKTRACE,
35 EXCEPTION_TYPE,
36)
37from opentelemetry.semconv.attributes.http_attributes import HTTP_REQUEST_METHOD
38from opentelemetry.semconv.attributes.url_attributes import URL_PATH, URL_QUERY
39
40# The handler stamps this on every request span; it joins a captured trace
41# back to the response the CLI reports for that hop.
42_REQUEST_ID_ATTRIBUTE = "plain.request.id"
43
44if TYPE_CHECKING:
45 from collections.abc import Generator, Mapping, Sequence
46
47 from opentelemetry.sdk.trace import ReadableSpan
48 from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
49 InMemorySpanExporter,
50 )
51
52
53def _span_duration_ms(span: ReadableSpan) -> float:
54 if span.start_time is None or span.end_time is None:
55 return 0.0
56 return (span.end_time - span.start_time) / 1_000_000
57
58
59def _span_start_offset_ms(span: ReadableSpan, trace_start: int | None) -> float:
60 """Milliseconds from the trace's first span to this span's start."""
61 if span.start_time is None or trace_start is None:
62 return 0.0
63 return (span.start_time - trace_start) / 1_000_000
64
65
66def _source_location(attributes: Mapping[str, Any]) -> str | None:
67 """Build a human-readable source location from OTel code.* attributes."""
68 file_path = attributes.get(CODE_FILE_PATH)
69 if not file_path:
70 return None
71 location = str(file_path)
72 if line := attributes.get(CODE_LINE_NUMBER):
73 location += f":{line}"
74 if function := attributes.get(CODE_FUNCTION_NAME):
75 location += f" in {function}"
76 return location
77
78
79def _text(value: Any) -> str | None:
80 """Coerce an OpenTelemetry attribute value to text, preserving None."""
81 return None if value is None else str(value)
82
83
84class QueryEntry(TypedDict):
85 """One distinct SQL statement and its occurrences in this trace.
86
87 `count` means one thing — how many times the statement ran — so it can be
88 read without asking which executions it covers. Judging whether a repeat
89 is a problem is left to the reader: `sources` says where each execution
90 came from.
91 """
92
93 sql: str
94 count: int
95 total_duration_ms: float
96 sources: list[str]
97
98
99class TraceException(TypedDict):
100 """An exception recorded on a span."""
101
102 span: str
103 error_type: str | None
104 message: str | None
105 stacktrace: str | None
106
107
108class SpanEvent(TypedDict):
109 """One OpenTelemetry span event — attributes passed through verbatim."""
110
111 name: str
112 attributes: dict[str, Any]
113
114
115class RawSpan(TypedDict):
116 """A captured span in generic OpenTelemetry shape — no domain projection.
117
118 Attributes and events are passed through verbatim, minus `code.stacktrace`
119 (a multi-KB debug stack the postgres instrumentation records on every
120 query span). The span list is flat; `parent_span_id` gives the structure.
121 The trace id belongs to the containing `CapturedTrace` rather than being
122 repeated on every span.
123 """
124
125 name: str
126 kind: str
127 span_id: str
128 parent_span_id: str | None
129 start_offset_ms: float
130 duration_ms: float
131 status: NotRequired[str]
132 attributes: NotRequired[dict[str, Any]]
133 events: NotRequired[list[SpanEvent]]
134
135
136# `code.stacktrace` is a full, multi-KB filtered stack the postgres
137# instrumentation records on every query span in DEBUG. It would dwarf the
138# rest of the trace, so it is dropped from the raw span output.
139_EXCLUDED_SPAN_ATTRIBUTES = frozenset({CODE_STACKTRACE})
140
141
142def _span_dict(span: ReadableSpan, trace_start: int | None) -> RawSpan:
143 """Convert a captured span to a generic, JSON-serializable OTel dict."""
144 raw: RawSpan = {
145 "name": span.name,
146 "kind": span.kind.name,
147 "span_id": trace.format_span_id(span.context.span_id),
148 "parent_span_id": (
149 trace.format_span_id(span.parent.span_id)
150 if span.parent is not None
151 else None
152 ),
153 "start_offset_ms": round(_span_start_offset_ms(span, trace_start), 2),
154 "duration_ms": round(_span_duration_ms(span), 2),
155 }
156 status = span.status.status_code.name
157 if status != "UNSET":
158 raw["status"] = status
159 attributes = {
160 key: value
161 for key, value in (span.attributes or {}).items()
162 if key not in _EXCLUDED_SPAN_ATTRIBUTES
163 }
164 if attributes:
165 raw["attributes"] = attributes
166 events: list[SpanEvent] = [
167 {"name": event.name, "attributes": dict(event.attributes or {})}
168 for event in span.events
169 ]
170 if events:
171 raw["events"] = events
172 return raw
173
174
175class TraceAnalysis(TypedDict):
176 """Derived analysis of one trace — counts, exceptions, query grouping."""
177
178 duration_ms: float
179 span_count: int
180 query_count: int
181 transaction_count: int
182 exceptions: list[TraceException]
183 queries: list[QueryEntry]
184
185
186class CapturedTrace(TypedDict):
187 """One captured trace — a single request — identified, read, and raw.
188
189 `name` and `request_id` identify the hop, `analysis` is the opinionated
190 read, and `spans` is the untouched OpenTelemetry shape. `request_id`
191 joins a trace back to the response the CLI reports for it.
192 """
193
194 name: str
195 request_id: str | None
196 analysis: TraceAnalysis
197 spans: list[RawSpan]
198
199
200# Statements that manage a transaction rather than read or write data. They
201# are counted apart from real queries: savepoint names are unique, so they
202# never group, and a handful of them can fill a query list on their own.
203# With plain.postgres only the savepoint three can actually appear —
204# BEGIN/COMMIT are issued outside the instrumented cursor and emit no span —
205# but any db instrumentation that does span them classifies the same way.
206_TRANSACTION_OPERATIONS = frozenset(
207 {"BEGIN", "START", "COMMIT", "END", "ROLLBACK", "SAVEPOINT", "RELEASE"}
208)
209
210
211def _db_operation(attributes: Mapping[str, Any], sql: str) -> str:
212 """The statement's leading SQL keyword, uppercased."""
213 if operation := attributes.get(DB_OPERATION_NAME):
214 return str(operation).upper()
215 keywords = sql.lstrip("( \t\r\n").split(maxsplit=1)
216 return keywords[0].upper() if keywords else ""
217
218
219def _trace_name(*, root: ReadableSpan | None, spans: Sequence[ReadableSpan]) -> str:
220 """Label a trace by its root span — method and path when it is a request.
221
222 The path is what makes hops of a redirect chain tellable apart; the root
223 span's own name carries the matched route, which is identical across a
224 trailing-slash redirect and missing entirely when resolution failed. The
225 query string is included because an auth bounce revisits one path with
226 different params, and those hops have to stay distinguishable.
227 """
228 if root is None:
229 return spans[0].name
230 attributes = root.attributes or {}
231 method = attributes.get(HTTP_REQUEST_METHOD)
232 path = attributes.get(URL_PATH)
233 if not (method and path):
234 return root.name
235 if query := attributes.get(URL_QUERY):
236 return f"{method} {path}?{query}"
237 return f"{method} {path}"
238
239
240def analyze_traces(spans: Sequence[ReadableSpan]) -> list[CapturedTrace]:
241 """Summarize captured OpenTelemetry spans, one entry per trace.
242
243 Traces come back in the order they started, which for a followed redirect
244 is hop order.
245 """
246 spans_by_trace: dict[int, list[ReadableSpan]] = {}
247 for span in sorted(spans, key=lambda s: s.start_time or 0):
248 spans_by_trace.setdefault(span.context.trace_id, []).append(span)
249
250 return [_analyze_trace(trace_spans) for trace_spans in spans_by_trace.values()]
251
252
253def _analyze_trace(spans: list[ReadableSpan]) -> CapturedTrace:
254 """Analyze one trace's spans, which arrive ordered by start time."""
255 queries_by_sql: dict[str, QueryEntry] = {}
256 transaction_count = 0
257 exceptions: list[TraceException] = []
258
259 for span in spans:
260 attributes = span.attributes or {}
261
262 if (raw_sql := attributes.get(DB_QUERY_TEXT)) is not None:
263 sql = str(raw_sql)
264 if _db_operation(attributes, sql) in _TRANSACTION_OPERATIONS:
265 transaction_count += 1
266 else:
267 entry = queries_by_sql.setdefault(
268 sql,
269 {"sql": sql, "count": 0, "total_duration_ms": 0.0, "sources": []},
270 )
271 entry["count"] += 1
272 entry["total_duration_ms"] += _span_duration_ms(span)
273 if (location := _source_location(attributes)) and (
274 location not in entry["sources"]
275 ):
276 entry["sources"].append(location)
277
278 for event in span.events:
279 if event.name == "exception":
280 event_attributes = event.attributes or {}
281 exceptions.append(
282 {
283 "span": span.name,
284 "error_type": _text(
285 event_attributes.get(EXCEPTION_TYPE)
286 or attributes.get(ERROR_TYPE)
287 ),
288 "message": _text(event_attributes.get(EXCEPTION_MESSAGE)),
289 "stacktrace": _text(event_attributes.get(EXCEPTION_STACKTRACE)),
290 }
291 )
292
293 # Slowest first — the list answers "where did the time go", and a repeat
294 # count is visible on its own row for anyone asking the other question.
295 queries = sorted(
296 queries_by_sql.values(),
297 key=lambda q: (-q["total_duration_ms"], -q["count"]),
298 )
299 query_count = sum(query["count"] for query in queries)
300 for query in queries:
301 query["total_duration_ms"] = round(query["total_duration_ms"], 2)
302
303 # Trace wall-clock duration: first span start to last span end. Spans
304 # arrive sorted by start time, so the earliest is spans[0]; end times can
305 # still be out of order, so those do need a scan.
306 trace_start = spans[0].start_time
307 trace_end = max((s.end_time for s in spans if s.end_time is not None), default=None)
308 if trace_start is not None and trace_end is not None:
309 duration_ms = round((trace_end - trace_start) / 1_000_000, 2)
310 else:
311 duration_ms = 0.0
312
313 # The trace's entry span — the one nothing else in the trace started.
314 root = next((span for span in spans if span.parent is None), None)
315 request_id = (root.attributes or {}).get(_REQUEST_ID_ATTRIBUTE) if root else None
316
317 return {
318 "name": _trace_name(root=root, spans=spans),
319 "request_id": _text(request_id),
320 "analysis": {
321 "duration_ms": duration_ms,
322 "span_count": len(spans),
323 "query_count": query_count,
324 "transaction_count": transaction_count,
325 "exceptions": exceptions,
326 "queries": queries,
327 },
328 "spans": [_span_dict(s, trace_start) for s in spans],
329 }
330
331
332def capture_available() -> bool:
333 """Whether `capture_trace_spans` can run.
334
335 Needs a global tracer provider `capture_trace_spans` knows how to mutate
336 — the SDK's own, or the proxy it can replace. A third-party provider is
337 left alone rather than crashed into.
338 """
339 from opentelemetry.sdk.trace import TracerProvider
340
341 provider = trace.get_tracer_provider()
342 return isinstance(provider, TracerProvider | trace.ProxyTracerProvider)
343
344
345@contextmanager
346def capture_trace_spans() -> Generator[InMemorySpanExporter]:
347 """Capture every span emitted within the block, in isolation.
348
349 For the duration of the block the active tracer provider records every
350 span (its sampler is forced on) and delivers spans *only* to an in-memory
351 exporter. Span processors an installed package configured —
352 `plain.connect`'s OTLP exporter — are detached, so a captured request
353 is neither persisted nor shipped anywhere. The sampler and processors
354 are restored on exit.
355
356 This is not `plain.testing.capture_spans`, which refuses to run when
357 something other than plain.testing installed the process's tracer provider.
358 A test process can promise that; `plain request` runs in the app as it
359 is configured for development, where `plain.connect` has usually
360 installed its provider already. So this one borrows that provider for
361 the block and hands it back. When nothing has installed one, the two
362 share plain.testing's, so either can run first.
363
364 Guard calls with `capture_available()`. For one-shot, single-threaded
365 callers such as CLI commands; it mutates process-global tracing state.
366 """
367 # Imported here rather than at the top: plain.dev loads this module to
368 # register the command every time `plain` starts, and the SDK is only
369 # worth its import time when a request is actually made.
370 from opentelemetry.sdk.trace import (
371 SynchronousMultiSpanProcessor,
372 TracerProvider,
373 sampling,
374 )
375 from opentelemetry.sdk.trace.export import SimpleSpanProcessor
376 from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
377 InMemorySpanExporter,
378 )
379 from plain.testing.otel import tracer_provider_for_capturing
380
381 exporter = InMemorySpanExporter()
382
383 if isinstance(trace.get_tracer_provider(), trace.ProxyTracerProvider):
384 # Nothing configured a provider, so one is installed, and the still
385 # unresolved proxy tracers bind to it. `set_tracer_provider` is
386 # one-shot: this provider stays for the life of the process. It is
387 # plain.testing's provider for capturing and not one of our own, so
388 # that a `capture_spans()` later in the same process finds a
389 # provider it knows and adds to it. A test that runs this command
390 # and then captures spans is that process.
391 tracer_provider_for_capturing()
392
393 # From here a real provider exists either way — capture by mutating it.
394 provider = cast(TracerProvider, trace.get_tracer_provider())
395
396 # Instrumentation tracers baked in this provider's sampler and its
397 # composite span processor by reference, so isolate by mutating those
398 # objects in place: force the sampler to record every span, and swap the
399 # composite's processor list for one holding only our exporter.
400 sampler = provider.sampler
401 composite = cast(SynchronousMultiSpanProcessor, provider._active_span_processor)
402
403 had_sampler_attr = "should_sample" in sampler.__dict__
404 original_should_sample = sampler.should_sample
405 original_processors = composite._span_processors
406
407 sampler.should_sample = sampling.ALWAYS_ON.should_sample # ty: ignore[invalid-assignment]
408 composite._span_processors = (SimpleSpanProcessor(exporter),)
409 try:
410 yield exporter
411 finally:
412 composite._span_processors = original_processors
413 if had_sampler_attr:
414 sampler.should_sample = original_should_sample # ty: ignore[invalid-assignment]
415 else:
416 del sampler.should_sample