Skip to content

fix(cloudflare): Ensure every request is instruments the functions#20044

Open
JPeer264 wants to merge 1 commit intodevelopfrom
jp/fix-instrumentation
Open

fix(cloudflare): Ensure every request is instruments the functions#20044
JPeer264 wants to merge 1 commit intodevelopfrom
jp/fix-instrumentation

Conversation

@JPeer264
Copy link
Copy Markdown
Member

closes #20030
closes JS-2020

Important: This issue is not reproducible locally, so it can only be done on production. I still added an integration test (which would also pass on develop now), just in case this would be added to miniflare.

The problem is that when we instrument the requests we mark them on the object with __SENTRY_INSTRUMENTED__. This would work the very first time and for other requests. Sometimes, however, the worker kind of restarts its state, without resetting __SENTRY_INSTRUMENTED__ - which means that at this point we do not instrument the new set fetch requests (and other requests).

To remove this issue we set a global WeakMap and cache the instrumented function in there. So whenever this scenario happens, we do not check via the __SENTRY_INSTRUMENTED__ on the object itself, but on the global WeakMap. The benefit of this is, that even when the WeakMap would removes its reference, we just instrument again. In the worst case if the WeakMap loses its reference, and for some reason in the future fetch requests keep its instrumentation in the given scenario, then we just overwrite fetch with a fresh instrumentation.

This is being achieved by wrapping all functions in ensureInstrumented:

before:

if (isInstrumented(handler.fetch)) return;

handler.fetch = new Proxy(handler.fetch, ...);

markAsInstrumented(handler.fetch);

after:

handler.fetch = ensureInstrumented(handler.fetch, (original) => new Proxy(original, ...))
 

@linear-code
Copy link
Copy Markdown

linear-code bot commented Mar 30, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 30, 2026

Semver Impact of This PR

🟢 Patch (bug fixes)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

Deps

  • Bump babel-loader from 10.0.0 to 10.1.1 by dependabot in #19997
  • Bump handlebars from 4.7.7 to 4.7.9 by dependabot in #20008

Nuxt

  • Add middleware instrumentation compatibility for Nuxt 5 by s1gr1d in #19968
  • Support parametrized SSR routes in Nuxt 5 by s1gr1d in #19977

Other

  • (browser) Replace element timing spans with metrics by logaretm in #19869
  • (bun) Add bunRuntimeMetricsIntegration by chargome in #19979
  • (core) Support embedding APIs in google-genai by nicohrubec in #19797
  • (node) Add nodeRuntimeMetricsIntegration by chargome in #19923
  • (node-core) Add OTLP integration for node-core/light by andreiborza in #19729
  • (solid) Add route parametrization for Solid Router by andreiborza in #20031

Bug Fixes 🐛

Ci

  • Update validate-pr action to remove draft enforcement by stephanie-anderson in #20037
  • Update validate-pr action to remove draft enforcement by stephanie-anderson in #20035

Node

  • Deduplicate sentry-trace and baggage headers on outgoing requests by Lms24 in #19960
  • Ensure startNewTrace propagates traceId in OTel environments by logaretm in #19963

Other

  • (cloudflare) Ensure every request is instruments the functions by JPeer264 in #20044
  • (core) Guard nullish response in supabase PostgREST handler by antonis in #20033
  • (e2e) Pin @opentelemetry/api to 1.9.0 in ts3.8 test app by logaretm in #19992
  • (nuxt) Use virtual module for Nuxt pages data (SSR route parametrization) by s1gr1d in #20020
  • (opentelemetry) Convert seconds timestamps in span.end() to milliseconds by logaretm in #19958
  • (profiling) Disable profiling in worker threads by chargome in #20040
  • (react-router) Disable debug ID injection in Vite plugin to prevent double injection by isaacs in #19890

Documentation 📚

  • (release) Update publishing-a-release.md by nicohrubec in #19982

Internal Changes 🔧

Core

  • Introduce instrumented method registry for AI integrations by nicohrubec in #19981
  • Consolidate getOperationName into one shared utility by nicohrubec in #19971

Deps

  • Bump amqplib from 0.10.7 to 0.10.9 by dependabot in #20000
  • Bump actions/upload-artifact from 6 to 7 by dependabot in #19569
  • Bump srvx from 0.11.12 to 0.11.13 by dependabot in #20001
  • Bump @apollo/server from 5.4.0 to 5.5.0 by dependabot in #20007

Deps Dev

  • Remove esbuild override in astro-5-cf-workers E2E test by isaacs in #20024
  • Bump node-forge from 1.3.2 to 1.4.0 by dependabot in #20012
  • Bump yaml from 2.8.2 to 2.8.3 by dependabot in #19985

Other

  • (browser) Reduce browser package bundle size by HazAT in #19856
  • (browser-tests) Add waitForMetricRequest helper by logaretm in #20002
  • (deno) Expand Deno E2E test coverage by chargome in #19957
  • (e2e) Add e2e tests for nodeRuntimeMetricsIntegration by chargome in #19989

🤖 This preview updates automatically when you update the PR.

@JPeer264 JPeer264 force-pushed the jp/fix-instrumentation branch from f306352 to e99be8e Compare March 30, 2026 16:51
Comment on lines +25 to +33

/**
* Mark an object as instrumented, storing the instrumented version.
* @param original The original uninstrumented object
* @param instrumented The instrumented version (defaults to original if not provided)
*/
export function markAsInstrumented<T>(original: T, instrumented?: T): void {
try {
(obj as SentryInstrumented<T>).__SENTRY_INSTRUMENTED__ = true;
if (isWeakMapKey(original)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The construct trap reuses a cached instrumented proxy with a stale context when a Durable Object class is instantiated multiple times, corrupting telemetry.
Severity: CRITICAL

Suggested Fix

When wrapping methods within the construct trap, pass noMark = true to ensureInstrumented (or the equivalent in wrapMethodWithSentry). This will prevent caching the instrumented proxy and ensure a new wrapper with the correct instance-specific context is created for each new Durable Object instance.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/cloudflare/src/instrument.ts#L25-L33

Potential issue: When a Durable Object class is instantiated multiple times, methods
instrumented in the `construct` trap (e.g., `alarm`, `webSocketMessage`) are wrapped
using `ensureInstrumented` without preventing caching. This causes the instrumented
proxy from the first instance, which has a closure over the initial `context` and
`options`, to be reused for all subsequent instances. As a result, errors and spans from
these later instances will be reported with the incorrect context of the first instance,
leading to corrupted monitoring data.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown

@cursor cursor bot left a comment

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 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: DO lifecycle proxies cache stale context across instances
    • Fixed by creating instance-specific proxies with noMark=true to prevent caching stale context and options in the global WeakMap, ensuring each DurableObject instance uses its own DurableObjectState.

Create PR

Or push these changes by commenting:

@cursor push 44b2aefb35
Preview (44b2aefb35)
diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts
--- a/packages/cloudflare/src/durableobject.ts
+++ b/packages/cloudflare/src/durableobject.ts
@@ -3,7 +3,7 @@
 import type { DurableObject } from 'cloudflare:workers';
 import { setAsyncLocalStorageAsyncContextStrategy } from './async';
 import type { CloudflareOptions } from './client';
-import { ensureInstrumented, getInstrumented, markAsInstrumented } from './instrument';
+import { getInstrumented, markAsInstrumented } from './instrument';
 import { getFinalOptions } from './options';
 import { wrapRequestHandler } from './request';
 import { instrumentContext } from './utils/instrumentContext';
@@ -68,38 +68,50 @@
       // Any other public methods on the Durable Object instance are RPC calls.
 
       if (obj.fetch && typeof obj.fetch === 'function') {
-        obj.fetch = ensureInstrumented(
-          obj.fetch,
-          original =>
-            new Proxy(original, {
-              apply(target, thisArg, args) {
-                return wrapRequestHandler({ options, request: args[0], context }, () => {
-                  return Reflect.apply(target, thisArg, args);
-                });
-              },
-            }),
-        );
+        const originalFetch = obj.fetch;
+        obj.fetch = new Proxy(originalFetch, {
+          apply(target, thisArg, args) {
+            return wrapRequestHandler({ options, request: args[0], context }, () => {
+              return Reflect.apply(target, thisArg, args);
+            });
+          },
+        });
+        markAsInstrumented(obj.fetch);
       }
 
       if (obj.alarm && typeof obj.alarm === 'function') {
-        obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, obj.alarm);
+        const originalAlarm = obj.alarm;
+        obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, originalAlarm, undefined, true);
+        markAsInstrumented(obj.alarm);
       }
 
       if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') {
+        const originalWebSocketMessage = obj.webSocketMessage;
         obj.webSocketMessage = wrapMethodWithSentry(
           { options, context, spanName: 'webSocketMessage' },
-          obj.webSocketMessage,
+          originalWebSocketMessage,
+          undefined,
+          true,
         );
+        markAsInstrumented(obj.webSocketMessage);
       }
 
       if (obj.webSocketClose && typeof obj.webSocketClose === 'function') {
-        obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose);
+        const originalWebSocketClose = obj.webSocketClose;
+        obj.webSocketClose = wrapMethodWithSentry(
+          { options, context, spanName: 'webSocketClose' },
+          originalWebSocketClose,
+          undefined,
+          true,
+        );
+        markAsInstrumented(obj.webSocketClose);
       }
 
       if (obj.webSocketError && typeof obj.webSocketError === 'function') {
+        const originalWebSocketError = obj.webSocketError;
         obj.webSocketError = wrapMethodWithSentry(
           { options, context, spanName: 'webSocketError' },
-          obj.webSocketError,
+          originalWebSocketError,
           (_, error) =>
             captureException(error, {
               mechanism: {
@@ -107,7 +119,9 @@
                 handled: false,
               },
             }),
+          true,
         );
+        markAsInstrumented(obj.webSocketError);
       }
 
       for (const method of Object.getOwnPropertyNames(obj)) {

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

});
},
}),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

DO lifecycle proxies cache stale context across instances

Medium Severity

In the DurableObject construct handler, lifecycle methods like fetch, alarm, webSocketMessage, etc. that live on the prototype are passed to ensureInstrumented / wrapMethodWithSentry. The first construction creates a proxy that captures the instance-specific context (DurableObjectState) and options in its closure, then caches the mapping protoMethod → proxy in the global WeakMap. When a second instance of the same class is constructed, obj.fetch resolves to the same prototype function, ensureInstrumented finds the cached proxy, and returns it — with the first instance's stale context and options baked in. This means waitUntil and other context-dependent operations target the wrong DurableObjectState.

Additional Locations (1)
Fix in Cursor Fix in Web

@github-actions
Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 25.64 kB - -
@sentry/browser - with treeshaking flags 24.13 kB - -
@sentry/browser (incl. Tracing) 42.15 kB - -
@sentry/browser (incl. Tracing, Profiling) 46.76 kB - -
@sentry/browser (incl. Tracing, Replay) 80.94 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 70.56 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 85.65 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 97.91 kB - -
@sentry/browser (incl. Feedback) 42.42 kB - -
@sentry/browser (incl. sendFeedback) 30.3 kB - -
@sentry/browser (incl. FeedbackAsync) 35.28 kB - -
@sentry/browser (incl. Metrics) 26.95 kB - -
@sentry/browser (incl. Logs) 27.1 kB - -
@sentry/browser (incl. Metrics & Logs) 27.77 kB - -
@sentry/react 27.41 kB - -
@sentry/react (incl. Tracing) 44.48 kB - -
@sentry/vue 30.08 kB - -
@sentry/vue (incl. Tracing) 44.05 kB - -
@sentry/svelte 25.66 kB - -
CDN Bundle 28.31 kB - -
CDN Bundle (incl. Tracing) 43.1 kB - -
CDN Bundle (incl. Logs, Metrics) 29.68 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 44.16 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 68.48 kB - -
CDN Bundle (incl. Tracing, Replay) 80 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 81.04 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 85.54 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 86.58 kB - -
CDN Bundle - uncompressed 82.66 kB - -
CDN Bundle (incl. Tracing) - uncompressed 127.81 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 86.81 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 131.22 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 209.79 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 244.68 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 248.08 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 257.59 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 260.98 kB - -
@sentry/nextjs (client) 46.89 kB - -
@sentry/sveltekit (client) 42.62 kB - -
@sentry/node-core 56.7 kB +0.03% +14 B 🔺
@sentry/node 173.85 kB +0.01% +9 B 🔺
@sentry/node - without tracing 96.77 kB +0.01% +6 B 🔺
@sentry/aws-serverless 113.76 kB +0.01% +10 B 🔺

View base workflow run

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.

@sentry/cloudflare: Child spans inside transactions from Durable Objects are not indexed by Sentry

2 participants