Skip to content

feat: migrate to Sentry span streaming and sample noise at request start - #60

Merged
sergical merged 2 commits into
mainfrom
feat/span-streaming
Sep 28, 2026
Merged

sergical merged 2 commits into
mainfrom
feat/span-streaming

Conversation

@sergical

Copy link
Copy Markdown
Member

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

  • Drop the traceLifecycle: "static" bridge and the beforeSendTransaction filter it kept alive. That option is removed in a future SDK major, so this unblocks the v12 bump.
  • Sample at request start with a tracesSampler (rootSampleRate in src/telemetry.ts). Streamed spans cannot be dropped once started, so every old drop rule is now a rate:
    • untracked scanner routes (/.env, /wp-admin/*, /): 0
    • tracked sub-paths with no MCP method (/mcp/actuator/heapdump): 0
    • notifications/initialized, notifications/roots/list_changed: 0
    • ping, server/discover, tools/list, initialize: HEARTBEAT_SPAN_KEEP_RATE (1%)
    • everything else: 1
    • MCP child segments inherit parentSampled, so the http.server root and mcp.server child are kept or dropped together.
  • Synthesize Mcp-Method for legacy clients. The sampler sees headers only. For a POST to a tracked route with a JSON body up to 64 KB and no Mcp-Method header, withMcpMethodHeader reads 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.
  • beforeSendSpan on the streamed shape: strips request attributes and removes user.* from /mcp segments that carry no email, keeping the anonymous posture from TELEMETRY.md.
  • app.server.response still counts 100% of requests, so dashboards are unaffected.

Why initialize is sampled as a whole

The old filter singled out the uptime monitor by clientInfo.name in the body. The sampler cannot read the body, and a real session's signal is in its tool calls, which stay at 100%, so every initialize now takes the heartbeat rate. TELEMETRY.md documents this.

Verification

  • pnpm build, pnpm typecheck, pnpm test: 227 passed.
  • New tests: rootSampleRate unit cases; worker test asserting streamed envelope items (is_segment, sentry.op, no type: transaction), no user.* on anonymous /mcp spans, and heartbeat sampling end to end (modern ping with header and legacy ping without header produce no span at a stubbed Math.random of 0.5; a legacy tools/call produces a segment tagged tools/call).

After deploy, check in Sentry

  • heartbeat initialize/ping spans at roughly 1% of the metric count
  • tool-call traces show both http.server and mcp.server segments
  • no user.* attributes on /mcp spans
  • no spans for scanner sub-paths

Created with Claude Code

sergical and others added 2 commits September 28, 2026 18:40
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>
@sergical
sergical merged commit 779d6ca into main Sep 28, 2026
16 checks passed
@sergical
sergical deleted the feat/span-streaming branch September 28, 2026 23:12

@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 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 28c4150. Configure here.

Comment thread src/telemetry.ts
* `app.server.response` still counts 100% of requests.
*/
export function rootSampleRate(input: RootSpanSampleInput): number {
if (typeof input.parentSampled === "boolean") return Number(input.parentSampled);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 28c4150. Configure here.

Comment thread src/worker.ts
!Number.isSafeInteger(contentLength) ||
contentLength <= 0 ||
contentLength > MAX_INSPECTED_MCP_BODY_BYTES
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 28c4150. Configure here.

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.

Migrate telemetry filtering to span streaming before Sentry SDK v12

1 participant