Skip to content

feat(cloudflare): Add cacheClient to reuse the client across invocations - #23151

Draft
JPeer264 wants to merge 3 commits into
jp/cacheclient-add-hooksfrom
jp/cacheclient
Draft

feat(cloudflare): Add cacheClient to reuse the client across invocations#23151
JPeer264 wants to merge 3 commits into
jp/cacheclient-add-hooksfrom
jp/cacheclient

Conversation

@JPeer264

@JPeer264 JPeer264 commented Aug 7, 2026

Copy link
Copy Markdown
Member

closes #23083
closes #22545
closes #21950

What

This PR is reusing the client, instead of creating a new one. This is also only possible because of #22969 (as now we have the correct isolation scopes per request).

To still have an escape hatch and keep the old behavior there is the cacheClient: false option, that just creates a new client per request, as before.

Why

There are more and more issues coming in, that .dispose is leading to errors, which makes sense as in DurableObjects data can flow in after a request happened and it stays alive. Since we created a new client on each request, we also had to clean it up - the best point in time was after a request, which was too early for e.g. #22545. Since there is not a perfect time to dispose the client the only option is to reuse the client and not dispose at all (this is then also aligned with how other SDK machinery works).

Issues and how they're solved

Timing

In CF, timers are usually 0 and therefore our 5 second auto flush wouldn't work. In order to still retrieve all the data we need to have point in times (hooks) where it is safe to flush. In our case we have a request and flush after a request, like before. Events that come in a later point in time are then captured with the hooks added in #23136 (most important one is the afterEnvelope).

We start listening to the hooks once flushPointReached is set to true - which is AFTER a request, the time where we have no control anymore about flushing manually otherwise.

waitUntil

Keeping the correct waitUntil is important, as each request needs its own waitUntil to properly flush. To still keep the correct waitUntil this is now bound onto the scope directly. I tried using setSDKProcessingMetadata on the scope, but it just didn't work properly on deployed workers. Instead this is bound onto the scope directly with a Symbol - that works like a charm. This is the INVOCATION_STATE #namingishard

flushLock

The flush lock would wait for all spans to be finished and then flush. This would just not work, as we only have one client. So we skip this entirely and get rid of that hack. We keep this in order to have the escape hatch cacheClient: false in case something goes sideways.

Bonuses

Bonus 1

Because we are now reusing the client we are saving valuable CPU cycles per request. With cacheClient: true we gain up to ~14-21% per request, which is loads. Also on top of the CPU wins we also retrieve more events, which would have been dropped before.

Bonus 2

In v12 (or any other major) we could get rid of all the flushLock hacks and the rest of hacks we did

Bonus 3

Dedupe integration works now as intended. Because we created a new client and the dedupeIntegration only deduplicated per integration, which was a new one on every client, we only deduped it per request, not for all requests.

Sidenotes

During the implementation I thought about having multiple clients, which are cached in one global map - in case the isolations would get reused from other deployments or other bindings. After some excessive tests it seems that new deployments are getting a fresh isolate, different bindings have their own isolate, only ExportedHandlers and WorkerEntrypoints share one isolate, which makes sense to some degree, as they're isolated within each request anyways (they're getting a fresh isolate on a new deployment though). Because this is the case only one client is being created instead of checking if the config differs between clients.


Clanker description

Building and disposing a client per invocation costs real time on every request, and in a Durable Object it also loses data: there is no waitUntil boundary that dependably extends execution, so anything captured after the handler returned went to a client that had already been disposed.

Enabled by default, this caches one client per isolate. The first initialization wins for the isolate's lifetime: a later init with different options reuses that client, and a new deployment always starts fresh isolates, so clients are always built from the
current version's options. A cached client is flushed but not disposed at an invocation boundary, and it is re-bound to the current scope on every invocation — otherwise initialScope would apply only to an isolate's first invocation, and a client disposed by a competing init would keep being handed out. A cached client whose transport is gone is evicted rather than returned.

Because a reused client never reaches an end-of-invocation flush, delivery is eager: the new afterEnvelope hook on the core client drains the transport buffer as soon as an envelope has been accepted, and logs and metrics drain on a debounced hook so they are batched rather than sent one at a time. Spans that end after the invocation's flush point are delivered through core's flushTraceSpans hook, which flushes only that trace's bucket from the span streaming buffer. The per-invocation flush lock and span tracking are skipped, since binding a client that outlives the invocation to one invocation's lock would make later flushes wait on that invocation's work forever.

A shared client also shares integration state, so dedupe works across invocations: the same error raised by two separate requests is reported only once.

@JPeer264 JPeer264 self-assigned this Aug 7, 2026
@JPeer264

JPeer264 commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

bugbot run

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

⚠️ Warning: Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.

Path Size % Change Change
@sentry/browser 30.34 kB +0.02% +4 B 🔺
@sentry/browser - with treeshaking flags 28.55 kB +0.02% +4 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing 26.87 kB +0.02% +5 B 🔺
@sentry/browser (incl. Tracing) 47.81 kB +0.01% +4 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 47.83 kB +0.02% +5 B 🔺
@sentry/browser (incl. Tracing, Profiling) 52.56 kB +0.02% +8 B 🔺
@sentry/browser (incl. Tracing, Replay) 87.19 kB +0.01% +4 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 76.62 kB +0.01% +6 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 91.91 kB +0.01% +4 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 104.56 kB +0.01% +5 B 🔺
@sentry/browser (incl. Feedback) 47.68 kB +0.01% +4 B 🔺
@sentry/browser (incl. sendFeedback) 35.17 kB +0.02% +4 B 🔺
@sentry/browser (incl. FeedbackAsync) 40.34 kB +0.01% +3 B 🔺
@sentry/browser (incl. Metrics) 31.42 kB +0.02% +6 B 🔺
@sentry/browser (incl. Logs) 31.64 kB -0.06% -16 B 🔽
@sentry/browser (incl. Metrics & Logs) 32.32 kB +0.01% +2 B 🔺
@sentry/react 32.15 kB +0.02% +4 B 🔺
@sentry/react (incl. Tracing) 50.07 kB +0.01% +4 B 🔺
@sentry/vue 35.46 kB +0.01% +2 B 🔺
@sentry/vue (incl. Tracing) 49.82 kB +0.02% +6 B 🔺
@sentry/svelte 30.37 kB +0.02% +4 B 🔺
CDN Bundle 32.34 kB +0.03% +7 B 🔺
CDN Bundle (incl. Tracing) 48.13 kB - -
CDN Bundle (incl. Logs, Metrics) 33.89 kB +0.03% +7 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 49.46 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.23 kB +0.01% +5 B 🔺
CDN Bundle (incl. Tracing, Replay) 85.74 kB +0.01% +1 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 87.02 kB +0.01% +1 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 91.56 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.86 kB +0.01% +1 B 🔺
CDN Bundle - uncompressed 95.91 kB +0.04% +32 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 143.62 kB +0.03% +32 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 100.53 kB +0.04% +32 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 147.59 kB +0.03% +32 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 225.23 kB +0.02% +32 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 262.87 kB +0.02% +32 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 266.84 kB +0.02% +32 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 276.58 kB +0.02% +32 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 280.53 kB +0.02% +32 B 🔺
@sentry/nextjs (client) 52.63 kB +0.02% +7 B 🔺
@sentry/sveltekit (client) 48.27 kB +0.03% +10 B 🔺
@sentry/core/server 65.77 kB - -
@sentry/core/browser 52 kB -0.01% -3 B 🔽
@sentry/node 119.43 kB -0.01% -5 B 🔽
@sentry/node/import (ESM hook with diagnostics-channel injection) 0 B added added
@sentry/node - without tracing 83.39 kB -0.03% -20 B 🔽
@sentry/aws-serverless 92.75 kB +0.01% +5 B 🔺
@sentry/cloudflare (withSentry) - minified 217.04 kB +1.01% +2.15 kB 🔺
@sentry/cloudflare (withSentry) 539.46 kB +1.64% +8.69 kB 🔺

View base workflow run

Comment thread packages/cloudflare/src/client.ts
// Second and third invocations capture the same error, but dedupe drops them.
await runner.makeRequest('get', '/cache/dedupe?id=dedupe-shared');
await runner.makeRequest('get', '/cache/dedupe?id=dedupe-shared');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dedupe test asserts nothing about dropped duplicates

Medium Severity

The second and third requests are fired without any assertion, and the runner silently ignores envelopes that match no registered expectation, so the test passes identically whether dedupe drops the duplicates or reports all three. The claimed cross-invocation dedupe behaviour of the shared client is not actually covered.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 04d255e. Configure here.

Comment thread packages/cloudflare/src/client.ts
Comment thread packages/cloudflare/src/client.ts Outdated
Comment thread packages/cloudflare/src/client.ts Outdated
@JPeer264
JPeer264 force-pushed the jp/cacheclient branch 2 times, most recently from d081c44 to a362f65 Compare August 7, 2026 13:09
@JPeer264

JPeer264 commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

bugbot run

@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 4 potential issues.

There are 5 total unresolved issues (including 1 from previous review).

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 a362f65. Configure here.

Comment thread packages/cloudflare/src/sdk.ts Outdated
cached.setExecutionContext(options.ctx);
return cached;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cached client ignores workflow dedupe opt-out

Medium Severity

init() returns the isolate's first client regardless of the options passed later, but instrumentWorkflowWithSentry relies on init({ ..., enableDedupe: false }) to keep every workflow step failure. When another entrypoint in the same isolate (an ExportedHandler or WorkerEntrypoint, which a WorkflowEntrypoint shares an isolate with) created the client first, the workflow silently runs with dedupe enabled and repeated step errors are dropped — and the reverse ordering disables dedupe for the request handler.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a362f65. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That is indeed true. I just wonder why we didn't have tests for this scenario 🤔

Comment thread packages/cloudflare/src/flush.ts
} catch {
// The owning invocation already ended; the send races isolate teardown either way.
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Workflows lack invocation state for eager sends

Medium Severity

Every other entrypoint attaches invocation state to its forked isolation scope, but the workflow wrapper still uses a plain withIsolationScope and never calls setInvocationState. Eager envelope sends from a workflow therefore fall back to the client-global _invocationContext, which the most recent init() overwrites. With concurrent workflow runs (or a request handler running alongside), a run's envelopes get registered on another invocation's waitUntil, so they can be suspended when that unrelated invocation ends.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a362f65. Configure here.

await runner.makeRequest('get', '/cache/handler-error?id=instance-1', { expectError: true });
await runner.makeRequest('get', '/cache/handler-error?id=instance-2', { expectError: true });
await runner.completed();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate integration test spawns another worker

Low Severity

The "multiple DO instances share the same client" test is identical to the earlier "cacheClient: true - DO handler error is captured" test: same expectations, same two requests, same instance ids. It adds no coverage while starting another wrangler dev process, which is the expensive part of this suite.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a362f65. Configure here.

Building and disposing a client per invocation costs real time on every request, and in
a Durable Object it also loses data: there is no `waitUntil` boundary that dependably
extends execution, so anything captured after the handler returned went to a client that
had already been disposed.

Enabled by default, this caches one client per isolate. The first initialization wins
for the isolate's lifetime: a later init with different options reuses that client, and
a new deployment always starts fresh isolates, so clients are always built from the
current version's options. A cached client is flushed but not disposed at an invocation
boundary, and it is re-bound to the current scope on every invocation — otherwise
`initialScope` would apply only to an isolate's first invocation, and a client disposed
by a competing init would keep being handed out. A cached client whose transport is gone
is evicted rather than returned.

Because a reused client never reaches an end-of-invocation flush, delivery is eager: the
new `afterEnvelope` hook on the core client drains the transport buffer as soon as an
envelope has been accepted, and logs and metrics drain on a debounced hook so they are
batched rather than sent one at a time. Spans that end after the invocation's flush
point are delivered through core's `flushTraceSpans` hook, which flushes only that
trace's bucket from the span streaming buffer. The per-invocation flush lock and span
tracking are skipped, since binding a client that outlives the invocation to one
invocation's lock would make later flushes wait on that invocation's work forever.

A shared client also shares integration state, so dedupe works across invocations: the
same error raised by two separate requests is reported only once.

Uncached behavior is unchanged; pass `cacheClient: false` to restore it.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant