feat(components): an artifact that breaks stops breaking in silence (… - #266
Conversation
…product#4081)
An artifact built from leadbay_artifact_kit runs in a chat-hosted page whose
only channel out is window.cowork.callMcpTool. The runtime reported nothing, so
every way an artifact could break died in the browser: a dropdown that
populated empty, a button that did nothing when clicked, a call that timed out
or came back as a failure envelope, text that would not parse.
The runtime now reports six failure points itself, through a new
leadbay_artifact_event ingest tool, and the MCP splits them the way it already
splits its own failures. The dividing line is whether something threw:
bridge_unavailable / call_timeout / call_failed / parse_failed -> Sentry,
source:"artifact", fingerprinted (artifact, tool, code) because the error
crossed the bridge as JSON and has no stack to group by
options_empty / action_blocked / result_rejected -> PostHog,
"mcp artifact event" — nothing threw, so Sentry would never see them, and
they are what a user experiences as "the artifact is broken"
Provenance rides separately: a new `_origin` meta-field, extracted alongside
`_triggered_by` and stripped before execute(). The runtime stamps "artifact";
an agent call omits it and records as "agent". It is emitted as `origin` on the
tool-call events that already exist, so artifact traffic separates from agent
traffic on current dashboards rather than needing a parallel event family. It
is deliberately not folded into `_triggered_by` — that field is the auditable
record of what the user said, and a provenance label would destroy it.
Three constraints the spec set, and how each is met:
Not leadbay_report_friction. That tool is consent-gated, carries the user's
own words, and must never fire unprompted. This path is separate end to end,
and the description tells the agent not to call it.
The leadbay_set_telemetry opt-out needed no new plumbing: the event is an
ordinary tool call, so the existing suppressTelemetry predicate already
silences it. Asserted by test.
No user text. Only bounded enums and error codes travel. LbError.message and
envelope message can carry API payload text the user never approved for
sending (product#3943), so they are dropped at the source.
Three safety properties in the runtime. report() uses the raw host bridge,
bypassing call()/normalize()/withTimeout — routed through call(), a telemetry
timeout would emit a timeout event that would itself time out. Emits are
fire-and-forget, so telemetry never surfaces in a view-model's .error. And they
are deduped on (kind, surface, tool, code) and capped at 40 per page, so a poll
failing every 3s emits once.
Note for future readers: withSurface restores its marker when the loader's
promise is CREATED, not when it settles, which is why call() snapshots
runningSurface at entry rather than reading it in its catch. An earlier version
read it in the catch and misattributed every resource/list failure as
surface:"call".
48 new tests across three new files. typecheck green on all five packages;
components 349/349 and core 1792/1792 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Couldn't auto-sync with main — this PR has a merge conflict outside the version files. main shipped |
…untime-failures Conflict was WORKFLOWS.md: main added row 61 (product#4178 — a launched job's result arrives in the same answer) while this branch had taken 61 for the artifact-telemetry story. Kept main's row at 61 and renumbered the artifact-telemetry row to 62; verified against MERGE_HEAD that the file gains exactly one line and loses none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Well-structured feature with strong test coverage for the intended paths (dedupe, cap, no-message, fingerprinting, opt-out via env var, _origin stripping). Two issues found on close reading:
- Privacy gap on the hosted server — the new
captureArtifactEventmethod isn't wired intobindTelemetryIdentity(packages/mcp/src/http-server.ts), so it skips the per-userleadbay_set_telemetryopt-out check that every other analytics capture method gets on the multi-tenant HTTP transports. The PR's tests only cover the stdio/env-var suppression path, so this wasn't caught. - Telemetry misattribution race —
lastToolinpackages/components/src/runtime.tsis a shared global read after anawait, unlikerunningSurfacewhich is deliberately snapshotted before the first await to avoid exactly this. Concurrent field/list loads can causeoptions_emptyevents to report the wrongtool.
Neither is CLAUDE.md-guideline-specific (no Tool.ui/generated-file/budget issues found); both are correctness/privacy bugs in the new code itself. Details inline.
…right tool Two defects found in review of product#4081, both in the new code. 1. Privacy gap on the hosted server. bindTelemetryIdentity wraps each PASSIVE analytics capture in the isSuppressed gate and threads the per-request identity; anything not named there falls through the `...base` spread. captureArtifactEvent was missing, so on the multi-tenant HTTP/SSE transports a user who ran `leadbay_set_telemetry disable` still emitted artifact outcome events — and those events carried no identity, so they landed on the wrong distinctId. The PR claimed the opt-out was honored "with no new plumbing"; that was true of stdio only, and the tests exercised buildServer with a bare initTelemetry handle, which never passes through bindTelemetryIdentity at all. Artifact events belong on the suppressed side of the line: they are automatic diagnostics the user never asked to send. The report_friction and send_feedback carve-outs exist for user-initiated deliveries of the user's own words, which this is the opposite of — the new audit locks that distinction in so nobody "fixes" a dropped event by copying the exemption. 2. Telemetry misattribution race. `lastTool` was a module-level global written at call() entry and read after the load's await, so two pickers mounting together — an ordinary artifact layout — cross-attributed: the first to resolve empty reported the other's tool name. This is the same race runningSurface already avoids by snapshotting synchronously at call() entry; the discipline simply had not been applied to the tool name. The tool now goes into a per-load slot, written synchronously while the loader is on the stack. Only the first call a loader makes synchronously is captured, so a multi-call loader reports its first tool or none — never another control's. `tool` is optional on LbEvent precisely because absent is an honest answer where a stale global name was a lie. Both fixes are covered by tests that were confirmed to FAIL without them: the concurrency file fails 5/5 with a shared global restored (reproducing the reviewer's leadbay_pull_leads case), and the hosted audit fails 6/7 with the bindTelemetryIdentity line removed, including the `id: undefined` that proves the identity half of the bug. components 354/354, core 1856/1856, mcp 1016 passing with only the 8 pre-existing Windows-specific failures (release scripts, POSIX modes, symlinks) unchanged. typecheck green on all five packages; runtime drift guard clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Both issues from the previous CHANGES_REQUESTED review (5266170056) are resolved in this diff:
- Hosted opt-out gap —
captureArtifactEventis now wired intobindTelemetryIdentity(packages/mcp/src/http-server.ts), gated byisSuppressedand threaded withidentity, matching every other passive capture. New regression test:packages/mcp/test/audit/artifact-event-hosted-optout.test.ts. lastToolcross-attribution race — replaced with a per-loadLoadSlotsnapshotted synchronously atcall()entry (packages/components/src/runtime.ts), mirroring the existingrunningSurfacediscipline. New regression test:packages/components/test/runtime-telemetry-concurrency.test.ts, which exercises interleaved concurrent loads.
No new issues found in this diff. (Note: I was unable to programmatically dismiss the prior CHANGES_REQUESTED review — tooling limitation, not a signal of unresolved concerns.)
Follows up on #4025. Closes product#4081.
The problem
An artifact built from
leadbay_artifact_kitruns in a chat-hosted page whoseonly channel out is
window.cowork.callMcpTool. Until now the runtime reportednothing, so every way an artifact could break died in the browser: a dropdown
that populated empty, a button that did nothing when clicked, a call that timed
out or came back as a failure envelope, text that would not parse. None of it
reached us. Artifact-originated tool calls were also indistinguishable from
agent-originated ones on every dashboard.
What this does
The
@leadbay/componentsruntime now reports its own failures, automatically,through a new
leadbay_artifact_eventingest tool. The MCP splits them the wayit already splits its own failures:
bridge_unavailablewindow.coworkcall_timeoutcall_failedisErrorparse_failedcontent[0].textwould not parseoptions_emptyaction_blockedresult_rejected{error:true}/ failedcheckResultThe dividing line is whether something threw. The three outcome kinds are cases
where the call succeeded and the UI is simply wrong — Sentry would never see
them, and they are what a user experiences as "the artifact is broken."
Exceptions go to Sentry under a new
ExceptionCtx.sourcevalue,"artifact",fingerprinted
["mcp", "artifact", tool, code]. The error was thrown in thebrowser and crossed the bridge as JSON, so there is no stack to forward and
default fingerprinting would collapse every artifact failure into one issue.
These are reports, not stack traces.
Outcomes go to a new
mcp artifact eventPostHog event carrying onlykind,surface,kit_version,tool,code.Provenance
A new
_originmeta-field, extracted alongside_triggered_byand strippedbefore
execute(). The runtime stamps"artifact"on every call it makes;an agent-issued call omits it and records as
"agent". It is emitted asoriginon the existingmcp tool called/mcp composite callevents, soartifact traffic separates from agent traffic on the dashboards we already
have rather than needing a parallel event family.
_originis deliberately NOT folded into_triggered_by: that field is theauditable record of what the user said, and overwriting it with a provenance
label would destroy the trace it exists to keep. An unrecognized
_originvalue falls back to
"agent"rather than passing through.Constraints from the spec
leadbay_report_friction. That tool is consent-gated, carries theuser's own words, and must never fire unprompted. This one is automatic
diagnostics and is a separate path end to end. The tool description tells the
agent explicitly not to call it, and points user-raised problems at
report_frictioninstead.leadbay_set_telemetryopt-out is honored with no new plumbing.The event is an ordinary tool call, so the existing
suppressTelemetrypredicate already silences it for an opted-out user. Asserted by test.
message.
LbError.messageand envelopemessagecan contain API payloadtext the user never approved for sending (the product#3943 line), so they are
dropped at the source. Envelope
codeis a bounded enum and is kept.Three safety properties in the runtime
report()uses the raw host bridge, bypassingcall(),normalize()andwithTimeout(). A telemetry call routed throughcall()would, on timeout, emit a timeout event that would itself time out.in a view-model's
.errorand never blocks a user action.(kind, surface, tool, code)and capped at 40 eventsper page, so a poll failing every 3s emits once, not thousands of times.
Notes for review
withSurfacerestores its ambient marker synchronously, when the loader'spromise is created, not when it settles. That is why
call()snapshotsrunningSurfaceat entry before its first await rather than reading it inits catch — an earlier version read it in the catch and misattributed every
resource/list failure as
surface:"call". Two tests cover this.leadbay_artifact_eventlives intools/(granular-shaped, static relay) soit stays out of
COMPOSITE_FILE_TOOL_NAMESand carries no_triggered_bymandate — an artifact button click has no fresh user utterance to quote. It
is registered in
compositeReadToolsso it is always exposed, alongsideleadbay_artifact_kit; a kit without its sink is a blind artifact.execute(), not left to theinputSchema: the MCP SDK does not enforce enums before dispatch (same
reasoning as
leadbay_set_telemetry'sBAD_ACTIONguard), and an unknownkind would otherwise become an unbounded Sentry tag / PostHog property.
@leadbay/componentsbumped to0.6.0.kit_versionrides on every eventso a regression pins to a kit release.
should never be reached from user language, so it is correctly absent from
TOOLS_WITH_ROUTING.WORKFLOWS.mdrow 59 added.Testing
All new files, per repo convention. 48 new tests:
packages/components/test/runtime-telemetry.test.ts(19) — each failurepoint emits, classification is right, no message text escapes, no feedback
loop, dedupe and cap hold,
setTelemetry(false)silences.packages/core/test/unit/tools/artifact-event.test.ts(11) — forwards theevent, makes no API call, rejects unknown kind/surface, bounds field lengths.
packages/mcp/test/artifact-event-telemetry.test.ts(18) — the Sentry-vs-PostHog routing decision, fingerprint separation, the opt-out, and
_origin(including that it is stripped before
execute()and that a spoofed valuefalls back to
agent).pnpm -r typecheckgreen on all five packages. Components 349/349 and core1792/1792 green. The components drift guard (
build --check) passes.Pre-existing failures, unrelated to this PR — verified reproducing
identically on a clean stash of this branch, all Windows-environment-specific:
6 in
test/audit/{registry-publish-retry-window,release-outcome-alarm}.test.ts,2 in
test/unit/update-state.test.ts(POSIX file modes and symlink creation),and 1 in promptforge's
snippet-references.test.ts(backslash pathseparators). Untouched here; worth a separate look on a Windows dev setup.
🤖 Generated with Claude Code
Close leadbay/product#4081