From 8b619e3860feee4455218631a4c76a824da76b7e Mon Sep 17 00:00:00 2001 From: Philip Atkinson Date: Sun, 18 Oct 2026 10:19:05 +0100 Subject: [PATCH] fix(cloudflare): Don't hold Durable Object invocations open with the segment-end flush timer Co-Authored-By: Claude Opus 5.5 --- packages/cloudflare/src/client.ts | 16 ++++++++++++++++ packages/cloudflare/test/sdk.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 3b4f591d5e22..751d3d5dac63 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -6,6 +6,7 @@ import { applySdkMetadata, debug, spanIsSampled, + spanStreamingIntegration, } from '@sentry/core'; import type { ServerRuntimeClientOptions } from '@sentry/core/server'; import { ServerRuntimeClient } from '@sentry/core/server'; @@ -15,6 +16,10 @@ import type { makeFlushLock } from './flush'; import type { CloudflareTransportOptions } from './transport'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; +declare const __SENTRY_TRACING__: boolean; + +const SPAN_STREAMING_INTEGRATION_NAME = 'SpanStreaming'; + /** * The Sentry Cloudflare SDK Client. * @@ -59,6 +64,17 @@ export class CloudflareClient extends ServerRuntimeClient { _flushInterval: 0, }; + // workerd can't unref timers, so the default 500ms segment-end flush timer holds every + // Durable Object invocation open (and billed) until it fires, sampled or not. The boundary + // flush and the eager drain already deliver streamed spans here. + if ( + (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) && + clientOptions.traceLifecycle !== 'static' && + !clientOptions.integrations.some(i => i.name === SPAN_STREAMING_INTEGRATION_NAME) + ) { + clientOptions.integrations.push(spanStreamingIntegration({ flushOnSegmentEnd: false })); + } + super(clientOptions); this._flushLock = flushLock; this.isCachedClient = options.cacheClient === true; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index d7820ecc343e..dcbbb7089da4 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,6 +1,6 @@ import * as SentryCore from '@sentry/core'; import type { Envelope, Integration } from '@sentry/core'; -import { getClient } from '@sentry/core'; +import { getClient, startSpan } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; @@ -69,6 +69,32 @@ describe('init', () => { ); }); + test('does not leave a timer pending when a segment span ends', () => { + vi.useFakeTimers(); + try { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 0 }); + + startSpan({ name: 'alarm' }, () => undefined); + + // A pending timer keeps a Durable Object invocation open until it fires. + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + test("doesn't add spanStreamingIntegration when tracing is excluded from the bundle", () => { + vi.stubGlobal('__SENTRY_TRACING__', false); + try { + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + const integrations = client?.getOptions().integrations.filter(i => i.name === 'SpanStreaming'); + + expect(integrations).toEqual([]); + } finally { + vi.unstubAllGlobals(); + } + }); + type MarkedIntegration = Integration & { _custom?: boolean }; test("doesn't add spanStreamingIntegration if user added it manually", () => {