Skip to content

test(qa): cover the Copilot runtime and fix the six telemetry bugs it found - #233

Open
bertschneider wants to merge 8 commits into
mainfrom
qa-copilot
Open

test(qa): cover the Copilot runtime and fix the six telemetry bugs it found#233
bertschneider wants to merge 8 commits into
mainfrom
qa-copilot

Conversation

@bertschneider

@bertschneider bertschneider commented Aug 28, 2026

Copy link
Copy Markdown
Member

Fixes SIG-323

The QA suite covered claude and codex. This adds copilot, and fixes six product defects that running it turned up. Each defect gets its own commit, and the suite comes last.

What makes this runtime different

Copilot's hooks carry no numbers, and none of the tool events the plugin consumes. Tokens, model, tool spans and sub-agent spans all come out of Copilot's own OpenTelemetry file, which the plugin reads at each turn boundary.

So the evidence works differently here. The hooks column claims one chat span per turn and says nothing about the rest. The tool and token comparison runs against a file that is also the plugin's input, so agreement there shows the plugin copied its input faithfully and says nothing about whether Copilot measured the session correctly. Each spec states which of its assertions rest on the hook record, the one input that stays independent.

The fixes

Commit Defect
stopReason Copied raw onto every chat span. The deny list is spelled in Claude's snake_case, so Copilot's camelCase twin passed through it.
cost github.copilot.cost exported at all. AI credits land one attribute away from the money figure Dash0 derives at ingest, and nothing on the span says which is which.
cursor A resumed turn double-counted the previous one. The OTel cursor lived in the session directory that SessionEnd deletes, and a Copilot session id outlives its session.
invoke_agent A sub-agent's identity rode on a custom key no backend feature reads, while Copilot's own OTel described that sub-agent properly and the plugin discarded it. The layer is re-emitted now, so the tree matches Claude and Codex.
sub-agent session A sub-agent minted a spurious conversation carrying one token-less span. The call_ prefix guard holds in prompt mode only; interactive sessions give sub-agents a plain UUID.
traceparent Exported on every interactive chat span. It is a propagation header, and it named a different trace from the one its span belonged to.

A delegating turn now produces the same tree the other runtimes produce:

chat
└── execute_tool task
    └── invoke_agent task     gen_ai.agent.name, gen_ai.agent.id
        └── execute_tool bash

Four of the six came from qa-attrs.py and the two-turn probe. The other two appear only in interactive sessions, which the driver cannot run, so I found them by driving Copilot by hand. The attribute spec now records that limit, so a clean result there reads as a statement about prompt mode.

What the QA arm is

qa/tools/qa-session-copilot.sh provisions the shipped package into a throwaway home through the real marketplace path, enables native OTel the way the dash0-configure launch function does, registers the QA recorder beside the plugin's hooks, and runs copilot -p --output-format json. qa/tools/qa-otel.py re-reads the native-OTel file as the second channel. With those come a copilot branch in qa-compare.py, six specs across four topics, five learnings, and five preflight checks.

Also here

Session directories left by killed runs are collected now. A session that ends deletes its own; one that is killed delivers no sessionEnd, and nothing was collecting those. My machine held 20 of them, the oldest six weeks old, each with an events.jsonl holding that session's prompts.

Verification

Every commit builds, vets, and passes the unit and tagged TestE2ECopilot suites on its own. make lint and make test (race plus shell) are green. The live canary TestE2EFullFlowWithCopilot passes against a real session with a PAT.

Every fix is pinned by a test that fails when the fix is reverted, which I checked by reverting each one. Three review rounds ran against the branch. Several tests turned out unable to fail for the reason they claimed, and those were repaired.

Known limits

  • The QA driver runs prompt mode only. Interactive-mode test infrastructure is being built separately.
  • With native OTel disabled and a lost sessionStart marker, a real session is still suppressed. Such a session emits bare chat spans anyway, and a marker goes missing only when the sessionStart hook failed outright.
  • Claude and Codex leak session directories the same way on a kill, and neither validates a session id before the pipeline sees it. Out of scope here.

Every Copilot chat span carried stopReason ("end_turn") as a raw,
unnamespaced attribute. The span's own status already says whether the
turn ended well, so the key added nothing and looked like a leak.

eventAttributes copies every payload field it does not recognize, and
attrSkipKeys is spelled in the field names Claude Code and Codex use.
Copilot's payloads are camelCase, so stop_hook_active on the same
agentStop payload was already denied while its camelCase sibling walked
straight through. Deny it by its own spelling.

Found by qa/tools/qa-attrs.py on the first Copilot QA run, which is the
same check that caught the equivalent Codex leak (turn_id).

The e2e guard is the stronger one: it feeds a real agentStop payload
through the built binary and asserts no emitted span carries the key.
Every Copilot chat span carried github.copilot.cost, Copilot's own
accounting in AI credits, copied through from the native-OTel chat spans
the turn is summed from.

It collides with the figure next to it. Dash0 derives
dash0.gen_ai.usage.cost from tokens at ingest, in money. Shipping both
puts two attributes ending in "cost" on one span, in two units, one of
them a vendor's internal accounting unit, and nothing on the span says
which is which. A dashboard summing them is wrong without ever looking
wrong.

Usage.Cost and attrFloat go with it. A field whose only purpose is to be
discarded at the last step is the shape the export grew out of, and
leaving it invites the same commit back.

No deny-list entry: the key reaches the plugin only through the
native-OTel file, never through a hook payload, so nothing can copy it
onto a span by accident. The guard is the e2e test, whose staged native
chat span carries the attribute and whose emitted spans must not.

The credits figure stays recoverable from the native-OTel file, and
qa/tools/qa-otel.py still reports it as a channel figure.
@bertschneider
bertschneider requested review from a team as code owners August 28, 2026 14:16
@dash0-dev

dash0-dev Bot commented Aug 28, 2026

Copy link
Copy Markdown

Darkplane auto-approval is enabled for this repository (mode: Dry run).

An evaluation will run once this pull request's CI checks have completed — no action needed.

@bertschneider bertschneider changed the title Copilot QA coverage, and the six defects it found test(qa): cover the Copilot runtime and fix the six telemetry bugs it found Aug 28, 2026
Comment thread internal/source/copilot/otelfile.go
@dash0-dev

dash0-dev Bot commented Aug 28, 2026

Copy link
Copy Markdown

Darkplane did not approve this pull request. Score 51.73 of 100, threshold 75. Blast radius 58, Evidence quality 82, Change footprint 15.

Darkplane would not approve this pull request. It scored 51.73 of 100, and 75 was required.

  • 🟡 Blast radius scored 58.
    The change touches core runtime logic — cmd/copilot-on-event/main.go, internal/source/copilot/otelfile.go, new session.go, internal/pipeline/pipeline.go, internal/otlp/otlp.go — introducing new filesystem behavior (session-marker files, SweepOldSessionDirs, reserved-name path guards, os.RemoveAll calls gated on session-id validation) that changes on-disk state and directory sweeping semantics, which is not purely additive or trivially revertable if partially rolled out. It is scoped to the Copilot source path only (no changes to Claude/Codex runtimes), is covered by substantial new unit and e2e tests including explicit guards against destructive path traversal, but the breadth (13 source/test files plus 24 docs/QA files, ~4000 lines) and the introduction of new persistent state/cleanup logic on disk raise the blast radius above a minimal, single-file fix.
  • 🔴 Change footprint scored 15.
    This is a substantial, hand-crafted change to load-bearing telemetry logic: new sub-agent detection/emission (session.go, otelfile.go ancestor resolution rewrite), a new session-marker/reserved-id security mechanism guarding os.RemoveAll in cmd/copilot-on-event/main.go, cursor-location fixes, and attribute-leak fixes (traceparent, stopReason, github.copilot.cost). These are non-mechanical, judgment-heavy edits to core span-building and path-safety code, not boilerplate. Extensive new tests and QA tooling accompany it, but the core diff itself is novel bug-fix logic rather than a routine, well-understood pattern.
The other criterion
  • 🟢 Evidence quality scored 82.
    All CI checks pass (lint, build-and-test, e2e, install-config-contract, consistency-checks, Cursor Bugbot, CLA, Aikido security), and the PR adds extensive new unit tests (otelfile_test.go-equivalents in copilot_test.go, session_test.go, otlp_test.go, pipeline_test.go) plus a large QA spec suite. Cursor Bugbot flagged four real issues across iterations, and the author responded with concrete fixes and dedicated regression tests for each (verified by reverting fixes to show failing tests), showing genuine iterative validation rather than unexamined self-report.

An AI evaluator produced these scores from the diff, the repository history, and the pull request text. The pull request text is author-controlled, so treat the scores as signals rather than proof. Reviewed for commit 608e359.

Was this decision right? Give feedback

Comment thread internal/source/copilot/otelfile.go Outdated
A resumed session double-counted the launch before it. Measured on a
two-turn QA run: turn 2's chat span carried 59068 input tokens for a turn
of 29655, having summed turn 1 a second time, and turn 1's tool span was
emitted again under turn 2's trace.

The cursor — the id of the last native span consumed — lived in the
per-session directory that pipeline.Process removes on SessionEnd. But a
Copilot session id outlives its session: `copilot --resume` comes back
under the same id, and the second launch found no cursor and re-read the
file from the start. The cursor is precisely the state that has to
survive the end of a launch.

It now lives beside the native-OTel files, keyed by conversation, and the
stale-file sweep skips it. "Stale" there means "untouched for a day",
which an idle conversation also is, while the shared native-OTel file
stays fresh under other sessions' writes. Sweeping the cursor would send
the next ReadTurn back to the top of that file and re-count everything
it already reported, which is the defect this commit fixes.

Only reachable when both launches write to one native-OTel file. The
launch function the dash0-configure skill installs gives each launch its
own file and deletes it at exit, which made the lost cursor harmless; a
fixed COPILOT_OTEL_FILE_EXPORTER_PATH — the documented alternative to
that function — does not.

TestReadTurn_cursorSurvivesSessionEnd asserts the cursor's location
rather than simulating the wipe: there is no session directory left in
that path to wipe, which is the property that makes the wipe survivable.
The wipe itself is driven end to end by
TestE2ECopilotDefersTurnWhenTraceContextMissing.
A Copilot sub-agent's identity was carried on dash0.gen_ai.tool.task.name,
a custom key on the spawning task span. It reaches Dash0 and no backend
feature reads it, so the sub-agent was effectively anonymous.

The standard attributes were available all along and being thrown away.
Copilot's native OpenTelemetry describes the sub-agent with a proper
invoke_agent span carrying gen_ai.agent.name and gen_ai.agent.id, and the
plugin collapsed that layer, re-parented the inner tools onto the task
span, and invented a key for the part it had discarded.

The layer is now re-emitted, so Copilot produces the same shape as Claude
and Codex:

    chat → execute_tool task → invoke_agent task → execute_tool bash

Only the layers nothing is emitted for are collapsed: the native chat
spans, and the turn's root invoke_agent, which the pipeline's own chat
span already represents. Parenting resolves each span to its nearest
ancestor that is also emitted, which does both jobs at once.

gen_ai.agent.id is the spawning tool call's id, not the native
"builtin:<kind>". The native value is shared by every sub-agent of a kind,
so it is a type filter wearing an id's name; the call id is unique per
invocation and is also the session id Copilot gives that sub-agent's own
hooks, so the dropped hook session and the emitted span name the same
thing.

No usage on the agent span. Attribution stays flat — a sub-agent's chat
spans fold into the parent turn — so repeating those tokens here would
double any sum across the trace. The native span carries none either.

dash0.gen_ai.tool.task.name is gone. The instance label the model chose
is still in the task span's gen_ai.tool.call.arguments, where Copilot
puts it.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6bf0183. Configure here.

Comment thread qa/tools/qa-otel.py
Comment thread cmd/copilot-on-event/main.go
A delegating turn produced two conversations in Dash0: the real one, and a
second holding a single `chat ` span with no model and no usage — the
sub-agent's own hook session.

Normalize drops a sub-agent session on a `call_` prefix, and that only
holds in prompt mode. `copilot -p` names a sub-agent session
call_<toolCallId>; an interactive session gives it a plain UUID, the
prefix misses it, and the sub-agent's userPromptSubmitted/agentStop pair
mints a standalone, token-less conversation. Its native-OTel spans carry
the PARENT's conversation id, so there is nothing for it to report.

Suppress on what holds in both modes instead: a sub-agent session
receives no sessionStart. Each hook is its own short-lived process, so
"this session never had a sessionStart" is a claim about something that
happened elsewhere, and a file is the only way agentStop can learn it.

A missing marker is not on its own enough, though, so the native-OTel
file has to agree. Only sessionStart writes a marker, and that hook has
upstream ways to fail that say nothing about sub-agents: the bootstrap
downloads the binary inside it, under Copilot's 10s timeout, and every
fail-open path exits before the binary runs. The first session after a
version bump on a slow link is a real session with no marker, and
suppressing on the marker alone silenced all of it, permanently.

The file settles it. A sub-agent has no spans under its own conversation
id — its chat spans carry the parent's, which is the whole reason its
turn has nothing to report — so ReadTurn returns nil for a sub-agent and
a turn for a real session. One gap is left, the smaller one: with native
OTel off there is no file to ask, so a real session that also lost its
marker is still suppressed. Such a session emits only bare chat spans
anyway.

The marker lives beside the per-session directories, not in one, and
nothing ever deletes it.

Both of those were paid for. Inside a session directory, two things
removed it: SessionEnd handling, so a turn arriving afterwards — which
recreates the directory and a fresh trace context, and would otherwise
export normally — was read as a sub-agent's and dropped, which is the
last turn of every session, since agentStop and sessionEnd fire together
as separate processes and agentStop is the slower, scanning the
native-OTel file; and the sweep below, which cannot tell a killed session
from one idle for a day and so cost a live session its marker for good.
Moving it out fixed both, and reclaiming it on a timer instead
reintroduced exactly the same failure one level up: measured against the
built binary, an idle session's marker went to another session's sweep,
and every later turn of it was dropped and its state deleted, with no way
back because sessionStart never fires again.

So nothing reclaims a marker. It is an empty file named by a session id.
The suppression is fail-closed on missing state by construction — a lost
marker ends a session's telemetry silently, while a spurious export costs
one token-less conversation — so the state has to be permanent, and a
stale empty file is the smaller problem by a wide margin.

Read at agentStop rather than at userPromptSubmitted: Copilot fires
sessionStart and userPromptSubmitted in a nondeterministic order, so a
real session may not be marked yet when its first prompt arrives. It is
also written before the sweeps rather than after, since a concurrent
agentStop can be reading it and unbounded directory I/O in between only
widens that window.

The prefix guard stays as the cheap path: it drops a prompt-mode
sub-agent at its first event, before any scratch state is created.

Found by running an interactive session by hand — every test and QA
driver in this repo uses prompt mode, where the old guard works. The e2e
tests replay the interactive payload shape (UUID session id, traceparent,
no sessionStart) and the post-SessionEnd turn through the built binary,
and reverting either half fails them. Verified against a live interactive
session too.

The session id is validated before it is used to resolve a directory.
pipeline.Process substitutes a random id for one that is missing or
unsafe, but it does that inside itself, and this suppression removes a
directory before handing the event over. An agentStop with no sessionId
resolved SessionDir to the data root ITSELF and deleted it — the
bootstrap's cached binary and every concurrent session's state — while
"../victim" walked out of the root entirely. Both reproduced against the
built binary, exit 0, no diagnostic. A usable path segment is not enough
either: "started" and "bin" are both usable and both name a directory
this plugin owns. An id the guard will not vouch for means no marker, no
suppression and no sweep; the event takes the normal path.

Keeping the entrypoint's own paths off those names does not settle it.
Process joins the id onto the data root itself and its SessionEnd removes
what it joined, so a sessionEnd carrying "bin" deleted the bootstrap's
binary cache and one carrying "started" deleted every live session's
marker — both reproduced against the built binary. A reserved id is now
renamed in the payload before Process reads it, by a prefix rather than a
random id so every hook of the session lands on the same directory. The
sweep skips those names for the same reason, since an events.jsonl
written into one would otherwise date it as an ordinary stale session.
Copilot's ids are UUIDs, so this bounds what a malformed payload can do
rather than describing an observed failure.

Also collects the session directories killed runs leave behind. A session
that ends deletes its own; one that is killed — SIGKILL, a crash, a
closed terminal — delivers no sessionEnd, and nothing else was collecting
them. Measured on a developer's machine: 20 directories, the oldest six
weeks old. They are small, but each holds an events.jsonl carrying that
session's prompts, so it is user content sitting on disk indefinitely.

SweepOldSessionDirs runs on sessionStart beside SweepOldOtelFiles, which
has covered the native-OTel directory this way all along, and shares its
TTL. It dates a session by its events.jsonl rather than by the directory,
whose mtime only moves when an entry is added or removed and so stays
frozen at the first event — that would date a long-running session by
when it started and sweep it out from under itself. Only directories
carrying an events.jsonl are considered, which keeps the sweep off the
bootstrap's binary cache. Sweeping a live-but-idle session's directory
costs it a trace context, which its next prompt mints again; the marker
it would once have taken is no longer there.

The turn's own invoke_agent span is now recognised by not hanging under
an execute_tool call of this conversation, rather than by having no
parent id.
Copilot injects a traceparent into an interactive session's payloads, so
a turn whose trace continues one from outside would carry a parent id
naming a span the file does not hold, and an emptiness test would read
that root as a sub-agent, minting a span that duplicates the turn and
re-parenting the whole tool tree beneath it. Asking merely whether the
parent is some span of this conversation is looser than the rule and
admits the same duplication from inside one file, for a turn whose root
continues an earlier turn's trace.

The system-notification test grew a sessionStart: its fixture drove a
turn in a session that never started, which is now exactly the shape that
is dropped.

Claude and Codex leak session directories the same way on a kill; this
fixes the runtime the evidence came from.
…ends

Copilot continues its own trace into a hook by putting a W3C traceparent
on an interactive session's payloads. It is a propagation header, not a
span attribute, and eventAttributes copied it onto every chat span the
same way it copied stopReason: unnamespaced, undeclared, by the generic
copy-what-nobody-denied path.

On an emitted span it is worse than redundant. The span belongs to the
plugin's own trace, derived from the session id; the header names
Copilot's native trace and a span id that is not the span's parent.
Measured 2026-08-28: a chat span in trace 3b361e94… carrying
00-558ca38a…-adc5bef1061afc70-01, which resolves to a trace that exists
only in a local file and reaches no backend. It also landed on one span
of four, since the other three are synthesised and never saw the payload.

Correlating the plugin's spans with Copilot's native ones is worth doing
for anyone who exports both, but as a span link on every span of the
turn rather than whichever one happened to carry the header. That is its
own piece of work.

Prompt-mode payloads carry no traceparent, so neither the QA driver nor
any existing test could see this; it was found by reading the attributes
of a real interactive session's span. TestE2ECopilotPerTurnSpans now
drives the interactive payload shape through the built binary, and
reverting the deny fails it and the unit test.
FEATURE_MATRIX still described the behaviour this branch replaced: it
rated Copilot "Partial" on sub-agent spans and said their tool calls
re-parent under the spawning task span. A sub-agent now gets its own
invoke_agent span, so the tree matches the other runtimes and the rating
does too.

Also names the one case where gen_ai.agent.id is absent. An
execute_tool task encloses the invoke_agent it spawned and is therefore
written to the native-OTel file later, so a read falling between the two
flushes puts the agent in one turn and its spawning tool in the next —
and that tool call is where the id comes from. It is the late flush the
README already tolerates, but DEVELOPMENT.md and the README both stated
the id unconditionally.
Adds the third arm of the QA suite. `qa/tools/qa-session-copilot.sh`
provisions the shipped package into a throwaway home through the real
marketplace path — `copilot plugin marketplace add` then `plugin install`
— enables Copilot's native OpenTelemetry the way the dash0-configure
launch function does, registers the QA recorder alongside the plugin's
hooks, and runs `copilot -p --output-format json`. Nothing outside the
throwaway home is written, as on the Codex arm.

Copilot inverts the shape of the evidence, and the harness has to say so.
Its hooks carry no numbers and no tool events the plugin consumes: tokens,
cost, model, tool spans and sub-agent spans all come from Copilot's own
OpenTelemetry file, which qa/tools/qa-otel.py re-reads as the second
channel. That file is also the plugin's input, so agreement proves a
faithful copy and not a correct measurement — every spec here names which
of its assertions rest on the independent hook record and which do not.

qa-compare.py grows a copilot branch. Its `hooks` column claims a chat
span and prints `-` for the rest, because a None expectation is not zero
and comparing tool spans against 0 would report every correct span as a
surplus. Sub-agent `call_` hook sessions are counted apart from a reused
run id, and an absent OTel file reports nothing rather than zero so the
documented no-OTel degradation reads as a pass.

The recorder gains two concessions: Copilot's camelCase payloads carry no
event-name field, so it takes the name from argv as the plugin's own
bootstrap does, and they name the session `sessionId`. Both keep
record/index.jsonl one shape across all three runtimes.

Also here: five preflight checks, six specs across four topics, five
learnings, and the Settling note that Copilot's ingest lag is 25 seconds
rather than the 8 the other runtimes use — at 8 a two-turn run shows one
turn's spans and reads exactly like a product bug.

The first runs of this suite found four of the six defects fixed in the
commits before it — three from qa-attrs.py on the first probe, one from
the two-turn probe. The other two came from driving an interactive
session by hand, which is the one thing this driver cannot do: a
sub-agent's session id is a plain UUID there rather than call_-prefixed,
and only there does a payload carry a traceparent. The attribute spec and
its coverage map now say that a clean result is a statement about prompt
mode alone — naming what a check cannot see is worth as much as what it
can.

One learning is theirs as much as the suite's:
copilot-subagent-sessions-are-dropped-on-purpose first recorded a
sub-agent as running its own sessionStart and sessionEnd, which is the
opposite of the invariant the suppression rests on, and now records the
partial lifecycle that was measured.
lifecycle that was measured.
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