From 980cce0bce87243717b984e411a70641e428848a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 24 Aug 2026 14:54:25 +0200 Subject: [PATCH 1/5] feat(core)!: Make `tracePropagationTargets` matching case-insensitive String and regex entries in `tracePropagationTargets` now match the outgoing request URL regardless of casing. Previously the casing had to match exactly. In browsers the URL is normalized with `new URL()` first, which lower-cases the origin, so `'myApi.com'` or `/^myApi\.com/` could never match a request to `https://myApi.com`. Keep this to a dedicated matcher in core instead of changing `isMatchingPattern`, which also backs `ignoreErrors`, `denyUrls` and `ignoreTransactions`. Also drop the `g`/`y` flags on target regexes, which make `test()` stateful, so `/myApi\.com/g` previously matched only every other request. Fixes #16018 Co-Authored-By: Claude Opus 5 (1M context) --- .../caseInsensitiveTargets/init.js | 11 ++ .../caseInsensitiveTargets/subject.js | 5 + .../caseInsensitiveTargets/test.ts | 35 ++++++ .../case-insensitive/scenario.ts | 37 +++++++ .../case-insensitive/test.ts | 33 ++++++ docs/migration/v11-end-state.md | 18 +++- packages/browser/src/tracing/request.ts | 8 +- packages/browser/test/tracing/request.test.ts | 17 +++ packages/bun/src/integrations/fetch.ts | 17 +-- packages/cloudflare/src/integrations/fetch.ts | 17 +-- .../test/integrations/fetch.test.ts | 4 + packages/core/src/shared-exports.ts | 2 +- .../core/src/utils/tracePropagationTargets.ts | 51 ++++++++- .../lib/utils/tracePropagationTargets.test.ts | 102 ++++++++++++++++++ .../src/integrations/wintercg-fetch.ts | 17 +-- .../vercel-edge/test/wintercg-fetch.test.ts | 4 + 16 files changed, 325 insertions(+), 53 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts create mode 100644 packages/core/test/lib/utils/tracePropagationTargets.test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js new file mode 100644 index 000000000000..1ea0586adde2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['sentry-test-Site.example/String', /^http:\/\/sentry-test-site\.EXAMPLE\/regex/], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/subject.js new file mode 100644 index 000000000000..c043f0945780 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/subject.js @@ -0,0 +1,5 @@ +// The request URLs and the configured targets intentionally disagree on casing in both directions. +// These requests never resolve, so each is fired independently rather than chained. +fetch('http://sentry-test-Site.example/string/0').catch(() => {}); +fetch('http://sentry-test-site.example/REGEX/1').catch(() => {}); +fetch('http://sentry-test-site.example/no-match/2').catch(() => {}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/test.ts new file mode 100644 index 000000000000..96c10a678c63 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../../utils/helpers'; + +sentryTest( + 'should attach tracing headers to requests whose casing differs from tracePropagationTargets', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [, stringTargetRequest, regexTargetRequest, noMatchRequest] = await Promise.all([ + page.goto(url), + page.waitForRequest('http://sentry-test-site.example/string/0'), + page.waitForRequest('http://sentry-test-site.example/REGEX/1'), + page.waitForRequest('http://sentry-test-site.example/no-match/2'), + ]); + + expect(stringTargetRequest.headers()).toMatchObject({ + 'sentry-trace': expect.any(String), + baggage: expect.any(String), + }); + + expect(regexTargetRequest.headers()).toMatchObject({ + 'sentry-trace': expect.any(String), + baggage: expect.any(String), + }); + + const noMatchHeaders = noMatchRequest.headers(); + expect(noMatchHeaders['sentry-trace']).toBeUndefined(); + expect(noMatchHeaders['baggage']).toBeUndefined(); + }, +); diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts new file mode 100644 index 000000000000..6969690f9d8a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + // The casing here intentionally disagrees with the casing of the requested URLs below. + tracePropagationTargets: [/\/API\/Regex/, 'api/String'], + integrations: [], + transport: loggingTransport, +}); + +import * as http from 'http'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_span' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/regex`); + await makeHttpRequest(`${process.env.SERVER_URL}/API/STRING`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/no-match`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts new file mode 100644 index 000000000000..8f886bd04b3f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts @@ -0,0 +1,33 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('tracePropagationTargets match regardless of casing', async () => { + expect.assertions(9); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/regex', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/API/STRING', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/api/no-match', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: {}, + }) + .start() + .completed(); + closeTestServer(); +}); diff --git a/docs/migration/v11-end-state.md b/docs/migration/v11-end-state.md index d1f9c024e416..c2adaa51a598 100644 --- a/docs/migration/v11-end-state.md +++ b/docs/migration/v11-end-state.md @@ -520,7 +520,23 @@ Two consequences to be aware of when upgrading: Affected SDKs: All SDKs. -String and regular-expression matching for `tracePropagationTargets` is now case-insensitive. +String and regular-expression matching for `tracePropagationTargets` is now case-insensitive. Previously a target had to +match the casing of the outgoing request URL exactly. In browsers this was especially surprising, because the URL is +normalized with `new URL()` before matching, which lower-cases the origin: a target written with the same casing as the +request, such as `'myApi.com'` or `/^myApi\.com/`, could therefore never match a request to `https://myApi.com`. + +```js +Sentry.init({ + // In v10 neither of these matched a request to `https://myApi.com`. In v11 both do. + tracePropagationTargets: ['myApi.com', /^https:\/\/myApi\.com/], +}); +``` + +If you relied on case-sensitive matching to distinguish between two targets, narrow the target so it no longer depends +on casing, or use `tracePropagationTargets` in combination with a more specific path. + +As part of this, the `g` and `y` flags are ignored on `tracePropagationTargets` regular expressions. These flags made +matching stateful via `lastIndex`, so a target like `/myApi\.com/g` previously matched only every other request. ### Span attribute changes diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index c1fe9f1fe0df..77ff801e0f45 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -19,6 +19,7 @@ import { hasSpansEnabled, hasSpanStreamingEnabled, instrumentFetchRequest, + matchesTracePropagationTargets, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -27,7 +28,6 @@ import { spanIsIgnored, spanToJSON, startInactiveSpan, - stringMatchesSomePattern, stripDataUrlContent, stripUrlQueryAndFragment, timestampInSeconds, @@ -281,7 +281,7 @@ export function shouldAttachHeaders( if (!tracePropagationTargets) { return isRelativeSameOriginRequest; } else { - return stringMatchesSomePattern(targetUrl, tracePropagationTargets); + return matchesTracePropagationTargets(targetUrl, tracePropagationTargets); } } else { let resolvedUrl; @@ -300,8 +300,8 @@ export function shouldAttachHeaders( return isSameOriginRequest; } else { return ( - stringMatchesSomePattern(resolvedUrl.toString(), tracePropagationTargets) || - (isSameOriginRequest && stringMatchesSomePattern(resolvedUrl.pathname, tracePropagationTargets)) + matchesTracePropagationTargets(resolvedUrl.toString(), tracePropagationTargets) || + (isSameOriginRequest && matchesTracePropagationTargets(resolvedUrl.pathname, tracePropagationTargets)) ); } } diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index c5e2b9a859dd..4b87e9d7202a 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -294,6 +294,16 @@ describe('shouldAttachHeaders', () => { ['https://not-my-origin.com/api', 'api', true], ['https://my-origin.com?my-query', 'my-query', true], ['https://not-my-origin.com?my-query', 'my-query', true], + + // matching is case-insensitive in both directions, because `new URL()` lower-cases the origin + ['https://MY-ORIGIN.com', 'my-origin', true], + ['https://my-origin.com', 'MY-ORIGIN', true], + ['https://my-origin.com', /^https:\/\/MY-ORIGIN\.com/, true], + ['https://MY-ORIGIN.com', /^https:\/\/my-origin\.com/, true], + ['https://my-origin.com/API/my-route', '/api/', true], + ['https://my-origin.com/api/my-route', '/API/', true], + ['https://my-origin.com/API/my-route', /^\/api\//, true], + ['https://MY-ORIGIN.com', 'not-my-origin', false], // still no match on a genuinely different target ])( 'for url %j and tracePropagationTarget %j on page "https://my-origin.com/api/my-route" should return %j', (url, matcher, result) => { @@ -439,6 +449,13 @@ describe('shouldAttachHeaders', () => { ['https://not-my-origin.com/api', 'api', true], ['https://my-origin.com?my-query', 'my-query', true], ['https://not-my-origin.com?my-query', 'my-query', true], + + // matching is case-insensitive in both directions, because `new URL()` lower-cases the origin + ['https://MY-ORIGIN.com', 'my-origin', true], + ['https://my-origin.com', 'MY-ORIGIN', true], + ['https://my-origin.com', /^https:\/\/MY-ORIGIN\.com/, true], + ['https://MY-ORIGIN.com', /^https:\/\/my-origin\.com/, true], + ['https://MY-ORIGIN.com', 'not-my-origin', false], // still no match on a genuinely different target ])('for url %j and tracePropagationTarget %j should return %j', (url, matcher, result) => { expect(shouldAttachHeaders(url, [matcher])).toBe(result); }); diff --git a/packages/bun/src/integrations/fetch.ts b/packages/bun/src/integrations/fetch.ts index d002ee4875c9..b908ccaf2e25 100644 --- a/packages/bun/src/integrations/fetch.ts +++ b/packages/bun/src/integrations/fetch.ts @@ -15,7 +15,7 @@ import { instrumentFetchRequest, isSentryRequestUrl, LRUMap, - stringMatchesSomePattern, + shouldPropagateTraceForUrl, } from '@sentry/core'; const INTEGRATION_NAME = 'Fetch' as const; @@ -53,20 +53,7 @@ const _fetchIntegration = ((options: FetchOptions = {}) => { return false; } - const clientOptions = client.getOptions(); - - if (clientOptions.tracePropagationTargets === undefined) { - return true; - } - - const cachedDecision = _headersUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); - _headersUrlMap.set(url, decision); - return decision; + return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); } /** Helper that wraps shouldCreateSpanForRequest option */ diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index fab9501e19e5..585377e9d76d 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -15,7 +15,7 @@ import { instrumentFetchRequest, isSentryRequestUrl, LRUMap, - stringMatchesSomePattern, + shouldPropagateTraceForUrl, } from '@sentry/core'; const INTEGRATION_NAME = 'Fetch' as const; @@ -53,20 +53,7 @@ const _fetchIntegration = ((options: Partial = {}) => { return false; } - const clientOptions = client.getOptions(); - - if (clientOptions.tracePropagationTargets === undefined) { - return true; - } - - const cachedDecision = _headersUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); - _headersUrlMap.set(url, decision); - return decision; + return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); } /** Helper that wraps shouldCreateSpanForRequest option */ diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index 11dbaa18916b..c2cdda44d182 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -66,6 +66,10 @@ describe('WinterCGFetch instrumentation', () => { expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); + // tracePropagationTargets match regardless of casing + expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); + expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); + expect(shouldCreateSpan('http://my-website.com/')).toBe(true); expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); }); diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 91bcd6d02058..f405749e347c 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -117,7 +117,7 @@ export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnSco export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; export { getTraceData } from './utils/traceData'; -export { shouldPropagateTraceForUrl } from './utils/tracePropagationTargets'; +export { matchesTracePropagationTargets, shouldPropagateTraceForUrl } from './utils/tracePropagationTargets'; export { getTraceMetaTags } from './utils/meta'; export { debounce } from './utils/debounce'; export { uniq } from './utils/array'; diff --git a/packages/core/src/utils/tracePropagationTargets.ts b/packages/core/src/utils/tracePropagationTargets.ts index aa47e911a997..7bdb317d7e74 100644 --- a/packages/core/src/utils/tracePropagationTargets.ts +++ b/packages/core/src/utils/tracePropagationTargets.ts @@ -1,12 +1,59 @@ import { DEBUG_BUILD } from '../debug-build'; import type { CoreOptions as Options } from '../types/options'; +import type { TracePropagationTargets } from '../types/tracing'; import { debug } from './debug-logger'; +import { isRegExp, isString } from './is'; import type { LRUMap } from './lru'; -import { stringMatchesSomePattern } from './string'; const NOT_PROPAGATED_MESSAGE = '[Tracing] Not injecting trace data for url because it does not match tracePropagationTargets:'; +const NORMALIZED_REGEXP_CACHE = new WeakMap(); + +/** + * Returns an equivalent RegExp that ignores case and is safe to `test()` repeatedly. + * + * The `g` and `y` flags are dropped because they make `test()` stateful via `lastIndex`, which would make a target + * match only every other request. Results are cached since targets are matched once per outgoing request. + */ +function normalizeRegExpTarget(pattern: RegExp): RegExp { + const flags = `${pattern.flags.replace(/[gy]/g, '')}${pattern.ignoreCase ? '' : 'i'}`; + if (flags === pattern.flags) { + return pattern; + } + + const cached = NORMALIZED_REGEXP_CACHE.get(pattern); + if (cached) { + return cached; + } + + const normalizedPattern = new RegExp(pattern.source, flags); + NORMALIZED_REGEXP_CACHE.set(pattern, normalizedPattern); + return normalizedPattern; +} + +/** + * Check if a URL matches any of the given `tracePropagationTargets`. + * + * Matching is case-insensitive: URL normalization (e.g. `new URL()`) lower-cases the origin, so a target + * written with the same casing as the request (`'myApi.com'`, `/^myApi\.com/`) would otherwise never match. + */ +export function matchesTracePropagationTargets(url: string, tracePropagationTargets: TracePropagationTargets): boolean { + const lowerCaseUrl = url.toLowerCase(); + + for (const target of tracePropagationTargets) { + if (isString(target)) { + if (lowerCaseUrl.includes(target.toLowerCase())) { + return true; + } + } else if (isRegExp(target) && normalizeRegExpTarget(target).test(url)) { + return true; + } + } + + return false; +} + /** * Check if a given URL should be propagated to or not. * If no url is defined, or no trace propagation targets are defined, this will always return `true`. @@ -27,7 +74,7 @@ export function shouldPropagateTraceForUrl( return cachedDecision; } - const decision = stringMatchesSomePattern(url, tracePropagationTargets); + const decision = matchesTracePropagationTargets(url, tracePropagationTargets); decisionMap?.set(url, decision); DEBUG_BUILD && !decision && debug.log(NOT_PROPAGATED_MESSAGE, url); diff --git a/packages/core/test/lib/utils/tracePropagationTargets.test.ts b/packages/core/test/lib/utils/tracePropagationTargets.test.ts new file mode 100644 index 000000000000..b8bd4e3eb0a2 --- /dev/null +++ b/packages/core/test/lib/utils/tracePropagationTargets.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import type { TracePropagationTargets } from '../../../src/types/tracing'; +import { LRUMap } from '../../../src/utils/lru'; +import { matchesTracePropagationTargets, shouldPropagateTraceForUrl } from '../../../src/utils/tracePropagationTargets'; + +describe('matchesTracePropagationTargets', () => { + it.each([ + // string targets, matching casing + ['https://myapi.com/v1', ['myapi.com'], true], + ['https://myapi.com/v1', ['other.com'], false], + + // string targets, mismatched casing + ['https://myapi.com/v1', ['myApi.com'], true], + ['https://myApi.com/v1', ['myapi.com'], true], + ['https://MYAPI.COM/v1', ['myapi.com'], true], + ['https://myapi.com/API/v1', ['/api/'], true], + ['https://myapi.com/api/v1', ['/API/'], true], + + // regex targets, matching casing + [String.raw`https://myapi.com/v1`, [/^https:\/\/myapi\.com/], true], + [String.raw`https://myapi.com/v1`, [/^https:\/\/other\.com/], false], + + // regex targets, mismatched casing + [String.raw`https://myapi.com/v1`, [/^https:\/\/myApi\.com/], true], + [String.raw`https://myApi.com/v1`, [/^https:\/\/myapi\.com/], true], + [String.raw`https://myapi.com/API/v1`, [/\/api\//], true], + + // regexes that already ignore case keep working + [String.raw`https://myapi.com/v1`, [/^https:\/\/myAPI\.com/i], true], + + // a non-matching target stays non-matching regardless of casing + [String.raw`https://myapi.com/v1`, ['MYOTHERAPI.COM', /^https:\/\/OTHER\.com/], false], + + // mixed target lists + ['https://myapi.com/v1', ['other.com', /^https:\/\/myApi\.com/], true], + + // empty target list never matches + ['https://myapi.com/v1', [], false], + ])('for url %j and targets %j returns %j', (url, targets, expected) => { + expect(matchesTracePropagationTargets(url, targets)).toBe(expected); + }); + + it.each([[123], [null], [undefined], [{}], [() => true]])( + 'returns false instead of throwing for the unsupported target %j', + target => { + // `tracePropagationTargets` is typed as `(string | RegExp)[]`, but it is frequently set from plain JS, + // and throwing here would break the instrumented request itself. + expect(matchesTracePropagationTargets('https://myapi.com', [target] as unknown as TracePropagationTargets)).toBe( + false, + ); + }, + ); + + it('matches boxed strings', () => { + // eslint-disable-next-line no-new-wrappers + const target = new String('myApi.com') as unknown as string; + + expect(matchesTracePropagationTargets('https://myapi.com', [target])).toBe(true); + }); + + it('does not mutate the flags of the passed regex', () => { + const target = /^https:\/\/myApi\.com/; + + expect(matchesTracePropagationTargets('https://myapi.com', [target])).toBe(true); + expect(target.ignoreCase).toBe(false); + expect(target.flags).toBe(''); + }); + + it('preserves other flags when adding case insensitivity', () => { + expect(matchesTracePropagationTargets('https://myapi.com/v1', [/MYAPI\.com\/v1$/m])).toBe(true); + }); + + it('matches consistently across repeated calls for the same regex', () => { + const target = /myApi\.com/g; + + expect(matchesTracePropagationTargets('https://myapi.com/v1', [target])).toBe(true); + expect(matchesTracePropagationTargets('https://myapi.com/v2', [target])).toBe(true); + }); +}); + +describe('shouldPropagateTraceForUrl', () => { + it('propagates when no targets are defined', () => { + expect(shouldPropagateTraceForUrl('https://myapi.com', undefined)).toBe(true); + }); + + it('propagates when no url is defined', () => { + expect(shouldPropagateTraceForUrl(undefined, ['myapi.com'])).toBe(true); + }); + + it('matches case-insensitively', () => { + expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com'])).toBe(true); + expect(shouldPropagateTraceForUrl('https://myApi.com/v1', [/^https:\/\/myapi\.com/])).toBe(true); + }); + + it('caches the decision per url', () => { + const decisionMap = new LRUMap(10); + + expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com'], decisionMap)).toBe(true); + expect(decisionMap.get('https://myapi.com/v1')).toBe(true); + expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com'], decisionMap)).toBe(true); + }); +}); diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index cef56eeaaabc..217efe00df2d 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -15,7 +15,7 @@ import { instrumentFetchRequest, isSentryRequestUrl, LRUMap, - stringMatchesSomePattern, + shouldPropagateTraceForUrl, } from '@sentry/core'; const INTEGRATION_NAME = 'WinterCGFetch' as const; @@ -53,20 +53,7 @@ const _winterCGFetch = ((options: Partial = {}) => { return false; } - const clientOptions = client.getOptions(); - - if (clientOptions.tracePropagationTargets === undefined) { - return true; - } - - const cachedDecision = _headersUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); - _headersUrlMap.set(url, decision); - return decision; + return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); } /** Helper that wraps shouldCreateSpanForRequest option */ diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index d430b46df831..9d9ffcd755f4 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -66,6 +66,10 @@ describe('WinterCGFetch instrumentation', () => { expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); + // tracePropagationTargets match regardless of casing + expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); + expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); + expect(shouldCreateSpan('http://my-website.com/')).toBe(true); expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); }); From 5bce19e49872601ac47c59117d1e64252cd5408b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 24 Aug 2026 14:56:47 +0200 Subject: [PATCH 2/5] docs(core): Document case-insensitive `tracePropagationTargets` matching Note the new behaviour in the `tracePropagationTargets` JSDoc that users see in their editor, and scope the migration guide example to browsers. Server SDKs match against the raw URL, so a target matching the request's casing did work there in v10. Co-Authored-By: Claude Opus 5 (1M context) --- docs/migration/v11-end-state.md | 2 +- packages/browser/src/tracing/request.ts | 1 + packages/core/src/types/options.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/migration/v11-end-state.md b/docs/migration/v11-end-state.md index c2adaa51a598..05877c9b605f 100644 --- a/docs/migration/v11-end-state.md +++ b/docs/migration/v11-end-state.md @@ -527,7 +527,7 @@ request, such as `'myApi.com'` or `/^myApi\.com/`, could therefore never match a ```js Sentry.init({ - // In v10 neither of these matched a request to `https://myApi.com`. In v11 both do. + // In a browser, neither of these matched a request to `https://myApi.com` in v10. In v11 both do. tracePropagationTargets: ['myApi.com', /^https:\/\/myApi\.com/], }); ``` diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 77ff801e0f45..2ba87f347d67 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -65,6 +65,7 @@ export interface RequestInstrumentationOptions { * * If any of the two match any of the provided values, tracing headers will be attached to the outgoing request. * Both, the string values, and the RegExes you provide in the array will match if they partially match the URL or pathname. + * Matching is case-insensitive, so `'myApi.com'` and `/^myApi\.com/` both match a request to `https://myapi.com`. * * Examples: * - `tracePropagationTargets: [/^\/api/]` and request to `https://same-origin.com/api/posts`: diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 3821d6d050da..8ea3944f1613 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -455,6 +455,7 @@ export interface ClientOptions Date: Mon, 24 Aug 2026 16:03:44 +0200 Subject: [PATCH 3/5] test(core): Use complete hostname patterns in `tracePropagationTargets` tests CodeQL's js/incomplete-hostname-regexp flagged 16 new high-severity alerts on the test fixtures added in this branch: patterns like `/^https:\/\/myapi\.com/` and `'myapi.com'` also match `https://myapi.com.evil.com`. The rule is right to flag these. `tracePropagationTargets` decides which outgoing requests receive `sentry-trace` and `baggage` headers, so an unterminated target leaks trace context to unintended hosts, and these fixtures are what users copy. Terminate the host portion of each fixture. The assertions cover casing, not anchoring, so this does not weaken them. Co-Authored-By: Claude Opus 5 (1M context) --- packages/browser/test/tracing/request.test.ts | 8 ++-- .../lib/utils/tracePropagationTargets.test.ts | 44 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index 4b87e9d7202a..6b223740ee54 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -298,8 +298,8 @@ describe('shouldAttachHeaders', () => { // matching is case-insensitive in both directions, because `new URL()` lower-cases the origin ['https://MY-ORIGIN.com', 'my-origin', true], ['https://my-origin.com', 'MY-ORIGIN', true], - ['https://my-origin.com', /^https:\/\/MY-ORIGIN\.com/, true], - ['https://MY-ORIGIN.com', /^https:\/\/my-origin\.com/, true], + ['https://my-origin.com', /^https:\/\/MY-ORIGIN\.com\//, true], + ['https://MY-ORIGIN.com', /^https:\/\/my-origin\.com\//, true], ['https://my-origin.com/API/my-route', '/api/', true], ['https://my-origin.com/api/my-route', '/API/', true], ['https://my-origin.com/API/my-route', /^\/api\//, true], @@ -453,8 +453,8 @@ describe('shouldAttachHeaders', () => { // matching is case-insensitive in both directions, because `new URL()` lower-cases the origin ['https://MY-ORIGIN.com', 'my-origin', true], ['https://my-origin.com', 'MY-ORIGIN', true], - ['https://my-origin.com', /^https:\/\/MY-ORIGIN\.com/, true], - ['https://MY-ORIGIN.com', /^https:\/\/my-origin\.com/, true], + ['https://my-origin.com/', /^https:\/\/MY-ORIGIN\.com\//, true], + ['https://MY-ORIGIN.com/', /^https:\/\/my-origin\.com\//, true], ['https://MY-ORIGIN.com', 'not-my-origin', false], // still no match on a genuinely different target ])('for url %j and tracePropagationTarget %j should return %j', (url, matcher, result) => { expect(shouldAttachHeaders(url, [matcher])).toBe(result); diff --git a/packages/core/test/lib/utils/tracePropagationTargets.test.ts b/packages/core/test/lib/utils/tracePropagationTargets.test.ts index b8bd4e3eb0a2..22b7737f27e8 100644 --- a/packages/core/test/lib/utils/tracePropagationTargets.test.ts +++ b/packages/core/test/lib/utils/tracePropagationTargets.test.ts @@ -6,33 +6,33 @@ import { matchesTracePropagationTargets, shouldPropagateTraceForUrl } from '../. describe('matchesTracePropagationTargets', () => { it.each([ // string targets, matching casing - ['https://myapi.com/v1', ['myapi.com'], true], - ['https://myapi.com/v1', ['other.com'], false], + ['https://myapi.com/v1', ['myapi.com/'], true], + ['https://myapi.com/v1', ['other.com/'], false], // string targets, mismatched casing - ['https://myapi.com/v1', ['myApi.com'], true], - ['https://myApi.com/v1', ['myapi.com'], true], - ['https://MYAPI.COM/v1', ['myapi.com'], true], + ['https://myapi.com/v1', ['myApi.com/'], true], + ['https://myApi.com/v1', ['myapi.com/'], true], + ['https://MYAPI.COM/v1', ['myapi.com/'], true], ['https://myapi.com/API/v1', ['/api/'], true], ['https://myapi.com/api/v1', ['/API/'], true], // regex targets, matching casing - [String.raw`https://myapi.com/v1`, [/^https:\/\/myapi\.com/], true], - [String.raw`https://myapi.com/v1`, [/^https:\/\/other\.com/], false], + [String.raw`https://myapi.com/v1`, [/^https:\/\/myapi\.com\//], true], + [String.raw`https://myapi.com/v1`, [/^https:\/\/other\.com\//], false], // regex targets, mismatched casing - [String.raw`https://myapi.com/v1`, [/^https:\/\/myApi\.com/], true], - [String.raw`https://myApi.com/v1`, [/^https:\/\/myapi\.com/], true], + [String.raw`https://myapi.com/v1`, [/^https:\/\/myApi\.com\//], true], + [String.raw`https://myApi.com/v1`, [/^https:\/\/myapi\.com\//], true], [String.raw`https://myapi.com/API/v1`, [/\/api\//], true], // regexes that already ignore case keep working - [String.raw`https://myapi.com/v1`, [/^https:\/\/myAPI\.com/i], true], + [String.raw`https://myapi.com/v1`, [/^https:\/\/myAPI\.com\//i], true], // a non-matching target stays non-matching regardless of casing - [String.raw`https://myapi.com/v1`, ['MYOTHERAPI.COM', /^https:\/\/OTHER\.com/], false], + [String.raw`https://myapi.com/v1`, ['MYOTHERAPI.COM/', /^https:\/\/OTHER\.com\//], false], // mixed target lists - ['https://myapi.com/v1', ['other.com', /^https:\/\/myApi\.com/], true], + ['https://myapi.com/v1', ['other.com/', /^https:\/\/myApi\.com\//], true], // empty target list never matches ['https://myapi.com/v1', [], false], @@ -53,15 +53,15 @@ describe('matchesTracePropagationTargets', () => { it('matches boxed strings', () => { // eslint-disable-next-line no-new-wrappers - const target = new String('myApi.com') as unknown as string; + const target = new String('myApi.com/') as unknown as string; - expect(matchesTracePropagationTargets('https://myapi.com', [target])).toBe(true); + expect(matchesTracePropagationTargets('https://myapi.com/v1', [target])).toBe(true); }); it('does not mutate the flags of the passed regex', () => { - const target = /^https:\/\/myApi\.com/; + const target = /^https:\/\/myApi\.com\//; - expect(matchesTracePropagationTargets('https://myapi.com', [target])).toBe(true); + expect(matchesTracePropagationTargets('https://myapi.com/v1', [target])).toBe(true); expect(target.ignoreCase).toBe(false); expect(target.flags).toBe(''); }); @@ -71,7 +71,7 @@ describe('matchesTracePropagationTargets', () => { }); it('matches consistently across repeated calls for the same regex', () => { - const target = /myApi\.com/g; + const target = /myApi\.com\//g; expect(matchesTracePropagationTargets('https://myapi.com/v1', [target])).toBe(true); expect(matchesTracePropagationTargets('https://myapi.com/v2', [target])).toBe(true); @@ -84,19 +84,19 @@ describe('shouldPropagateTraceForUrl', () => { }); it('propagates when no url is defined', () => { - expect(shouldPropagateTraceForUrl(undefined, ['myapi.com'])).toBe(true); + expect(shouldPropagateTraceForUrl(undefined, ['myapi.com/'])).toBe(true); }); it('matches case-insensitively', () => { - expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com'])).toBe(true); - expect(shouldPropagateTraceForUrl('https://myApi.com/v1', [/^https:\/\/myapi\.com/])).toBe(true); + expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com/'])).toBe(true); + expect(shouldPropagateTraceForUrl('https://myApi.com/v1', [/^https:\/\/myapi\.com\//])).toBe(true); }); it('caches the decision per url', () => { const decisionMap = new LRUMap(10); - expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com'], decisionMap)).toBe(true); + expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com/'], decisionMap)).toBe(true); expect(decisionMap.get('https://myapi.com/v1')).toBe(true); - expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com'], decisionMap)).toBe(true); + expect(shouldPropagateTraceForUrl('https://myapi.com/v1', ['myApi.com/'], decisionMap)).toBe(true); }); }); From 5eec5915c10622a8abd4375b4727e03a5fa625a0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 24 Aug 2026 16:09:40 +0200 Subject: [PATCH 4/5] test(core): Anchor the remaining incomplete hostname pattern The `g`-flag regression test still used a pattern with no leading anchor, which CodeQL flagged because arbitrary hosts can precede it. Anchoring it keeps the test's purpose intact: with `g` preserved the anchored pattern still fails the second call, because `test()` resumes from `lastIndex` where `^` can no longer match. Verified by reverting the flag-stripping, which fails this test alone. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/test/lib/utils/tracePropagationTargets.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/lib/utils/tracePropagationTargets.test.ts b/packages/core/test/lib/utils/tracePropagationTargets.test.ts index 22b7737f27e8..6e7d973e115a 100644 --- a/packages/core/test/lib/utils/tracePropagationTargets.test.ts +++ b/packages/core/test/lib/utils/tracePropagationTargets.test.ts @@ -71,7 +71,7 @@ describe('matchesTracePropagationTargets', () => { }); it('matches consistently across repeated calls for the same regex', () => { - const target = /myApi\.com\//g; + const target = /^https:\/\/myApi\.com\//g; expect(matchesTracePropagationTargets('https://myapi.com/v1', [target])).toBe(true); expect(matchesTracePropagationTargets('https://myapi.com/v2', [target])).toBe(true); From d99ffd37c7705fa5117ebabe16f261a3e10bd505 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 25 Aug 2026 09:46:16 +0200 Subject: [PATCH 5/5] test: Exercise `tracePropagationTargets` casing on the default trace lifecycle The new suites were copied from neighbouring ones that pin `traceLifecycle: 'static'`, so they covered the non-default path. Span streaming is the default in v11, which is what these should exercise. The node envelope assertion moves from `transaction` to `span`, since streaming emits span v2 items rather than transaction events. Both suites were re-confirmed to fail without the fix under streaming. Co-Authored-By: Claude Opus 5 (1M context) --- .../tracePropagationTargets/caseInsensitiveTargets/init.js | 1 - .../tracePropagationTargets/case-insensitive/scenario.ts | 1 - .../tracing/tracePropagationTargets/case-insensitive/test.ts | 3 ++- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js index 1ea0586adde2..602ff7507dc2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/caseInsensitiveTargets/init.js @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/browser'; window.Sentry = Sentry; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], tracePropagationTargets: ['sentry-test-Site.example/String', /^http:\/\/sentry-test-site\.EXAMPLE\/regex/], diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts index 6969690f9d8a..8456ec50072c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/scenario.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts index 8f886bd04b3f..46eba247aa1b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/case-insensitive/test.ts @@ -25,7 +25,8 @@ test('tracePropagationTargets match regardless of casing', async () => { await createRunner(__dirname, 'scenario.ts') .withEnv({ SERVER_URL }) .expect({ - transaction: {}, + // The specific envelope contents are covered elsewhere; this suite is about the request headers above. + span: {}, }) .start() .completed();