Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const flushMarkerMatcher = (envelope: Envelope): void => {

it('instruments sync KV operations on Durable Object storage', async ({ signal }) => {
const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined;
const spans = transactionEvent?.spans ?? [];
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"message": "Do not import from `@sentry/node` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points."
},
{
"group": ["@sentry/server-utils/**"],
"group": ["@sentry/server-utils/**", "!@sentry/server-utils/no-diagnostic-channels"],
"message": "Do not import from `@sentry/server-utils` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points."
}
]
Expand Down
81 changes: 0 additions & 81 deletions packages/cloudflare/src/async.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { captureException } from '@sentry/core';
import type { DurableObject } from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from './client';
import { ensureInstrumented } from './instrument';
import { instrumentEnv } from './instrumentations/worker/instrumentEnv';
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,4 @@ export { instrumentD1WithSentry } from './instrumentations/worker/instrumentD1';

export { instrumentWorkflowWithSentry } from './workflows';

export { setAsyncLocalStorageAsyncContextStrategy } from './async';
export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from '../async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from '../client';
import { getFinalOptions } from '../options';
import { instrumentContext } from '../utils/instrumentContext';
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/src/pages-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { wrapRequestHandler } from './request';
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/src/withSentry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { env as cloudflareEnv } from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from './client';
import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail';
import { instrumentExportedHandlerFetch } from './instrumentations/worker/instrumentFetch';
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/src/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
WorkflowStepRollbackOptions,
WorkflowTimeoutDuration,
} from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from './client';
import { flushAndDispose } from './flush';
import { instrumentEnv } from './instrumentations/worker/instrumentEnv';
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import { CloudflareClient, type CloudflareClientOptions } from '../src/client';
import { makeFlushLock } from '../src/flush';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Event } from '@sentry/core';
import { getCurrentScope, setCurrentClient } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import { CloudflareClient, type CloudflareClientOptions } from '../src/client';
import { instrumentCloudflareAgent } from '../src/instrumentations/agents';
import { resetSdk } from './testUtils';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe('instrumentWorkerEntrypoint', () => {
});

it('Calls setAsyncLocalStorageAsyncContextStrategy outside Proxy (at instrumentation time), not inside construct', async () => {
const asyncModule = await import('../../src/async');
const asyncModule = await import('@sentry/server-utils/no-diagnostic-channels');
const setStrategy = vi.spyOn(asyncModule, 'setAsyncLocalStorageAsyncContextStrategy');
const mockContext = createMockExecutionContext();
const TestClass = class extends WorkerEntrypoint {
Expand Down Expand Up @@ -216,6 +216,9 @@ describe('instrumentWorkerEntrypoint', () => {
const waitUntil = vi.fn();
const TestClass = vi.fn((context: ExecutionContext) => ({
fetch: () => {
// The client is created per request, on the scope forked for that request, so it is only
// reachable from inside the handler.
testClient = SentryCore.getClient();
context.waitUntil(deferred);
return new Response('test');
},
Expand All @@ -225,7 +228,6 @@ describe('instrumentWorkerEntrypoint', () => {
const worker = Reflect.construct(instrumented, [context, {}]);

const responsePromise = worker.fetch(new Request('https://example.com'));
testClient = SentryCore.getClient();

const response = await responsePromise;
await response.text();
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/test/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ExecutionContext } from '@cloudflare/workers-types';
import type { Event } from '@sentry/core';
import * as SentryCore from '@sentry/core';
import { beforeAll, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest';
import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from '../src/client';
import { CloudflareClient } from '../src/client';
import { wrapRequestHandler } from '../src/request';
Expand Down
2 changes: 1 addition & 1 deletion packages/deno/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ServerRuntimeClientOptions } from '@sentry/core';
import { _INTERNAL_flushLogsBuffer, SDK_VERSION, ServerRuntimeClient } from '@sentry/core';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils';
import type { DenoClientOptions } from './types';

function getHostName(): string | undefined {
Expand Down
11 changes: 10 additions & 1 deletion packages/opentelemetry/src/asyncContextStrategy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import * as api from '@opentelemetry/api';
import type { Scope, TracingChannelBinding } from '@sentry/core';
import {
getAsyncContextStrategy,
getDefaultCurrentScope,
getDefaultIsolationScope,
getMainCarrier,
getRootSpan,
setAsyncContextStrategy,
spanIsIgnored,
Expand All @@ -27,7 +29,14 @@ import { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContex
* We handle forking a hub inside of our custom OTEL Context Manager (./otelContextManager.ts)
*/
export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorageLookup {
const asyncLocalStorage = new AsyncLocalStorage<api.Context>();
// Re-use the AsyncLocalStorage of an already-installed strategy, if any. Otherwise a repeated
// setup would swap in a new store while integrations that captured the previous one (via
// `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one, breaking scope
// propagation across async boundaries.
const existingAsyncLocalStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()
?.asyncLocalStorage as AsyncLocalStorage<api.Context> | undefined;

const asyncLocalStorage = existingAsyncLocalStorage ?? new AsyncLocalStorage<api.Context>();

function getScopes(): CurrentScopes {
const ctx = api.context.active();
Expand Down
25 changes: 25 additions & 0 deletions packages/opentelemetry/test/asyncContextStrategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,31 @@ describe('asyncContextStrategy', () => {
});
});

describe('AsyncLocalStorage re-use', () => {
it('re-uses the AsyncLocalStorage of an already-installed strategy on repeated setup', () => {
setOpenTelemetryContextAsyncContextStrategy();
const firstStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage;
expect(firstStorage).toBeDefined();

setOpenTelemetryContextAsyncContextStrategy();
const secondStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage;

expect(secondStorage).toBe(firstStorage);
});

it('returns a context manager backed by the re-used AsyncLocalStorage', () => {
const firstLookup = setOpenTelemetryContextAsyncContextStrategy();
const secondLookup = setOpenTelemetryContextAsyncContextStrategy();

// The context manager returned on the second setup wraps the same AsyncLocalStorage instance,
// so consumers that captured the lookup from the first setup keep observing the active context.
expect(secondLookup.asyncLocalStorage).toBe(firstLookup.asyncLocalStorage);
expect(getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage).toBe(
firstLookup.asyncLocalStorage,
);
});
});

describe('withScope()', () => {
it('will make the passed scope the active scope within the callback', () =>
new Promise<void>(done => {
Expand Down
5 changes: 5 additions & 0 deletions packages/server-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
"import": "./build/esm/index.js",
"require": "./build/cjs/index.js"
},
"./no-diagnostic-channels": {
"types": "./build/types/index.no-diagnostic-channels.d.ts",
"import": "./build/esm/index.no-diagnostic-channels.js",
"require": "./build/cjs/index.no-diagnostic-channels.js"
},
Comment thread
cursor[bot] marked this conversation as resolved.
"./orchestrion": {
"types": "./build/types/orchestrion/index.d.ts",
"import": "./build/esm/orchestrion/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/server-utils/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export default [
// subpath export.
entrypoints: [
'src/index.ts',
'src/index.no-diagnostic-channels.ts',
'src/orchestrion/index.ts',
'src/orchestrion/config/index.ts',
// `src/orchestrion/runtime/register.ts` backs the `./orchestrion/register`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,28 @@
// Need to use node: prefix for deno compatibility
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Scope } from '@sentry/core';
import {
_INTERNAL_createTracingChannelBinding,
getAsyncContextStrategy,
getDefaultCurrentScope,
getDefaultIsolationScope,
getMainCarrier,
setAsyncContextStrategy,
} from '@sentry/core';

let installed = false;
type ScopeStore = { scope: Scope; isolationScope: Scope };

/**
* Sets the async context strategy to use AsyncLocalStorage.
*
* Idempotent: multiple integrations each call this from their `setupOnce`,
* but they must all share a single `AsyncLocalStorage` so context propagates
* between them. The first call wins, later calls are no-ops. This prevents
* orphaning an in-flight context if an integration is set up asynchronously.
*
* @internal Only exported to be used in higher-level Sentry packages
* @hidden Only exported to be used in higher-level Sentry packages
*/
export function setAsyncLocalStorageAsyncContextStrategy(): void {
if (installed) {
return;
}
installed = true;
// Re-use the AsyncLocalStorage of an already-installed strategy, if any. Otherwise a repeated
// setup (e.g. a second `Sentry.init()`) would swap in a new store while integrations that captured
// the previous one (via `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one,
// breaking scope propagation across async boundaries.
const existingAsyncStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()
?.asyncLocalStorage as AsyncLocalStorage<ScopeStore> | undefined;

const asyncStorage = new AsyncLocalStorage<{
scope: Scope;
isolationScope: Scope;
}>();
const asyncStorage = existingAsyncStorage ?? new AsyncLocalStorage<ScopeStore>();
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
andreiborza marked this conversation as resolved.

function getScopes(): { scope: Scope; isolationScope: Scope } {
const scopes = asyncStorage.getStore();
Expand All @@ -55,23 +47,30 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void {
});
}

// The isolation scope is shared, not forked, matching `withScope` above and the OpenTelemetry
// strategy. Forking it would silently discard `setUser`/`setTag`/`setContext` calls made inside
// the callback, as those write to the isolation scope.
function withSetScope<T>(scope: Scope, callback: (scope: Scope) => T): T {
const isolationScope = getScopes().isolationScope.clone();
const isolationScope = getScopes().isolationScope;
return asyncStorage.run({ scope, isolationScope }, () => {
return callback(scope);
});
}

// The current scope is forked alongside the isolation scope, matching the OpenTelemetry strategy
// (`buildContextWithSentryScopes` clones it on every fork). Sharing it by reference would let
// current-scope mutations inside the callback leak back out to the caller.
function withIsolationScope<T>(callback: (isolationScope: Scope) => T): T {
const scope = getScopes().scope;
const scope = getScopes().scope.clone();
const isolationScope = getScopes().isolationScope.clone();

return asyncStorage.run({ scope, isolationScope }, () => {
return callback(isolationScope);
});
}

Comment thread
sentry[bot] marked this conversation as resolved.
function withSetIsolationScope<T>(isolationScope: Scope, callback: (isolationScope: Scope) => T): T {
const scope = getScopes().scope;
const scope = getScopes().scope.clone();
return asyncStorage.run({ scope, isolationScope }, () => {
return callback(isolationScope);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/server-utils/src/exports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Shared exports not using diagnostics channels
export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute';
export { setAsyncLocalStorageAsyncContextStrategy } from './async-context';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './exports';
8 changes: 2 additions & 6 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
/**
* Server-only utilities shared across Sentry server SDKs.
*
* @module
*/
export * from './exports';

// Exports using diagnostics channels
export { graphqlIntegration } from './graphql';
export { mongooseIntegration, startMongooseLegacySpan } from './mongoose';
export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose';
Expand Down Expand Up @@ -36,4 +33,3 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
instrumentFastify,
} from './integrations/tracing-channel/fastify';
export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute';
Loading
Loading