Skip to content

feat: emit partial (pending) spans at span start - #37

Merged
PabloPardoGarcia merged 2 commits into
mainfrom
pablo/gla2-195-pending-spans
Jul 30, 2026
Merged

feat: emit partial (pending) spans at span start#37
PabloPardoGarcia merged 2 commits into
mainfrom
pablo/gla2-195-pending-spans

Conversation

@PabloPardoGarcia

Copy link
Copy Markdown
Member

Implements GLA2-195 (Partial spans project): the SDK half of the pending-spans design.

What

With init(partial_spans=True) (or GLASSFLOW_PARTIAL_SPANS; default OFF until the backend's Finished-column storage ships), every sampled span exports twice:

  1. At start: a content-free snapshot — same trace_id/span_id/parent/name/start timestamp as the final span (the identity ClickHouse keys replacement on), end_time == start_time, marked glassflow.span.pending = true, carrying only identity attributes (kind, operation, model/provider via the gen_ai.request.* prefix).
  2. At end: the normal final span, unchanged.

Flag off (the default): behavior is byte-identical to today — the processor isn't even registered.

Design points

  • Nothing bespoke on the wire path: PendingSpanProcessor.on_start hands the snapshot to the provider's existing BatchSpanProcessor — shared exporter, batching, retries, masking. on_start stays an in-memory enqueue, so the never-block guarantee holds.
  • Content exclusion is an allowlist, not a blocklist: only known identity/taxonomy keys ride a pending, so third-party instrumentors' content families are excluded structurally. A test pins input.value set at creation never reaching the snapshot.
  • Marker namespace: glassflow.span.pending bends the convention-native rule knowingly — OTel has no pending-span mechanism (spec #3732/#4646, semconv #2133 all open, none planned; Logfire's logfire.span_type is equally vendor-namespaced). Rationale lives in semconv.py per the ticket's AC.
  • Supporting change: the SDK's span/generation helpers now attach kind/operation/model/provider at span creation (previously set immediately after) — snapshots are built at on_start, so late-set attributes are invisible to them. Final spans are unchanged; a test asserts the at-start visibility.
  • The debounce seam is load-bearing: _emit is where a future "only emit if still open after N seconds" timer wraps (cancel via the processor's already-received on_end), cutting pending volume for fast spans without any wire-format change. Follow-up ticket covers it.

Tests

TDD: 9 new tests written first and watched fail — identity mirroring (ids/name/start), zero duration, parent linkage, allowlist vs content, flag-off byte-identical, sampled-out and disabled produce nothing, env-var resolution, at-creation attribute visibility. Full suite 156 passed; ruff + format + mypy --strict clean.

Opt-in (init(partial_spans=True) / GLASSFLOW_PARTIAL_SPANS, default
OFF until the backend's unfinished-spans storage ships): every sampled
span additionally exports a content-free snapshot at START — same
trace/span/parent ids, name, and start timestamp as the final span,
zero duration, marked glassflow.span.pending=true. The backend stores
it as an unfinished row the real span replaces at end; a snapshot that
is never replaced is the durable record of what a crashed agent was
doing.

Mechanics: PendingSpanProcessor's on_start builds a ReadableSpan
snapshot and delegates it to the provider's existing BatchSpanProcessor
(shared exporter/batching/retries/masking; on_start stays an in-memory
enqueue — the never-block guarantee holds). Attributes are filtered by
an identity ALLOWLIST (kind, operation, provider, tool name, plus the
gen_ai.request.* prefix) so content can never ride a pending, whatever
instrumentation set it. Sampled-out spans and disabled mode produce no
snapshots.

The marker key knowingly bends the convention-native rule: OTel has no
pending-span mechanism to align with (spec #3732/#4646, semconv #2133
open, none planned); rationale recorded in semconv.py.

Supporting change: the SDK's own span/generation helpers now attach
identity attributes at span CREATION (kind_attributes/_creation_
attributes) instead of only immediately after — on_start-built
snapshots would otherwise see empty attributes. Final spans are
byte-identical (the same values were previously set post-creation).

The _emit seam is the future debounce point (only emit if still open
after N seconds — the network-volume escape valve for fast spans);
designed for, deliberately not built (follow-up ticket).
@PabloPardoGarcia
PabloPardoGarcia merged commit e39e555 into main Jul 30, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant