v0.166.0
  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