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
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
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,
});
Original file line number Diff line number Diff line change
@@ -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(() => {});
Original file line number Diff line number Diff line change
@@ -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();
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
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<void> {
return new Promise<void>(resolve => {
http
.request(url, httpRes => {
httpRes.on('data', () => {
// we don't care about data
});
httpRes.on('end', () => {
resolve();
});
})
.end();
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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({
// The specific envelope contents are covered elsewhere; this suite is about the request headers above.
span: {},
})
.start()
.completed();
closeTestServer();
});
18 changes: 17 additions & 1 deletion docs/migration/v11-end-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,23 @@ Sentry.init({

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 a browser, neither of these matched a request to `https://myApi.com` in v10. 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

Expand Down
9 changes: 5 additions & 4 deletions packages/browser/src/tracing/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
hasSpansEnabled,
hasSpanStreamingEnabled,
instrumentFetchRequest,
matchesTracePropagationTargets,
parseUrl,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All @@ -27,7 +28,6 @@ import {
spanIsIgnored,
spanToJSON,
startInactiveSpan,
stringMatchesSomePattern,
stripDataUrlContent,
stripUrlQueryAndFragment,
timestampInSeconds,
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -281,7 +282,7 @@ export function shouldAttachHeaders(
if (!tracePropagationTargets) {
return isRelativeSameOriginRequest;
} else {
return stringMatchesSomePattern(targetUrl, tracePropagationTargets);
return matchesTracePropagationTargets(targetUrl, tracePropagationTargets);
}
} else {
let resolvedUrl;
Expand All @@ -300,8 +301,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))
);
}
}
Expand Down
17 changes: 17 additions & 0 deletions packages/browser/test/tracing/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
});
Expand Down
17 changes: 2 additions & 15 deletions packages/bun/src/integrations/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
instrumentFetchRequest,
isSentryRequestUrl,
LRUMap,
stringMatchesSomePattern,
shouldPropagateTraceForUrl,
} from '@sentry/core';

const INTEGRATION_NAME = 'Fetch' as const;
Expand Down Expand Up @@ -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 */
Expand Down
17 changes: 2 additions & 15 deletions packages/cloudflare/src/integrations/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
instrumentFetchRequest,
isSentryRequestUrl,
LRUMap,
stringMatchesSomePattern,
shouldPropagateTraceForUrl,
} from '@sentry/core';

const INTEGRATION_NAME = 'Fetch' as const;
Expand Down Expand Up @@ -53,20 +53,7 @@ const _fetchIntegration = ((options: Partial<Options> = {}) => {
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 */
Expand Down
4 changes: 4 additions & 0 deletions packages/cloudflare/test/integrations/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,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';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
*
* 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`:
Expand Down
51 changes: 49 additions & 2 deletions packages/core/src/utils/tracePropagationTargets.ts
Original file line number Diff line number Diff line change
@@ -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<RegExp, RegExp>();

/**
* 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`.
Expand All @@ -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);
Expand Down
Loading
Loading