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
2 changes: 1 addition & 1 deletion packages/cloudflare/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function withSentry<
QueueHandlerMessage,
CfHostMetadata
>,
>(optionsCallback: (env: Env) => CloudflareOptions, handler: T): T {
>(optionsCallback: (env: Env) => CloudflareOptions | undefined, handler: T): T {
Copy link
Member Author

Choose a reason for hiding this comment

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

note: I made this return undefined as it was also working before theoretically. I adapted the types to also allow undefined just to have this case also handled

setAsyncLocalStorageAsyncContextStrategy();

try {
Expand Down
22 changes: 20 additions & 2 deletions packages/cloudflare/src/options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { envToBool } from '@sentry/core';
import type { CloudflareOptions } from './client';

/**
Expand All @@ -17,6 +18,12 @@ function isVersionMetadata(value: unknown): value is CfVersionMetadata {
return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string';
}

function getEnvVar<T extends Record<string, unknown>>(env: unknown, varName: keyof T): string | undefined {
return typeof env === 'object' && env !== null && varName in env && typeof (env as T)[varName] === 'string'
? ((env as T)[varName] as string)
: undefined;
}

/**
* Merges the options passed in from the user with the options we read from
* the Cloudflare `env` environment variable object.
Expand All @@ -31,7 +38,7 @@ function isVersionMetadata(value: unknown): value is CfVersionMetadata {
*
* @returns The final options.
*/
export function getFinalOptions(userOptions: CloudflareOptions, env: unknown): CloudflareOptions {
export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknown): CloudflareOptions {
if (typeof env !== 'object' || env === null) {
return userOptions;
}
Expand All @@ -44,5 +51,16 @@ export function getFinalOptions(userOptions: CloudflareOptions, env: unknown): C
? env.CF_VERSION_METADATA.id
: undefined;

return { release, ...userOptions };
const tracesSampleRate =
userOptions.tracesSampleRate ?? parseFloat(getEnvVar(env, 'SENTRY_TRACES_SAMPLE_RATE') ?? '');

return {
release,
...userOptions,
dsn: userOptions.dsn ?? getEnvVar(env, 'SENTRY_DSN'),
Copy link
Member

Choose a reason for hiding this comment

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

l: can we also add SENTRY_DEBUG? You could use envToBool for that from node-core, maybe lift that up into @sentry/core?

Copy link
Member Author

Choose a reason for hiding this comment

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

Great idea. Added.

I also added tunnel to it

environment: userOptions.environment ?? getEnvVar(env, 'SENTRY_ENVIRONMENT'),
tracesSampleRate: isFinite(tracesSampleRate) ? tracesSampleRate : undefined,
debug: userOptions.debug ?? envToBool(getEnvVar(env, 'SENTRY_DEBUG')),
Copy link

Choose a reason for hiding this comment

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

envToBool(undefined) forces debug: false when unset

Medium Severity

When SENTRY_DEBUG is absent from env, getEnvVar returns undefined, and envToBool(undefined) returns false (since Boolean(undefined) is false in loose mode). This means debug is always explicitly set to false in the returned options, even when neither the user nor the env specified it. This breaks multiple existing toEqual assertions (e.g., lines 29, 120, 132) that don't expect a debug property.

Fix in Cursor Fix in Web

tunnel: userOptions.tunnel ?? getEnvVar(env, 'SENTRY_TUNNEL'),
};
Copy link

Choose a reason for hiding this comment

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

Spread operator overrides env-derived fallback values

Medium Severity

The ...userOptions spread at the end of the return object overrides the env-derived fallback values for dsn, environment, and tracesSampleRate whenever those keys exist in userOptions — even if their values are undefined. This means if a user passes { dsn: undefined }, the SENTRY_DSN env fallback is silently ignored. The ?? fallback logic and the isFinite guard are both bypassed by the spread.

Fix in Cursor Fix in Web

}
25 changes: 15 additions & 10 deletions packages/cloudflare/test/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ const MOCK_ENV = {
SENTRY_RELEASE: '1.1.1',
};

// Mock env without DSN for tests that should not initialize the SDK
const MOCK_ENV_WITHOUT_DSN = {
Copy link
Member Author

Choose a reason for hiding this comment

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

note: It seems that the SDK was not initialized for the tests where this is used. By now also allowing SENTRY_DSN from the variables these tests fail. I'll create a follow up PR and remove this, so the SDK is actually initialized (I don't want to blow up this PR with other code changes)

SENTRY_RELEASE: '1.1.1',
};

function addDelayedWaitUntil(context: ExecutionContext) {
context.waitUntil(new Promise<void>(resolve => setTimeout(() => resolve())));
}
Expand Down Expand Up @@ -149,12 +154,12 @@ describe('withSentry', () => {
addDelayedWaitUntil(_context);
return new Response('test');
},
} satisfies ExportedHandler<typeof MOCK_ENV>;
} satisfies ExportedHandler<typeof MOCK_ENV_WITHOUT_DSN>;

const wrappedHandler = withSentry(vi.fn(), handler);
const waits: Promise<unknown>[] = [];
const waitUntil = vi.fn(promise => waits.push(promise));
await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, {
await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV_WITHOUT_DSN, {
waitUntil,
} as unknown as ExecutionContext);
expect(flush).not.toBeCalled();
Expand Down Expand Up @@ -389,12 +394,12 @@ describe('withSentry', () => {
addDelayedWaitUntil(_context);
return;
},
} satisfies ExportedHandler<typeof MOCK_ENV>;
} satisfies ExportedHandler<typeof MOCK_ENV_WITHOUT_DSN>;

const wrappedHandler = withSentry(vi.fn(), handler);
const waits: Promise<unknown>[] = [];
const waitUntil = vi.fn(promise => waits.push(promise));
await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, {
await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV_WITHOUT_DSN, {
waitUntil,
} as unknown as ExecutionContext);
expect(flush).not.toBeCalled();
Expand Down Expand Up @@ -628,12 +633,12 @@ describe('withSentry', () => {
addDelayedWaitUntil(_context);
return;
},
} satisfies ExportedHandler<typeof MOCK_ENV>;
} satisfies ExportedHandler<typeof MOCK_ENV_WITHOUT_DSN>;

const wrappedHandler = withSentry(vi.fn(), handler);
const waits: Promise<unknown>[] = [];
const waitUntil = vi.fn(promise => waits.push(promise));
await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, {
await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV_WITHOUT_DSN, {
waitUntil,
} as unknown as ExecutionContext);
expect(flush).not.toBeCalled();
Expand Down Expand Up @@ -871,12 +876,12 @@ describe('withSentry', () => {
addDelayedWaitUntil(_context);
return;
},
} satisfies ExportedHandler<typeof MOCK_ENV>;
} satisfies ExportedHandler<typeof MOCK_ENV_WITHOUT_DSN>;

const wrappedHandler = withSentry(vi.fn(), handler);
const waits: Promise<unknown>[] = [];
const waitUntil = vi.fn(promise => waits.push(promise));
await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, {
await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV_WITHOUT_DSN, {
waitUntil,
} as unknown as ExecutionContext);
expect(flush).not.toBeCalled();
Expand Down Expand Up @@ -1069,12 +1074,12 @@ describe('withSentry', () => {
addDelayedWaitUntil(_context);
return;
},
} satisfies ExportedHandler<typeof MOCK_ENV>;
} satisfies ExportedHandler<typeof MOCK_ENV_WITHOUT_DSN>;

const wrappedHandler = withSentry(vi.fn(), handler);
const waits: Promise<unknown>[] = [];
const waitUntil = vi.fn(promise => waits.push(promise));
await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, {
await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV_WITHOUT_DSN, {
waitUntil,
} as unknown as ExecutionContext);
expect(flush).not.toBeCalled();
Expand Down
87 changes: 75 additions & 12 deletions packages/cloudflare/test/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual(userOptions);
expect(result).toEqual(expect.objectContaining(userOptions));
});

it('merges options from env with user options', () => {
Expand All @@ -26,7 +26,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: 'user-release' });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: 'user-release' }));
});

it('uses user options when SENTRY_RELEASE exists but is not a string', () => {
Expand All @@ -35,7 +35,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual(userOptions);
expect(result).toEqual(expect.objectContaining(userOptions));
});

it('uses user options when SENTRY_RELEASE does not exist', () => {
Expand All @@ -44,7 +44,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual(userOptions);
expect(result).toEqual(expect.objectContaining(userOptions));
});

it('takes user options over env options', () => {
Expand All @@ -53,7 +53,70 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual(userOptions);
expect(result).toEqual(expect.objectContaining(userOptions));
});

it('adds debug from env when user debug is undefined', () => {
const userOptions = { dsn: 'test-dsn' };
const env = { SENTRY_DEBUG: '1' };

const result = getFinalOptions(userOptions, env);

expect(result.debug).toBe(true);
});

it('uses SENTRY_DSN from env when user dsn is undefined', () => {
const userOptions = { release: 'user-release' };
const env = { SENTRY_DSN: 'https://key@ingest.sentry.io/1' };

const result = getFinalOptions(userOptions, env);

expect(result.dsn).toBe('https://key@ingest.sentry.io/1');
expect(result.release).toBe('user-release');
});

it('uses SENTRY_ENVIRONMENT from env when user environment is undefined', () => {
const userOptions = { dsn: 'test-dsn' };
const env = { SENTRY_ENVIRONMENT: 'staging' };

const result = getFinalOptions(userOptions, env);

expect(result.environment).toBe('staging');
});

it('uses SENTRY_TRACES_SAMPLE_RATE from env when user tracesSampleRate is undefined', () => {
const userOptions = { dsn: 'test-dsn' };
const env = { SENTRY_TRACES_SAMPLE_RATE: '0.5' };

const result = getFinalOptions(userOptions, env);

expect(result.tracesSampleRate).toBe(0.5);
});

it('does not use SENTRY_TRACES_SAMPLE_RATE from env when it is gibberish', () => {
const env = { SENTRY_TRACES_SAMPLE_RATE: 'ʕっ•ᴥ•ʔっ' };

const result = getFinalOptions(undefined, env);

expect(result.tracesSampleRate).toBeUndefined();
});

it('prefers user dsn over env SENTRY_DSN', () => {
const userOptions = { dsn: 'user-dsn', release: 'user-release' };
const env = { SENTRY_DSN: 'https://env@ingest.sentry.io/1' };

const result = getFinalOptions(userOptions, env);

expect(result.dsn).toBe('user-dsn');
});

it('ignores SENTRY_DSN when not a string', () => {
const userOptions = { dsn: undefined };
const env = { SENTRY_DSN: 123 };

const result = getFinalOptions(userOptions, env);

expect(result.dsn).toBeUndefined();
});

describe('CF_VERSION_METADATA', () => {
Expand All @@ -63,7 +126,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: 'version-123' });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: 'version-123' }));
});

it('prefers SENTRY_RELEASE over CF_VERSION_METADATA.id', () => {
Expand All @@ -75,7 +138,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: 'env-release' });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: 'env-release' }));
});

it('prefers user release over CF_VERSION_METADATA.id', () => {
Expand All @@ -84,7 +147,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: 'user-release' });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: 'user-release' }));
});

it('prefers user release over both SENTRY_RELEASE and CF_VERSION_METADATA.id', () => {
Expand All @@ -96,7 +159,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: 'user-release' });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: 'user-release' }));
});

it('ignores CF_VERSION_METADATA when it is not an object', () => {
Expand All @@ -105,7 +168,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: undefined });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: undefined }));
});

it('ignores CF_VERSION_METADATA when id is not a string', () => {
Expand All @@ -114,7 +177,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: undefined });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: undefined }));
});

it('ignores CF_VERSION_METADATA when id is missing', () => {
Expand All @@ -123,7 +186,7 @@ describe('getFinalOptions', () => {

const result = getFinalOptions(userOptions, env);

expect(result).toEqual({ dsn: 'test-dsn', release: undefined });
expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: undefined }));
});
});
});
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export {
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { envToBool } from './utils/envToBool';
export { applyScopeDataToEvent, mergeScopeData, getCombinedScopeData } from './utils/scopeData';
export { prepareEvent } from './utils/prepareEvent';
export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { envToBool } from '../../src/utils/envToBool';
import { envToBool } from '../../../src/utils/envToBool';

describe('envToBool', () => {
it.each([
Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/common-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export { getRequestUrl } from './utils/getRequestUrl';
export { initializeEsmLoader } from './sdk/esmLoader';
export { isCjs } from './utils/detection';
export { createMissingInstrumentationContext } from './utils/createMissingInstrumentationContext';
export { envToBool } from './utils/envToBool';
export { makeNodeTransport, type NodeTransportOptions } from './transports';
export type { HTTPModuleRequestIncomingMessage } from './transports/http-module';
export { cron } from './cron';
Expand Down Expand Up @@ -117,6 +116,7 @@ export {
wrapMcpServerWithSentry,
featureFlagsIntegration,
metrics,
envToBool,
} from '@sentry/core';

export type {
Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/light/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
consoleIntegration,
consoleSandbox,
debug,
envToBool,
eventFiltersIntegration,
functionToStringIntegration,
getCurrentScope,
Expand All @@ -29,7 +30,6 @@ import { defaultStackParser, getSentryRelease } from '../sdk/api';
import { makeNodeTransport } from '../transports';
import type { NodeClientOptions, NodeOptions } from '../types';
import { isCjs } from '../utils/detection';
import { envToBool } from '../utils/envToBool';
import { getSpotlightConfig } from '../utils/spotlight';
import { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageStrategy';
import { LightNodeClient } from './client';
Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
consoleSandbox,
conversationIdIntegration,
debug,
envToBool,
functionToStringIntegration,
getCurrentScope,
getIntegrationsToSetup,
Expand Down Expand Up @@ -38,7 +39,6 @@ import { systemErrorIntegration } from '../integrations/systemError';
import { makeNodeTransport } from '../transports';
import type { NodeClientOptions, NodeOptions } from '../types';
import { isCjs } from '../utils/detection';
import { envToBool } from '../utils/envToBool';
import { getSpotlightConfig } from '../utils/spotlight';
import { defaultStackParser, getSentryRelease } from './api';
import { NodeClient } from './client';
Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/utils/spotlight.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { envToBool } from './envToBool';
import { envToBool } from '@sentry/core';

/**
* Parse the spotlight option with proper precedence:
Expand Down
Loading