Skip to content

Commit b8cfdda

Browse files
fix(cloudflare): Don't hold Durable Object invocations open with the segment-end flush timer (#25490)
Since span streaming became the default (#22642) and segment-end flushing turned on for server runtimes (#23714), every root span end schedules `setTimeout(flush, 500)`, sampled or not. #23714 relies on `safeUnref` to stop that timer holding a server open, but workerd timers have no `unref()`. A Durable Object invocation stays open until its pending timers fire, so every instrumented DO `fetch`, alarm, `webSocketMessage` and propagated RPC call runs about 500 ms longer. That time is billed as duration, and the pending timer also blocks hibernation. This happens at `tracesSampleRate: 0` too. The 500 ms delay comes from the browser (#19643): `http.client` spans stay open up to 300 ms waiting for resource timing, so they can end after their segment. Neither that nor the buffer-memory reason for flushing at segment end on servers (#23714) applies to Workers, where nothing waits for resource timing and spans are flushed at the end of every invocation. The Cloudflare client now registers `spanStreamingIntegration({ flushOnSegmentEnd: false })` unless the user supplies their own. The runtime doesn't need the timer: the boundary flush in `wrapMethodWithSentry` runs after the method span ends, and the cached client's eager drain covers spans that end after the flush point. This matches the existing `_flushInterval: 0` for the log and metric buffers. Plain Worker handlers lose the timer too: it doesn't hold their requests open, so it cost them nothing, but `withSentry`'s boundary flush already delivers their spans. In workerd, with the same change applied to 11.6.0, DO `fetch` and alarm wall time drops from about 505 ms to about 6 ms, and at `tracesSampleRate: 1.0` the same span envelopes still arrive. Fixes #25489 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 58cd4f4 commit b8cfdda

2 files changed

Lines changed: 43 additions & 1 deletion

File tree

‎packages/cloudflare/src/client.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
applySdkMetadata,
77
debug,
88
spanIsSampled,
9+
spanStreamingIntegration,
910
} from '@sentry/core';
1011
import type { ServerRuntimeClientOptions } from '@sentry/core/server';
1112
import { ServerRuntimeClient } from '@sentry/core/server';
@@ -15,6 +16,10 @@ import type { makeFlushLock } from './flush';
1516
import type { CloudflareTransportOptions } from './transport';
1617
import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext';
1718

19+
declare const __SENTRY_TRACING__: boolean;
20+
21+
const SPAN_STREAMING_INTEGRATION_NAME = 'SpanStreaming';
22+
1823
/**
1924
* The Sentry Cloudflare SDK Client.
2025
*
@@ -59,6 +64,17 @@ export class CloudflareClient extends ServerRuntimeClient {
5964
_flushInterval: 0,
6065
};
6166

67+
// workerd can't unref timers, so the default 500ms segment-end flush timer holds every
68+
// Durable Object invocation open (and billed) until it fires, sampled or not. The boundary
69+
// flush and the eager drain already deliver streamed spans here.
70+
if (
71+
(typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) &&
72+
clientOptions.traceLifecycle !== 'static' &&
73+
!clientOptions.integrations.some(i => i.name === SPAN_STREAMING_INTEGRATION_NAME)
74+
) {
75+
clientOptions.integrations.push(spanStreamingIntegration({ flushOnSegmentEnd: false }));
76+
}
77+
6278
super(clientOptions);
6379
this._flushLock = flushLock;
6480
this.isCachedClient = options.cacheClient === true;

‎packages/cloudflare/test/sdk.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as SentryCore from '@sentry/core';
22
import type { Envelope, Integration } from '@sentry/core';
3-
import { getClient } from '@sentry/core';
3+
import { getClient, startSpan } from '@sentry/core';
44
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
55
import type { CloudflareOptions } from '../src/client';
66
import { CloudflareClient } from '../src/client';
@@ -69,6 +69,32 @@ describe('init', () => {
6969
);
7070
});
7171

72+
test('does not leave a timer pending when a segment span ends', () => {
73+
vi.useFakeTimers();
74+
try {
75+
init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 0 });
76+
77+
startSpan({ name: 'alarm' }, () => undefined);
78+
79+
// A pending timer keeps a Durable Object invocation open until it fires.
80+
expect(vi.getTimerCount()).toBe(0);
81+
} finally {
82+
vi.useRealTimers();
83+
}
84+
});
85+
86+
test("doesn't add spanStreamingIntegration when tracing is excluded from the bundle", () => {
87+
vi.stubGlobal('__SENTRY_TRACING__', false);
88+
try {
89+
const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' });
90+
const integrations = client?.getOptions().integrations.filter(i => i.name === 'SpanStreaming');
91+
92+
expect(integrations).toEqual([]);
93+
} finally {
94+
vi.unstubAllGlobals();
95+
}
96+
});
97+
7298
type MarkedIntegration = Integration & { _custom?: boolean };
7399

74100
test("doesn't add spanStreamingIntegration if user added it manually", () => {

0 commit comments

Comments
 (0)