diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9577eb15aff7..94b1b007c912 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -454,7 +454,7 @@ jobs: with: node-version-file: 'package.json' - name: Set up Deno - uses: denoland/setup-deno@v2.0.2 + uses: denoland/setup-deno@v2.0.3 with: deno-version: v2.1.5 - name: Restore caches @@ -886,6 +886,7 @@ jobs: - uses: pnpm/action-setup@v4 with: version: 9.4.0 + # TODO: Remove this once the repo is bumped to 20.19.2 or higher - name: Set up Node for Angular 20 if: matrix.test-application == 'angular-20' uses: actions/setup-node@v4 diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 53c561e96828..27abae270d73 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -69,6 +69,9 @@ jobs: fail-fast: false matrix: include: + - test-application: 'angular-20' + build-command: 'test:build-canary' + label: 'angular-20 (next)' - test-application: 'create-react-app' build-command: 'test:build-canary' label: 'create-react-app (canary)' @@ -123,8 +126,14 @@ jobs: - uses: pnpm/action-setup@v4 with: version: 9.4.0 - + # TODO: Remove this once the repo is bumped to 20.19.2 or higher + - name: Set up Node for Angular 20 + if: matrix.test-application == 'angular-20' + uses: actions/setup-node@v4 + with: + node-version: '20.19.2' - name: Set up Node + if: matrix.test-application != 'angular-20' uses: actions/setup-node@v4 with: node-version-file: 'dev-packages/e2e-tests/package.json' diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ba2260207c..abcab70edde8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,31 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.25.0 + +### Important Changes + +- **feat(browser): Add option to ignore `mark` and `measure` spans ([#16443](https://github.com/getsentry/sentry-javascript/pull/16443))** + +This release adds an option to `browserTracingIntegration` that lets you ignore +`mark` and `measure` spans created from the `performance.mark(...)` and `performance.measure(...)` browser APIs: + +```js +Sentry.init({ + integrations: [ + Sentry.browserTracingIntegration({ + ignorePerformanceApiSpans: ['measure-to-ignore', /mark-to-ignore/], + }), + ], +}); +``` + +### Other Changes + +- feat(browser): Export getTraceData from the browser sdks ([#16433](https://github.com/getsentry/sentry-javascript/pull/16433)) +- feat(node): Add `includeServerName` option ([#16442](https://github.com/getsentry/sentry-javascript/pull/16442)) +- fix(nuxt): Remove setting `@sentry/nuxt` external ([#16444](https://github.com/getsentry/sentry-javascript/pull/16444)) + ## 9.24.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js new file mode 100644 index 000000000000..409d1e4e7906 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + ignorePerformanceApiSpans: ['measure-ignore', /mark-i/], + idleTimeout: 9000, + }), + ], + tracesSampleRate: 1, +}); + +performance.mark('mark-pass'); +performance.mark('mark-ignore'); +performance.measure('measure-pass'); +performance.measure('measure-ignore'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/test.ts new file mode 100644 index 000000000000..6c1348b3185f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/test.ts @@ -0,0 +1,47 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest( + 'should ignore mark and measure spans that match `ignorePerformanceApiSpans`', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('**/path/to/script.js', (route: Route) => + route.fulfill({ path: `${__dirname}/assets/script.js` }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const transactionRequestPromise = waitForTransactionRequest( + page, + evt => evt.type === 'transaction' && evt.contexts?.trace?.op === 'pageload', + ); + + await page.goto(url); + + const transactionEvent = envelopeRequestParser(await transactionRequestPromise); + const markAndMeasureSpans = transactionEvent.spans?.filter(({ op }) => op && ['mark', 'measure'].includes(op)); + + expect(markAndMeasureSpans?.length).toBe(3); + expect(markAndMeasureSpans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'mark-pass', + op: 'mark', + }), + expect.objectContaining({ + description: 'measure-pass', + op: 'measure', + }), + expect.objectContaining({ + description: 'sentry-tracing-init', + op: 'mark', + }), + ]), + ); + }, +); diff --git a/dev-packages/e2e-tests/test-applications/angular-20/package.json b/dev-packages/e2e-tests/test-applications/angular-20/package.json index 9493ff799f99..34ce69c6ea44 100644 --- a/dev-packages/e2e-tests/test-applications/angular-20/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-20/package.json @@ -10,6 +10,7 @@ "watch": "ng build --watch --configuration development", "test": "playwright test", "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add @angular/animations@next @angular/common@next @angular/compiler@next @angular/core@next @angular/forms@next @angular/platform-browser@next @angular/platform-browser-dynamic@next @angular/router@next && pnpm add -D @angular-devkit/build-angular@next @angular/cli@next @angular/compiler-cli@next && pnpm build", "test:assert": "playwright test", "clean": "npx rimraf .angular node_modules pnpm-lock.yaml dist" }, diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json index 4de665edda9b..56c23e38522e 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json @@ -15,7 +15,7 @@ "@sentry/core": "latest || *", "@sentry/opentelemetry": "latest || *", "@types/node": "^18.19.1", - "fastify": "4.29.0", + "fastify": "4.29.1", "typescript": "5.6.3", "ts-node": "10.9.2" }, diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index d5ca039c65f0..7695802941a6 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, spanToJSON, + stringMatchesSomePattern, } from '@sentry/core'; import { WINDOW } from '../types'; import { trackClsAsStandaloneSpan } from './cls'; @@ -307,6 +308,15 @@ interface AddPerformanceEntriesOptions { * Default: [] */ ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; + + /** + * Performance spans created from browser Performance APIs, + * `performance.mark(...)` nand `performance.measure(...)` + * with `name`s matching strings in the array will not be emitted. + * + * Default: [] + */ + ignorePerformanceApiSpans: Array; } /** Add performance related spans to a transaction */ @@ -346,7 +356,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries case 'mark': case 'paint': case 'measure': { - _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + _addMeasureSpans(span, entry, startTime, duration, timeOrigin, options.ignorePerformanceApiSpans); // capture web vitals const firstHidden = getVisibilityWatcher(); @@ -440,7 +450,15 @@ export function _addMeasureSpans( startTime: number, duration: number, timeOrigin: number, + ignorePerformanceApiSpans: AddPerformanceEntriesOptions['ignorePerformanceApiSpans'], ): void { + if ( + ['mark', 'measure'].includes(entry.entryType) && + stringMatchesSomePattern(entry.name, ignorePerformanceApiSpans) + ) { + return; + } + const navEntry = getNavigationEntry(false); const requestTime = msToSec(navEntry ? navEntry.requestStart : 0); // Because performance.measure accepts arbitrary timestamps it can produce diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index 87646a690f0e..50dcfd65a528 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -76,7 +76,7 @@ describe('_addMeasureSpans', () => { const startTime = 23; const duration = 356; - _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + _addMeasureSpans(span, entry, startTime, duration, timeOrigin, []); expect(spans).toHaveLength(1); expect(spanToJSON(spans[0]!)).toEqual( @@ -112,10 +112,75 @@ describe('_addMeasureSpans', () => { const startTime = 23; const duration = -50; - _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + _addMeasureSpans(span, entry, startTime, duration, timeOrigin, []); expect(spans).toHaveLength(0); }); + + it('ignores performance spans that match ignorePerformanceApiSpans', () => { + const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const entries: PerformanceEntry[] = [ + { + entryType: 'measure', + name: 'measure-pass', + duration: 10, + startTime: 12, + toJSON: () => ({}), + }, + { + entryType: 'measure', + name: 'measure-ignore', + duration: 10, + startTime: 12, + toJSON: () => ({}), + }, + { + entryType: 'mark', + name: 'mark-pass', + duration: 0, + startTime: 12, + toJSON: () => ({}), + }, + { + entryType: 'mark', + name: 'mark-ignore', + duration: 0, + startTime: 12, + toJSON: () => ({}), + }, + { + entryType: 'paint', + name: 'mark-ignore', + duration: 0, + startTime: 12, + toJSON: () => ({}), + }, + ]; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + entries.forEach(e => { + _addMeasureSpans(pageloadSpan, e, startTime, duration, timeOrigin, ['measure-i', /mark-ign/]); + }); + + expect(spans).toHaveLength(3); + expect(spans.map(spanToJSON)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ description: 'measure-pass', op: 'measure' }), + expect.objectContaining({ description: 'mark-pass', op: 'mark' }), + // name matches but type is not (mark|measure) => should not be ignored + expect.objectContaining({ description: 'mark-ignore', op: 'paint' }), + ]), + ); + }); }); describe('_addResourceSpans', () => { diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 27e1ea34b8b7..51e26fdf95b9 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -39,6 +39,7 @@ export { setCurrentClient, Scope, continueTrace, + getTraceData, suppressTracing, SDK_VERSION, setContext, diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 3f38bdb6a8be..1ba733ac4ca8 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -149,7 +149,42 @@ export interface BrowserTracingOptions { * * Default: [] */ - ignoreResourceSpans: Array; + ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; + + /** + * Spans created from the following browser Performance APIs, + * + * - [`performance.mark(...)`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/mark) + * - [`performance.measure(...)`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure) + * + * will not be emitted if their names match strings in this array. + * + * This is useful, if you come across `mark` or `measure` spans in your Sentry traces + * that you want to ignore. For example, sometimes, browser extensions or libraries + * emit these entries on their own, which might not be relevant to your application. + * + * * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.browserTracingIntegration({ + * ignorePerformanceApiSpans: ['myMeasurement', /myMark/], + * }), + * ], + * }); + * + * // no spans will be created for these: + * performance.mark('myMark'); + * performance.measure('myMeasurement'); + * + * // spans will be created for these: + * performance.mark('authenticated'); + * performance.measure('input-duration', ...); + * ``` + * + * Default: [] - By default, all `mark` and `measure` entries are sent as spans. + */ + ignorePerformanceApiSpans: Array; /** * Link the currently started trace to a previous trace (e.g. a prior pageload, navigation or @@ -234,6 +269,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongAnimationFrame: true, enableInp: true, ignoreResourceSpans: [], + ignorePerformanceApiSpans: [], linkPreviousTrace: 'in-memory', consistentTraceSampling: false, _experiments: {}, @@ -277,6 +313,7 @@ export const browserTracingIntegration = ((_options: Partial { private _logOnExitFlushListener: (() => void) | undefined; public constructor(options: NodeClientOptions) { - const serverName = options.serverName || global.process.env.SENTRY_NAME || os.hostname(); + const serverName = + options.includeServerName === false + ? undefined + : options.serverName || global.process.env.SENTRY_NAME || os.hostname(); + const clientOptions: ServerRuntimeClientOptions = { ...options, platform: 'node', diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index b99235e78f05..1a2afabdc1c6 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -61,6 +61,16 @@ export interface BaseNodeOptions { */ profileLifecycle?: 'manual' | 'trace'; + /** + * If set to `false`, the SDK will not automatically detect the `serverName`. + * + * This is useful if you are using the SDK in a CLI app or Electron where the + * hostname might be considered PII. + * + * @default true + */ + includeServerName?: boolean; + /** Sets an optional server name (device name) */ serverName?: string; diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index 5511f339e8e6..f053b1ba7e0e 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -129,6 +129,18 @@ describe('NodeClient', () => { expect(event.server_name).toEqual(os.hostname()); }); + test('does not add hostname when includeServerName = false', () => { + const options = getDefaultNodeClientOptions({}); + options.includeServerName = false; + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.server_name).toBeUndefined(); + }); + test("doesn't clobber existing runtime data", () => { const options = getDefaultNodeClientOptions({ serverName: 'bar' }); const client = new NodeClient(options); diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index 83e59d439ecf..771c534705cb 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -8,7 +8,6 @@ import type { SentryNuxtModuleOptions } from '../common/types'; import { constructFunctionReExport, constructWrappedFunctionExportQuery, - getExternalOptionsWithSentryNuxt, getFilenameFromNodeStartCommand, QUERY_END_INDICATOR, removeSentryQueryFromPath, @@ -131,13 +130,6 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, debug? return { name: 'rollup-plugin-inject-sentry-server-config', - options(opts) { - return { - ...opts, - external: getExternalOptionsWithSentryNuxt(opts.external), - }; - }, - buildStart() { const configPath = createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`); diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index a0e88c892a25..f1ef1c9e4cf2 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -2,7 +2,6 @@ import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import type { Nuxt } from 'nuxt/schema'; import * as path from 'path'; -import type { ExternalOption } from 'rollup'; /** * Find the default SDK init file for the given type (client or server). @@ -73,35 +72,6 @@ export function removeSentryQueryFromPath(url: string): string { return url.replace(regex, ''); } -/** - * Add @sentry/nuxt to the external options of the Rollup configuration to prevent Rollup bundling all dependencies - * that would result in adding imports from OpenTelemetry libraries etc. to the server build. - */ -export function getExternalOptionsWithSentryNuxt(previousExternal: ExternalOption | undefined): ExternalOption { - const sentryNuxt = /^@sentry\/nuxt$/; - let external: ExternalOption; - - if (typeof previousExternal === 'function') { - external = new Proxy(previousExternal, { - apply(target, thisArg, args: [string, string | undefined, boolean]) { - const [source] = args; - if (sentryNuxt.test(source)) { - return true; - } - return Reflect.apply(target, thisArg, args); - }, - }); - } else if (Array.isArray(previousExternal)) { - external = [sentryNuxt, ...previousExternal]; - } else if (previousExternal) { - external = [sentryNuxt, previousExternal]; - } else { - external = sentryNuxt; - } - - return external; -} - /** * Extracts and sanitizes function re-export and function wrap query parameters from a query string. * If it is a default export, it is not considered for re-exporting. diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 9419c5f0a545..f19ec98b4b64 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -6,7 +6,6 @@ import { constructWrappedFunctionExportQuery, extractFunctionReexportQueryParameters, findDefaultSdkInitFile, - getExternalOptionsWithSentryNuxt, getFilenameFromNodeStartCommand, QUERY_END_INDICATOR, removeSentryQueryFromPath, @@ -367,60 +366,3 @@ export { foo_sentryWrapped as foo }; expect(result).toBe(''); }); }); - -describe('getExternalOptionsWithSentryNuxt', () => { - it('should return sentryExternals when previousExternal is undefined', () => { - const result = getExternalOptionsWithSentryNuxt(undefined); - expect(result).toEqual(/^@sentry\/nuxt$/); - }); - - it('should merge sentryExternals with array previousExternal', () => { - const previousExternal = [/vue/, 'react']; - const result = getExternalOptionsWithSentryNuxt(previousExternal); - expect(result).toEqual([/^@sentry\/nuxt$/, /vue/, 'react']); - }); - - it('should create array with sentryExternals and non-array previousExternal', () => { - const previousExternal = 'vue'; - const result = getExternalOptionsWithSentryNuxt(previousExternal); - expect(result).toEqual([/^@sentry\/nuxt$/, 'vue']); - }); - - it('should create a proxy when previousExternal is a function', () => { - const mockExternalFn = vi.fn().mockReturnValue(false); - const result = getExternalOptionsWithSentryNuxt(mockExternalFn); - - expect(typeof result).toBe('function'); - expect(result).toBeInstanceOf(Function); - }); - - it('should return true from proxied function when source is @sentry/nuxt', () => { - const mockExternalFn = vi.fn().mockReturnValue(false); - const result = getExternalOptionsWithSentryNuxt(mockExternalFn); - - // @ts-expect-error - result is a function - const output = result('@sentry/nuxt', undefined, false); - expect(output).toBe(true); - expect(mockExternalFn).not.toHaveBeenCalled(); - }); - - it('should return false from proxied function and call function when source just includes @sentry/nuxt', () => { - const mockExternalFn = vi.fn().mockReturnValue(false); - const result = getExternalOptionsWithSentryNuxt(mockExternalFn); - - // @ts-expect-error - result is a function - const output = result('@sentry/nuxt/dist/index.js', undefined, false); - expect(output).toBe(false); - expect(mockExternalFn).toHaveBeenCalledWith('@sentry/nuxt/dist/index.js', undefined, false); - }); - - it('should call original function when source does not include @sentry/nuxt', () => { - const mockExternalFn = vi.fn().mockReturnValue(false); - const result = getExternalOptionsWithSentryNuxt(mockExternalFn); - - // @ts-expect-error - result is a function - const output = result('vue', undefined, false); - expect(output).toBe(false); - expect(mockExternalFn).toHaveBeenCalledWith('vue', undefined, false); - }); -});