Skip to content

Commit 4eea4c9

Browse files
committed
feat(core): Drop the server spans of managed tunnel requests
Server SDKs start the incoming request span before a framework hook gets to answer a tunnel request, so clients with the managed tunnel enabled now add the tunnel paths to `ignoreSpans`.
1 parent 469c61e commit 4eea4c9

4 files changed

Lines changed: 95 additions & 8 deletions

File tree

‎packages/core/src/client.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import type { ResolvedDataCollection } from './types/datacollection';
4141
import { createClientReportEnvelope } from './utils/clientreport';
4242
import { consoleSandbox, debug } from './utils/debug-logger';
4343
import { dsnToString, makeDsn } from './utils/dsn';
44+
import { getTunnelIgnoreSpanFilter } from './utils/tunnel';
4445
import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope';
4546
import { getPossibleEventMessages, isInternalException } from './utils/eventUtils';
4647
import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is';
@@ -271,6 +272,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
271272
DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.');
272273
}
273274

275+
const tunnelSpanFilter = options._managedTunnel && getTunnelIgnoreSpanFilter(options._managedTunnel, this._dsn);
276+
if (tunnelSpanFilter) {
277+
this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), tunnelSpanFilter];
278+
}
279+
274280
const { beforeSendSpan, traceLifecycle } = this._options;
275281
// A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle,
276282
// so a mismatch means it is silently never called.

‎packages/core/src/types/options.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export type IgnoreSpanAttributeValue = string | boolean | number | string[] | bo
9696
* At least one of the properties (`name`, `op`, or `attributes`) must be set.
9797
* If multiple are set, all must match for the span to be ignored.
9898
*/
99-
type IgnoreSpanFilter =
99+
export type IgnoreSpanFilter =
100100
| {
101101
/**
102102
* Spans with a name matching this pattern will be ignored.

‎packages/core/src/utils/tunnel.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api';
22
import { getClient } from '../currentScopes';
33
import { suppressTracing } from '../tracing/trace';
4-
import type { DsnLike } from '../types/dsn';
5-
import type { TunnelOptions } from '../types/options';
4+
import { URL_PATH } from '@sentry/conventions/attributes';
5+
import type { DsnComponents, DsnLike } from '../types/dsn';
6+
import type { IgnoreSpanFilter, TunnelOptions } from '../types/options';
7+
import { escapeStringForRegex } from '../vendor/escapeStringForRegex';
68
import { debug } from './debug-logger';
79
import { dsnToString, makeDsn } from './dsn';
810
import { parseEnvelope } from './envelope';
@@ -82,13 +84,27 @@ export function getTunnelPaths(options?: TunnelOptions): string[] {
8284
return [];
8385
}
8486

85-
if (tunnelOptions.path) {
86-
return [tunnelOptions.path];
87+
return tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns));
88+
}
89+
90+
/**
91+
* An `ignoreSpans` filter for the server spans of tunnel requests. Server SDKs start the span before a framework
92+
* hook gets to answer the request, so clients add this whenever the managed tunnel is enabled.
93+
*/
94+
export function getTunnelIgnoreSpanFilter(
95+
tunnelOptions: TunnelOptions,
96+
dsn: DsnComponents | undefined,
97+
): IgnoreSpanFilter | undefined {
98+
const allowedDsns = tunnelOptions.allowedDsns ?? (dsn ? [dsnToString(dsn)] : []);
99+
const paths = tunnelPathsFor(tunnelOptions, allowedDsns).map(escapeStringForRegex);
100+
if (!paths.length) {
101+
return undefined;
87102
}
88103

89-
return resolveAllowedDsns(tunnelOptions.allowedDsns)
90-
.map(dsn => getTunnelPath(dsn))
91-
.filter((path): path is string => !!path);
104+
const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? '');
105+
106+
// oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input
107+
return { attributes: { [URL_PATH]: new RegExp(`^(?:${basePath})?(?:${paths.join('|')})/?$`) } };
92108
}
93109

94110
/**
@@ -231,3 +247,11 @@ function fnv1a(input: string): number {
231247
// eslint-disable-next-line no-bitwise
232248
return hash >>> 0;
233249
}
250+
251+
function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array<string>): string[] {
252+
if (tunnelOptions.path) {
253+
return [tunnelOptions.path];
254+
}
255+
256+
return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path);
257+
}

‎packages/core/test/lib/utils/tunnel.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { makeDsn } from '../../../src/utils/dsn';
66
import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope';
77
import {
88
getTunnelPath,
9+
getTunnelIgnoreSpanFilter,
910
getTunnelPaths,
1011
handleTunnelRequest,
1112
handleTunnelRequestIfMatched,
@@ -360,6 +361,62 @@ describe('handleTunnelRequestIfMatched', () => {
360361
});
361362
});
362363

364+
describe('getTunnelIgnoreSpanFilter', () => {
365+
function urlPathMatcher(filter: ReturnType<typeof getTunnelIgnoreSpanFilter>): RegExp {
366+
return (filter as { attributes: { 'url.path': RegExp } }).attributes['url.path'];
367+
}
368+
369+
it("matches the tunnel path of the client's DSN, with or without a trailing slash", () => {
370+
const matcher = urlPathMatcher(getTunnelIgnoreSpanFilter({}, makeDsn(TEST_DSN)));
371+
const tunnelPath = getTunnelPath(TEST_DSN)!;
372+
373+
expect(matcher.test(tunnelPath)).toBe(true);
374+
expect(matcher.test(`${tunnelPath}/`)).toBe(true);
375+
expect(matcher.test(`${tunnelPath}x`)).toBe(false);
376+
expect(matcher.test('/api/users')).toBe(false);
377+
});
378+
379+
it('follows allowedDsns, a fixed path and the base path', () => {
380+
const otherDsn = 'https://other@o2.ingest.sentry.io/42';
381+
382+
expect(
383+
urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test(
384+
getTunnelPath(otherDsn)!,
385+
),
386+
).toBe(true);
387+
expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true);
388+
expect(
389+
urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test(
390+
`/app${getTunnelPath(TEST_DSN)}`,
391+
),
392+
).toBe(true);
393+
});
394+
395+
it('returns nothing when there is no path to match', () => {
396+
expect(getTunnelIgnoreSpanFilter({}, undefined)).toBeUndefined();
397+
});
398+
});
399+
400+
describe('client with the managed tunnel enabled', () => {
401+
it('ignores the server spans of tunnel requests and keeps user filters', () => {
402+
const client = new TestClient(
403+
getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }),
404+
);
405+
const ignoreSpans = client.getOptions().ignoreSpans!;
406+
407+
expect(ignoreSpans[0]).toBe('user-filter');
408+
expect(
409+
(ignoreSpans[1] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test(getTunnelPath(TEST_DSN)!),
410+
).toBe(true);
411+
});
412+
413+
it('leaves ignoreSpans alone without the managed tunnel', () => {
414+
const client = new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN }));
415+
416+
expect(client.getOptions().ignoreSpans).toBeUndefined();
417+
});
418+
});
419+
363420
describe('resolveServerTunnelOption', () => {
364421
it('keeps a URL as the transport tunnel', () => {
365422
expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' });

0 commit comments

Comments
 (0)