feat: migrate to Sentry span streaming and sample noise at request start - #60
Conversation
Closes #52. Drops the `traceLifecycle: "static"` bridge and the `beforeSendTransaction` filter it kept alive. With streamed spans nothing can be dropped after it starts, so every noise rule becomes a rate in a `tracesSampler`: 0 for untracked scanner routes, tracked sub-paths with no MCP method, and the two handshake notifications; `HEARTBEAT_SPAN_KEEP_RATE` for ping, server/discover, tools/list and initialize; 1 otherwise. MCP child segments inherit the parent's decision so root and child are kept together. The sampler only sees headers, not the body. Legacy clients that omit `Mcp-Method` get the header synthesized from a bounded read of a small JSON POST body before the request enters `withSentry`; the MCP transport ignores the header, so behaviour is unchanged. `beforeSendSpan` now edits the streamed span shape: strips request attributes and removes `user.*` from anonymous `/mcp` segments. Co-authored-by: Claude <claude@anthropic.com>
`beforeSendSpan` only kept `user.*` when `user.email` was a string, so a service token (username, no email) lost its attribution on spans while the error path still treated it as identified. Both paths now share `isIdentifiedUser`, and the span rule lives in `anonymizeSpanWithoutIdentity` next to its event twin so the two cannot drift again. Co-authored-by: Claude <claude@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 28c4150. Configure here.
| * `app.server.response` still counts 100% of requests. | ||
| */ | ||
| export function rootSampleRate(input: RootSpanSampleInput): number { | ||
| if (typeof input.parentSampled === "boolean") return Number(input.parentSampled); |
There was a problem hiding this comment.
Incoming traces skip sampling rules
Medium Severity
rootSampleRate returns immediately from parentSampled, so a sentry-trace header skips every local rate. An unsampled parent drops tools/call traces; a sampled one keeps scanner routes and handshake noise. Sentry sets parentSampled from incoming trace headers on the HTTP root, not only for in-process MCP children.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 28c4150. Configure here.
| !Number.isSafeInteger(contentLength) || | ||
| contentLength <= 0 || | ||
| contentLength > MAX_INSPECTED_MCP_BODY_BYTES | ||
| ) { |
There was a problem hiding this comment.
Header synthesis loses healthcheck kind
Medium Severity
withMcpMethodHeader always writes Mcp-Method for known methods, so inspectMcpRequest takes the header path and calls classifyMcpMethod. That never reads clientInfo.name, so healthcheck initialize is recorded as control on app.mcp.request.kind instead of heartbeat.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 28c4150. Configure here.


Closes #52. Supersedes #59, which GitHub closed when the branch of #58 (now merged) was deleted. Review history and the fixed Bugbot finding are on #59. Intended for release as 0.9.0 (minor): the span shape Sentry receives changes.
What changes
traceLifecycle: "static"bridge and thebeforeSendTransactionfilter it kept alive. That option is removed in a future SDK major, so this unblocks the v12 bump.tracesSampler(rootSampleRateinsrc/telemetry.ts). Streamed spans cannot be dropped once started, so every old drop rule is now a rate:/.env,/wp-admin/*,/): 0/mcp/actuator/heapdump): 0notifications/initialized,notifications/roots/list_changed: 0ping,server/discover,tools/list,initialize:HEARTBEAT_SPAN_KEEP_RATE(1%)parentSampled, so thehttp.serverroot andmcp.serverchild are kept or dropped together.Mcp-Methodfor legacy clients. The sampler sees headers only. For a POST to a tracked route with a JSON body up to 64 KB and noMcp-Methodheader,withMcpMethodHeaderreads a clone of the body and sets the header to the method name when it is on the known-method allow-list. The MCP transport ignores this header, so protocol behaviour is unchanged. Unknown method names never become a header value.beforeSendSpanon the streamed shape: strips request attributes and removesuser.*from/mcpsegments that carry no email, keeping the anonymous posture fromTELEMETRY.md.app.server.responsestill counts 100% of requests, so dashboards are unaffected.Why
initializeis sampled as a wholeThe old filter singled out the uptime monitor by
clientInfo.namein the body. The sampler cannot read the body, and a real session's signal is in its tool calls, which stay at 100%, so everyinitializenow takes the heartbeat rate.TELEMETRY.mddocuments this.Verification
pnpm build,pnpm typecheck,pnpm test: 227 passed.rootSampleRateunit cases; worker test asserting streamed envelope items (is_segment,sentry.op, notype: transaction), nouser.*on anonymous/mcpspans, and heartbeat sampling end to end (modern ping with header and legacy ping without header produce no span at a stubbedMath.randomof 0.5; a legacytools/callproduces a segment taggedtools/call).After deploy, check in Sentry
initialize/pingspans at roughly 1% of the metric counthttp.serverandmcp.serversegmentsuser.*attributes on/mcpspansCreated with Claude Code