diff --git a/CHANGELOG.md b/CHANGELOG.md index 614883faa7eb..0d17a94e4e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,82 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.13.0 + +### Important Changes + +- **feat(browser): Add option to explicitly end pageload span via `reportPageLoaded()` ([#17697](https://github.com/getsentry/sentry-javascript/pull/17697))** + + With this release you can take manual control of ending the pageload span. Usually this span is ended automatically by the SDK, based on a period of inactivity after the initial page was loaded in the browser. If you want full control over the pageload duration, you can tell Sentry, when your page was fully loaded: + + ```js + Sentry.init({ + //... + integrations: [ + // 1. Enable manual pageload reporting + Sentry.browserTracingIntegration({ enableReportPageLoaded: true }), + ], + }); + + // 2. Whenever you decide the page is loaded, call: + Sentry.reportPageLoaded(); + ``` + + Note that if `Sentry.reportPageLoaded()` is not called within 30 seconds of the initial pageload (or whatever value the `finalTimeout` option is set to), the pageload span will be ended automatically. + +- **feat(core,node): Add instrumentation for `GoogleGenerativeAI` ([#17625](https://github.com/getsentry/sentry-javascript/pull/17625))** + + The SDK now automatically instruments the `@google/generative-ai` package to provide insights into your AI operations. + +- **feat(nextjs): Promote `useRunAfterProductionCompileHook` to non-experimental build option ([#17721](https://github.com/getsentry/sentry-javascript/pull/17721))** + + The `useRunAfterProductionCompileHook` option is no longer experimental and is now a stable build option for Next.js projects. + +- **feat(nextjs): Use `afterProductionCompile` hook for webpack builds ([#17655](https://github.com/getsentry/sentry-javascript/pull/17655))** + + Next.js projects using webpack can opt-in to use the `useRunAfterProductionCompileHook` hook for source map uploads. + +- **feat(nextjs): Flip default value for `useRunAfterProductionCompileHook` for Turbopack builds ([#17722](https://github.com/getsentry/sentry-javascript/pull/17722))** + + The `useRunAfterProductionCompileHook` option is now enabled by default for Turbopack builds, enabling automated source map uploads. + +- **feat(node): Do not drop 300 and 304 status codes by default ([#17686](https://github.com/getsentry/sentry-javascript/pull/17686))** + + HTTP transactions with 300 and 304 status codes are now captured by default, providing better visibility into redirect and caching behavior. + +### Other Changes + +- feat(core): Add logger to core and allow scope to be passed log methods ([#17698](https://github.com/getsentry/sentry-javascript/pull/17698)) +- feat(core): Allow to pass `onSuccess` to `handleCallbackErrors` ([#17679](https://github.com/getsentry/sentry-javascript/pull/17679)) +- feat(core): Create template attributes in `consoleLoggingIntegration` ([#17703](https://github.com/getsentry/sentry-javascript/pull/17703)) +- feat(deps): bump @sentry/cli from 2.52.0 to 2.53.0 ([#17652](https://github.com/getsentry/sentry-javascript/pull/17652)) +- feat(node): Add extra platforms to `os` context ([#17720](https://github.com/getsentry/sentry-javascript/pull/17720)) +- fix(browser): Ensure idle span duration is adjusted when child spans are ignored ([#17700](https://github.com/getsentry/sentry-javascript/pull/17700)) +- fix(core): Ensure builtin stack frames don't affect `thirdPartyErrorFilterIntegration` ([#17693](https://github.com/getsentry/sentry-javascript/pull/17693)) +- fix(core): Fix client hook edge cases around multiple callbacks ([#17706](https://github.com/getsentry/sentry-javascript/pull/17706)) +- fix(nextjs): Enable fetch span when OTel setup is skipped ([#17699](https://github.com/getsentry/sentry-javascript/pull/17699)) +- fix(node): Fix `this` context for vercel AI instrumentation ([#17681](https://github.com/getsentry/sentry-javascript/pull/17681)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#17725](https://github.com/getsentry/sentry-javascript/pull/17725)) +- chore: Add link to build and test icon in readme ([#17719](https://github.com/getsentry/sentry-javascript/pull/17719)) +- chore(nuxt): Bump Vite and Rollup plugins ([#17671](https://github.com/getsentry/sentry-javascript/pull/17671)) +- chore(repo): Add changelog entry for `reportPageLoaded` ([#17724](https://github.com/getsentry/sentry-javascript/pull/17724)) +- ci: Fix lookup of changed E2E test apps ([#17707](https://github.com/getsentry/sentry-javascript/pull/17707)) +- ci(test-matrix): Add logs for `getTestMatrix` ([#17673](https://github.com/getsentry/sentry-javascript/pull/17673)) +- ref: Avoid some usage of `SyncPromise` where not needed ([#17641](https://github.com/getsentry/sentry-javascript/pull/17641)) +- ref(core): Add debug log when dropping a span via `ignoreSpans` ([#17692](https://github.com/getsentry/sentry-javascript/pull/17692)) +- ref(core): Avoid looking up anthropic-ai integration options ([#17694](https://github.com/getsentry/sentry-javascript/pull/17694)) +- ref(core): Streamline `module_metadata` assignment and cleanup functions ([#17696](https://github.com/getsentry/sentry-javascript/pull/17696)) +- ref(remix): Avoid unnecessary error wrapping `HandleDocumentRequestFunction` ([#17680](https://github.com/getsentry/sentry-javascript/pull/17680)) +- Revert "[Gitflow] Merge master into develop" + +
+ +Work in this release was contributed by @Olexandr88. Thank you for your contribution! + ## 10.12.0 ### Important Changes diff --git a/README.md b/README.md index 8b22dafb0c63..5f76eb4f7a11 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ _Bad software is everywhere, and we're tired of it. Sentry is on a mission to he faster, so we can get back to enjoying technology. If you want to join us [**Check out our open positions**](https://sentry.io/careers/)_ -![Build & Test](https://github.com/getsentry/sentry-javascript/workflows/CI:%20Build%20&%20Test/badge.svg) +[![Build & Test](https://github.com/getsentry/sentry-javascript/workflows/CI:%20Build%20&%20Test/badge.svg)](https://github.com/getsentry/sentry-javascript/actions) [![codecov](https://codecov.io/gh/getsentry/sentry-javascript/branch/develop/graph/badge.svg)](https://codecov.io/gh/getsentry/sentry-javascript) [![npm version](https://img.shields.io/npm/v/@sentry/core.svg)](https://www.npmjs.com/package/@sentry/core) [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) diff --git a/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/init.js b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/init.js new file mode 100644 index 000000000000..9b8269790d41 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/init.js @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/browser'; +// eslint-disable-next-line import/no-duplicates +import { thirdPartyErrorFilterIntegration } from '@sentry/browser'; +// eslint-disable-next-line import/no-duplicates +import { captureConsoleIntegration } from '@sentry/browser'; + +// This is the code the bundler plugin would inject to mark the init bundle as a first party module: +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:my-app': true, + }, +); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + thirdPartyErrorFilterIntegration({ behaviour: 'apply-tag-if-contains-third-party-frames', filterKeys: ['my-app'] }), + captureConsoleIntegration({ levels: ['error'], handled: false }), + ], + attachStacktrace: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/subject.js b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/subject.js new file mode 100644 index 000000000000..0a70d1f25c42 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/subject.js @@ -0,0 +1,28 @@ +// This is the code the bundler plugin would inject to mark the subject bundle as a first party module: +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:my-app': true, + }, +); + +const errorBtn = document.getElementById('errBtn'); +errorBtn.addEventListener('click', async () => { + Promise.allSettled([Promise.reject('I am a first party Error')]).then(values => + values.forEach(value => { + if (value.status === 'rejected') console.error(value.reason); + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/template.html b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/template.html new file mode 100644 index 000000000000..25a91142be08 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/test.ts b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/test.ts new file mode 100644 index 000000000000..9b918e8d1170 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; + +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode because +// thirdPartyErrorFilterIntegration is only available in the NPM package +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + +sentryTest('tags event if contains at least one third-party frame', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const errorEventPromise = waitForErrorRequest(page, e => { + return e.exception?.values?.[0]?.value === 'I am a third party Error'; + }); + + await page.route('**/thirdPartyScript.js', route => + route.fulfill({ + status: 200, + body: readFileSync(join(__dirname, 'thirdPartyScript.js')), + }), + ); + + await page.goto(url); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + expect(errorEvent.tags?.third_party_code).toBe(true); +}); + +/** + * This test seems a bit more complicated than necessary but this is intentional: + * When using `captureConsoleIntegration` in combination with `thirdPartyErrorFilterIntegration` + * and `attachStacktrace: true`, the stack trace includes native code stack frames which previously broke + * the third party error filtering logic. + * + * see https://github.com/getsentry/sentry-javascript/issues/17674 + */ +sentryTest( + "doesn't tag event if doesn't contain third-party frames", + async ({ getLocalTestUrl, page, browserName }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const errorEventPromise = waitForErrorRequest(page, e => { + return e.exception?.values?.[0]?.value === 'I am a first party Error'; + }); + + await page.route('**/thirdPartyScript.js', route => + route.fulfill({ + status: 200, + body: readFileSync(join(__dirname, 'thirdPartyScript.js')), + }), + ); + + await page.goto(url); + + await page.click('#errBtn'); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + + expect(errorEvent.tags?.third_party_code).toBeUndefined(); + + // ensure the stack trace includes native code stack frames which previously broke + // the third party error filtering logic + if (browserName === 'chromium') { + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: '', + function: 'Array.forEach', + in_app: true, + }); + } else if (browserName === 'webkit') { + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: '[native code]', + function: 'forEach', + in_app: true, + }); + } + }, +); diff --git a/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/thirdPartyScript.js b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/thirdPartyScript.js new file mode 100644 index 000000000000..e6a2e35dba01 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/thirdPartyScript.js @@ -0,0 +1,3 @@ +setTimeout(() => { + throw new Error('I am a third party Error'); +}, 100); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/subject.js b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/subject.js index 6974f191b76b..1c8c628b1358 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/subject.js @@ -13,4 +13,12 @@ console.log('Mixed:', 'prefix', { obj: true }, [4, 5, 6], 'suffix'); console.log(''); +// Test console substitution patterns (should NOT generate template attributes) +console.log('String substitution %s %d', 'test', 42); +console.log('Object substitution %o', { key: 'value' }); + +// Test multiple arguments without substitutions (should generate template attributes) +console.log('first', 0, 1, 2); +console.log('hello', true, null, undefined); + Sentry.flush(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 7561b76e8b72..442800456f9b 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -18,7 +18,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page expect(envelopeItems[0]).toEqual([ { type: 'log', - item_count: 11, + item_count: 15, content_type: 'application/vnd.sentry.items.log+json', }, { @@ -33,6 +33,9 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'console.trace {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 123, type: 'integer' }, + 'sentry.message.parameter.1': { value: false, type: 'boolean' }, }, }, { @@ -45,6 +48,9 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'console.debug {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 123, type: 'integer' }, + 'sentry.message.parameter.1': { value: false, type: 'boolean' }, }, }, { @@ -57,6 +63,9 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'console.log {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 123, type: 'integer' }, + 'sentry.message.parameter.1': { value: false, type: 'boolean' }, }, }, { @@ -69,6 +78,9 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'console.info {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 123, type: 'integer' }, + 'sentry.message.parameter.1': { value: false, type: 'boolean' }, }, }, { @@ -81,6 +93,9 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'console.warn {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 123, type: 'integer' }, + 'sentry.message.parameter.1': { value: false, type: 'boolean' }, }, }, { @@ -93,6 +108,9 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'console.error {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 123, type: 'integer' }, + 'sentry.message.parameter.1': { value: false, type: 'boolean' }, }, }, { @@ -117,6 +135,8 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'Object: {}', type: 'string' }, + 'sentry.message.parameter.0': { value: '{"key":"value","nested":{"prop":123}}', type: 'string' }, }, }, { @@ -129,6 +149,8 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'Array: {}', type: 'string' }, + 'sentry.message.parameter.0': { value: '[1,2,3,"string"]', type: 'string' }, }, }, { @@ -141,6 +163,11 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 'prefix', type: 'string' }, + 'sentry.message.parameter.1': { value: '{"obj":true}', type: 'string' }, + 'sentry.message.parameter.2': { value: '[4,5,6]', type: 'string' }, + 'sentry.message.parameter.3': { value: 'suffix', type: 'string' }, }, }, { @@ -155,6 +182,62 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + level: 'info', + severity_number: 10, + trace_id: expect.any(String), + body: 'String substitution %s %d test 42', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + severity_number: 10, + trace_id: expect.any(String), + body: 'Object substitution %o {"key":"value"}', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + severity_number: 10, + trace_id: expect.any(String), + body: 'first 0 1 2', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'first {} {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: 0, type: 'integer' }, + 'sentry.message.parameter.1': { value: 1, type: 'integer' }, + 'sentry.message.parameter.2': { value: 2, type: 'integer' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + severity_number: 10, + trace_id: expect.any(String), + body: 'hello true null undefined', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'hello {} {} {}', type: 'string' }, + 'sentry.message.parameter.0': { value: true, type: 'boolean' }, + 'sentry.message.parameter.1': { value: 'null', type: 'string' }, + 'sentry.message.parameter.2': { value: '', type: 'string' }, + }, + }, ], }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/init.js new file mode 100644 index 000000000000..39a212ae5fa5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true })], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts new file mode 100644 index 000000000000..cb346c55745a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + 'waits for Sentry.reportPageLoaded() to be called when `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const pageloadEventPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const eventData = envelopeRequestParser(await pageloadEventPromise); + + const traceContextData = eventData.contexts?.trace?.data; + const spanDurationSeconds = eventData.timestamp! - eventData.start_timestamp!; + + expect(traceContextData).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + ['sentry.idle_span_finish_reason']: 'reportPageLoaded', + }); + + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2); + expect(spanDurationSeconds).toBeLessThan(3); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/init.js new file mode 100644 index 000000000000..7ec015be44dd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true, finalTimeout: 3000 })], + tracesSampleRate: 1, + debug: true, +}); + +// not calling Sentry.reportPageLoaded() on purpose! diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/test.ts new file mode 100644 index 000000000000..df90ed1443f6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/test.ts @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + 'final timeout cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const pageloadEventPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const eventData = envelopeRequestParser(await pageloadEventPromise); + + const traceContextData = eventData.contexts?.trace?.data; + const spanDurationSeconds = eventData.timestamp! - eventData.start_timestamp!; + + expect(traceContextData).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + ['sentry.idle_span_finish_reason']: 'finalTimeout', + }); + + // We wait for 3 seconds before calling Sentry.reportPageLoaded() + // the margins are to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeGreaterThan(2.5); + expect(spanDurationSeconds).toBeLessThan(3.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/init.js new file mode 100644 index 000000000000..65e9938f7985 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true, instrumentNavigation: false })], + tracesSampleRate: 1, + debug: true, +}); + +setTimeout(() => { + Sentry.startBrowserTracingNavigationSpan(Sentry.getClient(), { name: 'custom_navigation' }); +}, 1000); + +setTimeout(() => { + Sentry.reportPageLoaded(); +}, 2500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts new file mode 100644 index 000000000000..75789cdc6de9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + 'starting a navigation span cancels the pageload span even if `enableReportPageLoaded` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const pageloadEventPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const eventData = envelopeRequestParser(await pageloadEventPromise); + + const traceContextData = eventData.contexts?.trace?.data; + const spanDurationSeconds = eventData.timestamp! - eventData.start_timestamp!; + + expect(traceContextData).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + ['sentry.idle_span_finish_reason']: 'cancelled', + }); + + // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes + expect(spanDurationSeconds).toBeLessThan(1.5); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/init.js new file mode 100644 index 000000000000..385a1cdf1df5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/init.js @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 3000, + finalTimeout: 3000, + childSpanTimeout: 3000, + }), + ], + ignoreSpans: [/ignore/], + tracesSampleRate: 1, + debug: true, +}); + +const waitFor = time => new Promise(resolve => setTimeout(resolve, time)); + +Sentry.startSpanManual( + { + name: 'take-me', + }, + async span => { + await waitFor(500); + span.end(); + }, +); + +Sentry.startSpanManual( + { + name: 'ignore-me', + }, + async span => { + await waitFor(1500); + span.end(); + }, +); + +Sentry.startSpanManual( + { + name: 'ignore-me-too', + }, + async span => { + await waitFor(2500); + span.end(); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/test.ts new file mode 100644 index 000000000000..bc752c9cdf41 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/test.ts @@ -0,0 +1,29 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers'; + +sentryTest( + 'adjusts the end timestamp of the root idle span if child spans are ignored', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const eventData = envelopeRequestParser(await pageloadRequestPromise); + + const { start_timestamp: startTimestamp, timestamp: endTimestamp } = eventData; + const durationSeconds = endTimestamp! - startTimestamp!; + + const spans = eventData.spans || []; + + expect(durationSeconds).toBeGreaterThan(0); + expect(durationSeconds).toBeLessThan(1.5); + + expect(spans.some(span => span.description === 'take-me')).toBe(true); + expect(spans.some(span => span.description?.includes('ignore-me'))).toBe(false); + }, +); diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.ts index 1261e7d5b3ac..86a4bda3e701 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.ts @@ -48,10 +48,18 @@ function run(): void { const { base, head = 'HEAD', optional } = values; + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.error(`Parsed command line arguments: base=${base}, head=${head}, optional=${optional}`); + const testApplications = globSync('*/package.json', { cwd: `${__dirname}/../test-applications`, }).map(filePath => dirname(filePath)); + // For GitHub Action debugging (using stderr the 'matrix=...' output is not polluted) + // eslint-disable-next-line no-console + console.error(`Discovered ${testApplications.length} test applications: ${testApplications.join(', ')}`); + // If `--base=xxx` is defined, we only want to get test applications changed since that base // Else, we take all test applications (e.g. on push) const includedTestApplications = base @@ -137,40 +145,60 @@ function getAffectedTestApplications( additionalArgs.push(`--head=${head}`); } - const affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) - .toString() - .split('\n') - .map(line => line.trim()) - .filter(Boolean); + let affectedProjects: string[] = []; + try { + affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) + .toString() + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to compute affected projects via Nx. Running all tests instead.', error); + return testApplications; + } - // If something in e2e tests themselves are changed, check if only test applications were changed + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.error(`Nx affected projects (${affectedProjects.length}): ${JSON.stringify(affectedProjects)}`); + + // Run all test apps that have affected projects as dependencies + const testAppsToRun = new Set( + testApplications.filter(testApp => { + const sentryDependencies = getSentryDependencies(testApp); + return sentryDependencies.some(dep => affectedProjects.includes(dep)); + }), + ); + + // If something in e2e tests themselves are changed, add changed test applications as well if (affectedProjects.includes('@sentry-internal/e2e-tests')) { try { const changedTestApps = getChangedTestApps(base, head); - // Shared code was changed, run all tests if (changedTestApps === false) { - return testApplications; - } - - // Only test applications that were changed, run selectively - if (changedTestApps.size > 0) { - return testApplications.filter(testApp => changedTestApps.has(testApp)); + // Shared code was changed, run all tests + // eslint-disable-next-line no-console + console.error('Shared e2e code changed. Running all test applications.'); + testApplications.forEach(testApp => testAppsToRun.add(testApp)); + } else if (changedTestApps.size > 0) { + // Only test applications that were changed, run selectively + // eslint-disable-next-line no-console + console.error( + `Only changed test applications will run (${changedTestApps.size}): ${JSON.stringify(Array.from(changedTestApps))}`, + ); + testApplications.forEach(testApp => { + if (changedTestApps.has(testApp)) { + testAppsToRun.add(testApp); + } + }); } } catch (error) { // eslint-disable-next-line no-console - console.error('Failed to get changed files, running all tests:', error); - return testApplications; + console.error('Failed to get changed files:', error); } - - // Fall back to running all tests - return testApplications; } - return testApplications.filter(testApp => { - const sentryDependencies = getSentryDependencies(testApp); - return sentryDependencies.some(dep => affectedProjects.includes(dep)); - }); + return Array.from(testAppsToRun); } function getChangedTestApps(base: string, head?: string): false | Set { @@ -182,6 +210,10 @@ function getChangedTestApps(base: string, head?: string): false | Set { .map(line => line.trim()) .filter(Boolean); + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.error(`Changed files since ${base}${head ? `..${head}` : ''}: ${JSON.stringify(changedFiles)}`); + const changedTestApps: Set = new Set(); const testAppsPrefix = 'dev-packages/e2e-tests/test-applications/'; diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 3deeb1ae0df4..5695877ad984 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -23,7 +23,9 @@ "test:watch": "yarn test --watch" }, "dependencies": { + "@anthropic-ai/sdk": "0.63.0", "@aws-sdk/client-s3": "^3.552.0", + "@google/genai": "^1.20.0", "@hapi/hapi": "^21.3.10", "@nestjs/common": "11.1.3", "@nestjs/core": "11.1.3", diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs index 9344137a4ed3..bbbefef79148 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/node'; -import { nodeContextIntegration } from '@sentry/node-core'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ @@ -13,6 +12,12 @@ Sentry.init({ recordInputs: true, recordOutputs: true, }), - nodeContextIntegration(), ], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs index c2776c15b001..8c6bbcc3ce0a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/node'; -import { nodeContextIntegration } from '@sentry/node-core'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ @@ -8,5 +7,11 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, transport: loggingTransport, - integrations: [Sentry.anthropicAIIntegration(), nodeContextIntegration()], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs index 39f1506eb2c9..2b8a197791e2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/node'; -import { nodeContextIntegration } from '@sentry/node-core'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ @@ -8,6 +7,11 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: false, transport: loggingTransport, - // Force include the integration - integrations: [Sentry.anthropicAIIntegration(), nodeContextIntegration()], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-manual-client.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-manual-client.mjs new file mode 100644 index 000000000000..590796931315 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-manual-client.mjs @@ -0,0 +1,115 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + // Create messages object with create and countTokens methods + this.messages = { + create: this._messagesCreate.bind(this), + countTokens: this._messagesCountTokens.bind(this), + }; + + this.models = { + retrieve: this._modelsRetrieve.bind(this), + }; + } + + /** + * Create a mock message + */ + async _messagesCreate(params) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + id: 'msg_mock123', + type: 'message', + model: params.model, + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hello from Anthropic mock!', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } + + async _messagesCountTokens() { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // For countTokens, just return input_tokens + return { + input_tokens: 15, + }; + } + + async _modelsRetrieve(modelId) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // Match what the actual implementation would return + return { + id: modelId, + name: modelId, + created_at: 1715145600, + model: modelId, // Add model field to match the check in addResponseAttributes + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', + }); + + const client = instrumentAnthropicAiClient(mockClient); + + // First test: basic message completion + await client.messages.create({ + model: 'claude-3-haiku-20240307', + system: 'You are a helpful assistant.', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + temperature: 0.7, + max_tokens: 100, + }); + + // Second test: error handling + try { + await client.messages.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // Error is expected and handled + } + + // Third test: count tokens with cached tokens + await client.messages.countTokens({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + + // Fourth test: models.retrieve + await client.models.retrieve('claude-3-haiku-20240307'); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs index 590796931315..d0acf5c42b79 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs @@ -1,39 +1,40 @@ -import { instrumentAnthropicAiClient } from '@sentry/core'; +import Anthropic from '@anthropic-ai/sdk'; import * as Sentry from '@sentry/node'; +import express from 'express'; -class MockAnthropic { - constructor(config) { - this.apiKey = config.apiKey; +const PORT = 3333; - // Create messages object with create and countTokens methods - this.messages = { - create: this._messagesCreate.bind(this), - countTokens: this._messagesCountTokens.bind(this), - }; +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); - this.models = { - retrieve: this._modelsRetrieve.bind(this), - }; - } + app.post('/anthropic/v1/messages/count_tokens', (req, res) => { + res.send({ + input_tokens: 15, + }); + }); - /** - * Create a mock message - */ - async _messagesCreate(params) { - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); + app.get('/anthropic/v1/models/:model', (req, res) => { + res.send({ + id: req.params.model, + name: req.params.model, + created_at: 1715145600, + model: req.params.model, + }); + }); + + app.post('/anthropic/v1/messages', (req, res) => { + const model = req.body.model; - if (params.model === 'error-model') { - const error = new Error('Model not found'); - error.status = 404; - error.headers = { 'x-request-id': 'mock-request-123' }; - throw error; + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').send('Model not found'); + return; } - return { + res.send({ id: 'msg_mock123', type: 'message', - model: params.model, + model, role: 'assistant', content: [ { @@ -47,41 +48,20 @@ class MockAnthropic { input_tokens: 10, output_tokens: 15, }, - }; - } - - async _messagesCountTokens() { - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - // For countTokens, just return input_tokens - return { - input_tokens: 15, - }; - } - - async _modelsRetrieve(modelId) { - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - // Match what the actual implementation would return - return { - id: modelId, - name: modelId, - created_at: 1715145600, - model: modelId, // Add model field to match the check in addResponseAttributes - }; - } + }); + }); + return app.listen(PORT); } async function run() { + const server = startMockAnthropicServer(); + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const mockClient = new MockAnthropic({ + const client = new Anthropic({ apiKey: 'mock-api-key', + baseURL: `http://localhost:${PORT}/anthropic`, }); - const client = instrumentAnthropicAiClient(mockClient); - // First test: basic message completion await client.messages.create({ model: 'claude-3-haiku-20240307', @@ -110,6 +90,8 @@ async function run() { // Fourth test: models.retrieve await client.models.retrieve('claude-3-haiku-20240307'); }); + + server.close(); } run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 27a0a523b927..9c14f698bc18 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -189,6 +189,16 @@ describe('Anthropic integration', () => { ]), }; + createEsmAndCjsTests(__dirname, 'scenario-manual-client.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates anthropic related spans when manually insturmenting client', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans with sendDefaultPii: false', async () => { await createRunner() diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs new file mode 100644 index 000000000000..9823f5680be3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs @@ -0,0 +1,23 @@ +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, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.googleGenAIIntegration({ + recordInputs: true, + recordOutputs: true, + }), + ], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1beta/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs new file mode 100644 index 000000000000..fa0a1136283d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs @@ -0,0 +1,17 @@ +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, + sendDefaultPii: true, + transport: loggingTransport, + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1beta/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs new file mode 100644 index 000000000000..9bcfb96ac103 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs @@ -0,0 +1,17 @@ +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, + sendDefaultPii: false, + transport: loggingTransport, + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1beta')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs new file mode 100644 index 000000000000..cfae135b6878 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs @@ -0,0 +1,109 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const PORT = 3333; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1beta/models/:model\\:generateContent', (req, res) => { + const model = req.params.model; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + res.send({ + candidates: [ + { + content: { + parts: [ + { + text: 'Mock response from Google GenAI!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + }); + }); + + return app.listen(PORT); +} + +async function run() { + const server = startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${PORT}` }, + }); + + // Test 1: chats.create and sendMessage flow + const chat = client.chats.create({ + model: 'gemini-1.5-pro', + config: { + temperature: 0.8, + topP: 0.9, + maxOutputTokens: 150, + }, + history: [ + { + role: 'user', + parts: [{ text: 'Hello, how are you?' }], + }, + ], + }); + + await chat.sendMessage({ + message: 'Tell me a joke', + }); + + // Test 2: models.generateContent + await client.models.generateContent({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + }); + + // Test 3: Error handling + try { + await client.models.generateContent({ + model: 'error-model', + contents: [ + { + role: 'user', + parts: [{ text: 'This will fail' }], + }, + ], + }); + } catch (error) { + // Expected error + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts new file mode 100644 index 000000000000..9aa5523c61d7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -0,0 +1,205 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Google GenAI integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chats.create + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 150, + }, + description: 'chat gemini-1.5-pro create', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - chat.sendMessage (should get model from context) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', // Should get from chat context + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }, + description: 'chat gemini-1.5-pro', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Third span - models.generateContent + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }, + description: 'models gemini-1.5-flash', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Fourth span - error handling + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'error-model', + }, + description: 'models error-model', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'unknown_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chats.create with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 150, + 'gen_ai.request.messages': expect.any(String), // Should include history when recordInputs: true + }), + description: 'chat gemini-1.5-pro create', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - chat.sendMessage with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.messages': expect.any(String), // Should include message when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }), + description: 'chat gemini-1.5-pro', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Third span - models.generateContent with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }), + description: 'models gemini-1.5-flash', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Fourth span - error handling with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + }), + description: 'models error-model', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'unknown_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_WITH_OPTIONS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Check that custom options are respected + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates google genai related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates google genai related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('creates google genai related spans with custom options', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 5abf8d51633d..de4079c4b5c4 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -15,6 +15,7 @@ export { anthropicAIIntegration, // eslint-disable-next-line deprecation/deprecation anrIntegration, + googleGenAIIntegration, // eslint-disable-next-line deprecation/deprecation disableAnrDetectionForCallback, captureCheckIn, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 541f8a97a410..0cbe5879b02e 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -125,6 +125,7 @@ export { profiler, amqplibIntegration, anthropicAIIntegration, + googleGenAIIntegration, vercelAIIntegration, logger, consoleLoggingIntegration, diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 5caab5bc75cc..4012d4118ad3 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -226,21 +226,13 @@ export function listenForWebVitalReportEvents( // we only want to collect LCP if we actually navigate. Redirects should be ignored. if (!options?.isRedirect) { _runCollectorCallbackOnce('navigation'); - safeUnsubscribe(unsubscribeStartNavigation, unsubscribeAfterStartPageLoadSpan); + unsubscribeStartNavigation(); + unsubscribeAfterStartPageLoadSpan(); } }); const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => { pageloadSpanId = span.spanContext().spanId; - safeUnsubscribe(unsubscribeAfterStartPageLoadSpan); + unsubscribeAfterStartPageLoadSpan(); }); } - -/** - * Invoke a list of unsubscribers in a safe way, by deferring the invocation to the next tick. - * This is necessary because unsubscribing in sync can lead to other callbacks no longer being invoked - * due to in-place array mutation of the subscribers array on the client. - */ -function safeUnsubscribe(...unsubscribers: (() => void | undefined)[]): void { - unsubscribers.forEach(u => u && setTimeout(u, 0)); -} diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index 5da27d74cb8d..fc805c82a4e5 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -23,6 +23,8 @@ export { startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { reportPageLoaded } from './tracing/reportPageLoaded'; + export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { feedbackAsyncIntegration as feedbackAsyncIntegration, feedbackAsyncIntegration as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index 47a2a16ae06b..f77d2774c36e 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -22,6 +22,9 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; + +export { reportPageLoaded } from './tracing/reportPageLoaded'; + export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; export { replayIntegration, getReplay } from '@sentry-internal/replay'; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index 72e2a40d07a1..c32e806f1de8 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -23,6 +23,8 @@ export { startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { reportPageLoaded } from './tracing/reportPageLoaded'; + export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 35de77f611d0..f2a3e7dc179c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,11 +1,8 @@ import { feedbackAsyncIntegration } from './feedbackAsync'; import { feedbackSyncIntegration } from './feedbackSync'; -import * as logger from './log'; export * from './exports'; -export { logger }; - export { reportingObserverIntegration } from './integrations/reportingobserver'; export { httpClientIntegration } from './integrations/httpclient'; export { contextLinesIntegration } from './integrations/contextlines'; @@ -42,6 +39,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { reportPageLoaded } from './tracing/reportPageLoaded'; export type { RequestInstrumentationOptions } from './tracing/request'; export { registerSpanErrorInstrumentation, @@ -63,6 +61,7 @@ export { zodErrorsIntegration, thirdPartyErrorFilterIntegration, featureFlagsIntegration, + logger, } from '@sentry/core'; export type { Span, FeatureFlagsIntegration } from '@sentry/core'; export { makeBrowserOfflineTransport } from './transports/offline'; diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 305b1e0322a0..a79f629855d7 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -250,6 +250,23 @@ export interface BrowserTracingOptions { */ consistentTraceSampling: boolean; + /** + * If set to `true`, the pageload span will not end itself automatically, unless it + * runs until the {@link BrowserTracingOptions.finalTimeout} (30 seconds by default) is reached. + * + * Set this option to `true`, if you want full control over the pageload span duration. + * You can use `Sentry.reportPageLoaded()` to manually end the pageload span whenever convenient. + * Be aware that you have to ensure that this is always called, regardless of the chosen route + * or path in the application. + * + * @default `false`. By default, the pageload span will end itself automatically, based on + * the {@link BrowserTracingOptions.finalTimeout}, {@link BrowserTracingOptions.idleTimeout} + * and {@link BrowserTracingOptions.childSpanTimeout}. This is more convenient to use but means + * that the pageload duration can be arbitrary and might not be fully representative of a perceived + * page load time. + */ + enableReportPageLoaded: boolean; + /** * _experiments allows the user to send options to define how this integration works. * @@ -297,6 +314,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { detectRedirects: true, linkPreviousTrace: 'in-memory', consistentTraceSampling: false, + enableReportPageLoaded: false, _experiments: {}, ...defaultRequestInstrumentationOptions, }; @@ -310,7 +328,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * * We explicitly export the proper type here, as this has to be extended in some cases. */ -export const browserTracingIntegration = ((_options: Partial = {}) => { +export const browserTracingIntegration = ((options: Partial = {}) => { const latestRoute: RouteInfo = { name: undefined, source: undefined, @@ -345,18 +363,21 @@ export const browserTracingIntegration = ((_options: Partial void); let lastInteractionTimestamp: number | undefined; + let _pageloadSpan: Span | undefined; + /** Create routing idle transaction. */ function _createRouteSpan(client: Client, startSpanOptions: StartSpanOptions, makeActive = true): void { - const isPageloadTransaction = startSpanOptions.op === 'pageload'; + const isPageloadSpan = startSpanOptions.op === 'pageload'; const initialSpanName = startSpanOptions.name; const finalStartSpanOptions: StartSpanOptions = beforeStartSpan @@ -390,7 +411,7 @@ export const browserTracingIntegration = ((_options: Partial { // This will generally always be defined here, because it is set in `setup()` of the integration // but technically, it is optional, so we guard here to be extra safe @@ -415,9 +436,19 @@ export const browserTracingIntegration = ((_options: Partial { emitFinish(); }); @@ -573,7 +605,15 @@ export const browserTracingIntegration = ((_options: Partial { + if (enableReportPageLoaded && _pageloadSpan) { + _pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'reportPageLoaded'); + _pageloadSpan.end(); + } + }); }, + afterAllSetup(client) { let startingUrl: string | undefined = getLocationHref(); diff --git a/packages/browser/src/tracing/reportPageLoaded.ts b/packages/browser/src/tracing/reportPageLoaded.ts new file mode 100644 index 000000000000..2d3d4a4991a4 --- /dev/null +++ b/packages/browser/src/tracing/reportPageLoaded.ts @@ -0,0 +1,14 @@ +import type { Client } from '@sentry/core'; +import { getClient } from '@sentry/core'; + +/** + * Manually report the end of the page load, resulting in the SDK ending the pageload span. + * This only works if {@link BrowserTracingOptions.enableReportPageLoaded} is set to `true`. + * Otherwise, the pageload span will end itself based on the {@link BrowserTracingOptions.finalTimeout}, + * {@link BrowserTracingOptions.idleTimeout} and {@link BrowserTracingOptions.childSpanTimeout}. + * + * @param client - The client to use. If not provided, the global client will be used. + */ +export function reportPageLoaded(client: Client | undefined = getClient()): void { + client?.emit('endPageloadSpan'); +} diff --git a/packages/browser/test/client.test.ts b/packages/browser/test/client.test.ts index c6cbc735c8a1..c1fcac17444b 100644 --- a/packages/browser/test/client.test.ts +++ b/packages/browser/test/client.test.ts @@ -3,6 +3,7 @@ */ import * as sentryCore from '@sentry/core'; +import { Scope } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { applyDefaultOptions, BrowserClient } from '../src/client'; import { WINDOW } from '../src/helpers'; @@ -30,10 +31,12 @@ describe('BrowserClient', () => { sendClientReports: true, }), ); + const scope = new Scope(); + scope.setClient(client); // Add some logs - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, client); - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, client); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope); // Simulate visibility change to hidden if (WINDOW.document) { @@ -58,9 +61,12 @@ describe('BrowserClient', () => { it('flushes logs when page visibility changes to hidden', () => { const flushOutcomesSpy = vi.spyOn(client as any, '_flushOutcomes'); + const scope = new Scope(); + scope.setClient(client); + // Add some logs - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, client); - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, client); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope); // Simulate visibility change to hidden if (WINDOW.document) { @@ -73,9 +79,12 @@ describe('BrowserClient', () => { }); it('flushes logs on flush event', () => { + const scope = new Scope(); + scope.setClient(client); + // Add some logs - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, client); - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, client); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope); // Trigger flush event client.emit('flush'); @@ -84,8 +93,11 @@ describe('BrowserClient', () => { }); it('flushes logs after idle timeout', () => { + const scope = new Scope(); + scope.setClient(client); + // Add a log which will trigger afterCaptureLog event - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log' }, client); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log' }, scope); // Fast forward the idle timeout vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL); @@ -94,14 +106,17 @@ describe('BrowserClient', () => { }); it('resets idle timeout when new logs are captured', () => { + const scope = new Scope(); + scope.setClient(client); + // Add initial log - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, client); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope); // Fast forward part of the idle timeout vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2); // Add another log which should reset the timeout - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, client); + sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope); // Fast forward the remaining time vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2); diff --git a/packages/browser/test/log.test.ts b/packages/browser/test/log.test.ts deleted file mode 100644 index 0967d38531dd..000000000000 --- a/packages/browser/test/log.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import * as sentryCore from '@sentry/core'; -import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { init, logger } from '../src'; -import { makeSimpleTransport } from './mocks/simpletransport'; - -const dsn = 'https://53039209a22b4ec1bcc296a3c9fdecd6@sentry.io/4291'; - -// Mock the core functions -vi.mock('@sentry/core', async requireActual => { - return { - ...((await requireActual()) as any), - _INTERNAL_captureLog: vi.fn(), - _INTERNAL_flushLogsBuffer: vi.fn(), - }; -}); - -describe('Logger', () => { - // Use the mocked functions - const mockCaptureLog = vi.mocked(sentryCore._INTERNAL_captureLog); - const mockFlushLogsBuffer = vi.mocked(sentryCore._INTERNAL_flushLogsBuffer); - - beforeEach(() => { - // Reset mocks - mockCaptureLog.mockClear(); - mockFlushLogsBuffer.mockClear(); - - // Reset the global scope, isolation scope, and current scope - getGlobalScope().clear(); - getIsolationScope().clear(); - getCurrentScope().clear(); - getCurrentScope().setClient(undefined); - - // Mock setTimeout and clearTimeout - vi.useFakeTimers(); - - // Initialize with logs enabled - init({ - dsn, - transport: makeSimpleTransport, - enableLogs: true, - }); - }); - - afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - }); - - describe('Logger methods', () => { - it('should export all log methods', () => { - expect(logger).toBeDefined(); - expect(logger.trace).toBeTypeOf('function'); - expect(logger.debug).toBeTypeOf('function'); - expect(logger.info).toBeTypeOf('function'); - expect(logger.warn).toBeTypeOf('function'); - expect(logger.error).toBeTypeOf('function'); - expect(logger.fatal).toBeTypeOf('function'); - }); - - it('should call _INTERNAL_captureLog with trace level', () => { - logger.trace('Test trace message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'trace', - message: 'Test trace message', - attributes: { key: 'value' }, - severityNumber: undefined, - }); - }); - - it('should call _INTERNAL_captureLog with debug level', () => { - logger.debug('Test debug message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'debug', - message: 'Test debug message', - attributes: { key: 'value' }, - severityNumber: undefined, - }); - }); - - it('should call _INTERNAL_captureLog with info level', () => { - logger.info('Test info message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test info message', - attributes: { key: 'value' }, - severityNumber: undefined, - }); - }); - - it('should call _INTERNAL_captureLog with warn level', () => { - logger.warn('Test warn message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'warn', - message: 'Test warn message', - attributes: { key: 'value' }, - severityNumber: undefined, - }); - }); - - it('should call _INTERNAL_captureLog with error level', () => { - logger.error('Test error message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'error', - message: 'Test error message', - attributes: { key: 'value' }, - severityNumber: undefined, - }); - }); - - it('should call _INTERNAL_captureLog with fatal level', () => { - logger.fatal('Test fatal message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'fatal', - message: 'Test fatal message', - attributes: { key: 'value' }, - severityNumber: undefined, - }); - }); - }); - - it('should handle parameterized strings with parameters', () => { - logger.info(logger.fmt`Hello ${'John'}, your balance is ${100}`, { userId: 123 }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: expect.objectContaining({ - __sentry_template_string__: 'Hello %s, your balance is %s', - __sentry_template_values__: ['John', 100], - }), - attributes: { - userId: 123, - }, - }); - }); - - it('should handle parameterized strings without additional attributes', () => { - logger.debug(logger.fmt`User ${'Alice'} logged in from ${'mobile'}`); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'debug', - message: expect.objectContaining({ - __sentry_template_string__: 'User %s logged in from %s', - __sentry_template_values__: ['Alice', 'mobile'], - }), - }); - }); -}); diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index baf0b5f64d14..2af3cb662689 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -2,7 +2,6 @@ * @vitest-environment jsdom */ -import type { BrowserClient } from '@sentry/browser'; import * as Sentry from '@sentry/browser'; import { describe, expect, it, vi } from 'vitest'; import type { JSSelfProfile } from '../../src/profiling/jsSelfProfiling'; @@ -36,7 +35,7 @@ describe('BrowserProfilingIntegration', () => { const flush = vi.fn().mockImplementation(() => Promise.resolve(true)); const send = vi.fn().mockImplementation(() => Promise.resolve()); - Sentry.init({ + const client = Sentry.init({ tracesSampleRate: 1, profilesSampleRate: 1, environment: 'test-environment', @@ -50,13 +49,11 @@ describe('BrowserProfilingIntegration', () => { integrations: [Sentry.browserTracingIntegration(), Sentry.browserProfilingIntegration()], }); - const client = Sentry.getClient(); - const currentTransaction = Sentry.getActiveSpan(); expect(currentTransaction).toBeDefined(); expect(Sentry.spanToJSON(currentTransaction!).op).toBe('pageload'); currentTransaction?.end(); - await client?.flush(1000); + await client!.flush(1000); expect(send).toHaveBeenCalledTimes(1); diff --git a/packages/browser/test/tracing/reportPageLoaded.test.ts b/packages/browser/test/tracing/reportPageLoaded.test.ts new file mode 100644 index 000000000000..48329b748970 --- /dev/null +++ b/packages/browser/test/tracing/reportPageLoaded.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BrowserClient, setCurrentClient } from '../../src'; +import { reportPageLoaded } from '../../src/tracing/reportPageLoaded'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; + +describe('reportPageLoaded', () => { + it('emits the endPageloadSpan event on the global client if no client is passed', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions({})); + setCurrentClient(client); + + const emitSpy = vi.spyOn(client, 'emit'); + reportPageLoaded(); + + expect(emitSpy).toHaveBeenCalledWith('endPageloadSpan'); + }); + + it('emits the endPageloadSpan event on the passed client', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions({})); + const emitSpy = vi.spyOn(client, 'emit'); + reportPageLoaded(client); + + expect(emitSpy).toHaveBeenCalledWith('endPageloadSpan'); + }); +}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index bc5bf37c0de4..b1c4854e5026 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -143,6 +143,7 @@ export { profiler, amqplibIntegration, anthropicAIIntegration, + googleGenAIIntegration, vercelAIIntegration, logger, consoleLoggingIntegration, diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index efe10ab22646..7a27846548b3 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -1,5 +1,5 @@ import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; -import { createTransport, rejectedSyncPromise, suppressTracing } from '@sentry/core'; +import { createTransport, suppressTracing } from '@sentry/core'; export interface BunTransportOptions extends BaseTransportOptions { /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ @@ -30,7 +30,7 @@ export function makeFetchTransport(options: BunTransportOptions): Transport { }); }); } catch (e) { - return rejectedSyncPromise(e); + return Promise.reject(e); } } diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index db0e0aab98a7..5a35a994b641 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -96,10 +96,9 @@ export { consoleLoggingIntegration, createConsolaReporter, featureFlagsIntegration, + logger, } from '@sentry/core'; -export * as logger from './logs/exports'; - export { withSentry } from './handler'; export { instrumentDurableObjectWithSentry } from './durableobject'; export { sentryPagesPlugin } from './pages-plugin'; diff --git a/packages/cloudflare/src/logs/exports.ts b/packages/cloudflare/src/logs/exports.ts deleted file mode 100644 index c21477e378b3..000000000000 --- a/packages/cloudflare/src/logs/exports.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Log, LogSeverityLevel, ParameterizedString } from '@sentry/core'; -import { _INTERNAL_captureLog } from '@sentry/core'; - -/** - * Capture a log with the given level. - * - * @param level - The level of the log. - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. - * @param severityNumber - The severity number of the log. - */ -function captureLog( - level: LogSeverityLevel, - message: ParameterizedString, - attributes?: Log['attributes'], - severityNumber?: Log['severityNumber'], -): void { - _INTERNAL_captureLog({ level, message, attributes, severityNumber }); -} - -/** - * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. - * - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. - * - * @example - * - * ``` - * Sentry.logger.trace('User clicked submit button', { - * buttonId: 'submit-form', - * formId: 'user-profile', - * timestamp: Date.now() - * }); - * ``` - * - * @example With template strings - * - * ``` - * Sentry.logger.trace(Sentry.logger.fmt`User ${user} navigated to ${page}`, { - * userId: '123', - * sessionId: 'abc-xyz' - * }); - * ``` - */ -export function trace(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('trace', message, attributes); -} - -/** - * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. - * - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. - * - * @example - * - * ``` - * Sentry.logger.debug('Component mounted', { - * component: 'UserProfile', - * props: { userId: 123 }, - * renderTime: 150 - * }); - * ``` - * - * @example With template strings - * - * ``` - * Sentry.logger.debug(Sentry.logger.fmt`API request to ${endpoint} failed`, { - * statusCode: 404, - * requestId: 'req-123', - * duration: 250 - * }); - * ``` - */ -export function debug(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('debug', message, attributes); -} - -/** - * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. - * - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. - * - * @example - * - * ``` - * Sentry.logger.info('User completed checkout', { - * orderId: 'order-123', - * amount: 99.99, - * paymentMethod: 'credit_card' - * }); - * ``` - * - * @example With template strings - * - * ``` - * Sentry.logger.info(Sentry.logger.fmt`User ${user} updated profile picture`, { - * userId: 'user-123', - * imageSize: '2.5MB', - * timestamp: Date.now() - * }); - * ``` - */ -export function info(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('info', message, attributes); -} - -/** - * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. - * - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. - * - * @example - * - * ``` - * Sentry.logger.warn('Browser compatibility issue detected', { - * browser: 'Safari', - * version: '14.0', - * feature: 'WebRTC', - * fallback: 'enabled' - * }); - * ``` - * - * @example With template strings - * - * ``` - * Sentry.logger.warn(Sentry.logger.fmt`API endpoint ${endpoint} is deprecated`, { - * recommendedEndpoint: '/api/v2/users', - * sunsetDate: '2024-12-31', - * clientVersion: '1.2.3' - * }); - * ``` - */ -export function warn(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('warn', message, attributes); -} - -/** - * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. - * - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. - * - * @example - * - * ``` - * Sentry.logger.error('Failed to load user data', { - * error: 'NetworkError', - * url: '/api/users/123', - * statusCode: 500, - * retryCount: 3 - * }); - * ``` - * - * @example With template strings - * - * ``` - * Sentry.logger.error(Sentry.logger.fmt`Payment processing failed for order ${orderId}`, { - * error: 'InsufficientFunds', - * amount: 100.00, - * currency: 'USD', - * userId: 'user-456' - * }); - * ``` - */ -export function error(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('error', message, attributes); -} - -/** - * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. - * - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. - * - * @example - * - * ``` - * Sentry.logger.fatal('Application state corrupted', { - * lastKnownState: 'authenticated', - * sessionId: 'session-123', - * timestamp: Date.now(), - * recoveryAttempted: true - * }); - * ``` - * - * @example With template strings - * - * ``` - * Sentry.logger.fatal(Sentry.logger.fmt`Critical system failure in ${service}`, { - * service: 'payment-processor', - * errorCode: 'CRITICAL_FAILURE', - * affectedUsers: 150, - * timestamp: Date.now() - * }); - * ``` - */ -export function fatal(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('fatal', message, attributes); -} - -export { fmt } from '@sentry/core'; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 75a0d5ed49d2..1de223b327c0 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -44,7 +44,7 @@ import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { getActiveSpan, showSpanDropWarning, spanToTraceContext } from './utils/spanUtils'; -import { rejectedSyncPromise, resolvedSyncPromise, SyncPromise } from './utils/syncpromise'; +import { rejectedSyncPromise } from './utils/syncpromise'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; @@ -137,7 +137,7 @@ export abstract class Client { private _outcomes: { [key: string]: number }; // eslint-disable-next-line @typescript-eslint/ban-types - private _hooks: Record; + private _hooks: Record>; /** * Initializes this client instance. @@ -316,16 +316,19 @@ export abstract class Client { * @returns A promise that will resolve with `true` if all events are sent before the timeout, or `false` if there are * still events in the queue when the timeout is reached. */ - public flush(timeout?: number): PromiseLike { + // @ts-expect-error - PromiseLike is a subset of Promise + public async flush(timeout?: number): PromiseLike { const transport = this._transport; - if (transport) { - this.emit('flush'); - return this._isClientDoneProcessing(timeout).then(clientFinished => { - return transport.flush(timeout).then(transportFlushed => clientFinished && transportFlushed); - }); - } else { - return resolvedSyncPromise(true); + if (!transport) { + return true; } + + this.emit('flush'); + + const clientFinished = await this._isClientDoneProcessing(timeout); + const transportFlushed = await transport.flush(timeout); + + return clientFinished && transportFlushed; } /** @@ -336,12 +339,12 @@ export abstract class Client { * @returns {Promise} A promise which resolves to `true` if the flush completes successfully before the timeout, or `false` if * it doesn't. */ - public close(timeout?: number): PromiseLike { - return this.flush(timeout).then(result => { - this.getOptions().enabled = false; - this.emit('close'); - return result; - }); + // @ts-expect-error - PromiseLike is a subset of Promise + public async close(timeout?: number): PromiseLike { + const result = await this.flush(timeout); + this.getOptions().enabled = false; + this.emit('close'); + return result; } /** @@ -603,6 +606,12 @@ export abstract class Client { ) => void, ): () => void; + /** + * A hook for the browser tracing integrations to trigger the end of a page load span. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'endPageloadSpan', callback: () => void): () => void; + /** * A hook for the browser tracing integrations to trigger after the pageload span was started. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -682,21 +691,23 @@ export abstract class Client { * Register a hook on this client. */ public on(hook: string, callback: unknown): () => void { - const hooks = (this._hooks[hook] = this._hooks[hook] || []); + const hookCallbacks = (this._hooks[hook] = this._hooks[hook] || new Set()); - // @ts-expect-error We assume the types are correct - hooks.push(callback); + // Wrap the callback in a function so that registering the same callback instance multiple + // times results in the callback being called multiple times. + // @ts-expect-error - The `callback` type is correct and must be a function due to the + // individual, specific overloads of this function. + // eslint-disable-next-line @typescript-eslint/ban-types + const uniqueCallback: Function = (...args: unknown[]) => callback(...args); + + hookCallbacks.add(uniqueCallback); // This function returns a callback execution handler that, when invoked, // deregisters a callback. This is crucial for managing instances where callbacks // need to be unregistered to prevent self-referencing in callback closures, // ensuring proper garbage collection. return () => { - // @ts-expect-error We assume the types are correct - const cbIndex = hooks.indexOf(callback); - if (cbIndex > -1) { - hooks.splice(cbIndex, 1); - } + hookCallbacks.delete(uniqueCallback); }; } @@ -797,6 +808,11 @@ export abstract class Client { traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, ): void; + /** + * Emit a hook event for browser tracing integrations to trigger the end of a page load span. + */ + public emit(hook: 'endPageloadSpan'): void; + /** * Emit a hook event for browser tracing integrations to trigger aafter the pageload span was started. */ @@ -872,18 +888,21 @@ export abstract class Client { /** * Send an envelope to Sentry. */ - public sendEnvelope(envelope: Envelope): PromiseLike { + // @ts-expect-error - PromiseLike is a subset of Promise + public async sendEnvelope(envelope: Envelope): PromiseLike { this.emit('beforeEnvelope', envelope); if (this._isEnabled() && this._transport) { - return this._transport.send(envelope).then(null, reason => { + try { + return await this._transport.send(envelope); + } catch (reason) { DEBUG_BUILD && debug.error('Error while sending envelope:', reason); return {}; - }); + } } DEBUG_BUILD && debug.error('Transport disabled'); - return resolvedSyncPromise({}); + return {}; } /* eslint-enable @typescript-eslint/unified-signatures */ @@ -938,24 +957,20 @@ export abstract class Client { * @returns A promise which will resolve to `true` if processing is already done or finishes before the timeout, and * `false` otherwise */ - protected _isClientDoneProcessing(timeout?: number): PromiseLike { - return new SyncPromise(resolve => { - let ticked: number = 0; - const tick: number = 1; + protected async _isClientDoneProcessing(timeout?: number): Promise { + let ticked = 0; - const interval = setInterval(() => { - if (this._numProcessing == 0) { - clearInterval(interval); - resolve(true); - } else { - ticked += tick; - if (timeout && ticked >= timeout) { - clearInterval(interval); - resolve(false); - } - } - }, tick); - }); + // if no timeout is provided, we wait "forever" until everything is processed + while (!timeout || ticked < timeout) { + await new Promise(resolve => setTimeout(resolve, 1)); + + if (!this._numProcessing) { + return true; + } + ticked++; + } + + return false; } /** Determines whether this SDK is enabled and a transport is present. */ @@ -1345,6 +1360,7 @@ function processBeforeSend( if (droppedSpans) { client.recordDroppedEvent('before_send', 'span', droppedSpans); } + processedEvent.spans = processedSpans; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b971aa8b43a3..631181ccacc8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,7 +101,7 @@ export { functionToStringIntegration } from './integrations/functiontostring'; export { inboundFiltersIntegration } from './integrations/eventFilters'; export { eventFiltersIntegration } from './integrations/eventFilters'; export { linkedErrorsIntegration } from './integrations/linkederrors'; -export { moduleMetadataIntegration } from './integrations/metadata'; +export { moduleMetadataIntegration } from './integrations/moduleMetadata'; export { requestDataIntegration } from './integrations/requestdata'; export { captureConsoleIntegration } from './integrations/captureconsole'; export { dedupeIntegration } from './integrations/dedupe'; @@ -122,7 +122,8 @@ export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; export { captureFeedback } from './feedback'; export type { ReportDialogOptions } from './report-dialog'; -export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/exports'; +export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/internal'; +export * as logger from './logs/public-api'; export { consoleLoggingIntegration } from './logs/console-integration'; export { createConsolaReporter } from './integrations/consola'; export { addVercelAiProcessors } from './utils/vercel-ai'; @@ -131,6 +132,8 @@ export { instrumentOpenAiClient } from './utils/openai'; export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants'; export { instrumentAnthropicAiClient } from './utils/anthropic-ai'; export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; +export { instrumentGoogleGenAIClient } from './utils/google-genai'; +export { GOOGLE_GENAI_INTEGRATION_NAME } from './utils/google-genai/constants'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; export type { AnthropicAiClient, @@ -138,6 +141,12 @@ export type { AnthropicAiInstrumentedMethod, AnthropicAiResponse, } from './utils/anthropic-ai/types'; +export type { + GoogleGenAIClient, + GoogleGenAIChat, + GoogleGenAIOptions, + GoogleGenAIIstrumentedMethod, +} from './utils/google-genai/types'; export type { FeatureFlag } from './utils/featureFlags'; export { @@ -207,6 +216,7 @@ export { basename, dirname, isAbsolute, join, normalizePath, relative, resolve } export { makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; export type { PromiseBuffer } from './utils/promisebuffer'; export { severityLevelFromString } from './utils/severity'; +export { replaceExports } from './utils/exports'; export { UNKNOWN_FUNCTION, createStackParser, diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index cdae9efa17dc..1caa7d2f212f 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -1,6 +1,6 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; -import { _INTERNAL_captureLog } from '../logs/exports'; +import { _INTERNAL_captureLog } from '../logs/internal'; import { formatConsoleArgs } from '../logs/utils'; import type { LogSeverityLevel } from '../types-hoist/log'; diff --git a/packages/core/src/integrations/metadata.ts b/packages/core/src/integrations/moduleMetadata.ts similarity index 100% rename from packages/core/src/integrations/metadata.ts rename to packages/core/src/integrations/moduleMetadata.ts diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index 1a0628359f5b..53739c9efd2d 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -42,7 +42,6 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti name: 'ThirdPartyErrorsFilter', setup(client) { // We need to strip metadata from stack frames before sending them to Sentry since these are client side only. - // TODO(lforst): Move this cleanup logic into a more central place in the SDK. client.on('beforeEnvelope', envelope => { forEachEnvelopeItem(envelope, (item, type) => { if (type === 'event') { @@ -108,8 +107,9 @@ function getBundleKeysForAllFramesWithFilenames(event: Event): string[][] | unde return ( frames - // Exclude frames without a filename since these are likely native code or built-ins - .filter(frame => !!frame.filename) + // Exclude frames without a filename or without lineno and colno, + // since these are likely native code or built-ins + .filter(frame => !!frame.filename && (frame.lineno ?? frame.colno) != null) .map(frame => { if (frame.module_metadata) { return Object.keys(frame.module_metadata) diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index 6c967499800f..bf49c745e788 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -6,8 +6,8 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import type { ConsoleLevel } from '../types-hoist/instrument'; import type { IntegrationFn } from '../types-hoist/integration'; import { CONSOLE_LEVELS, debug } from '../utils/debug-logger'; -import { _INTERNAL_captureLog } from './exports'; -import { formatConsoleArgs } from './utils'; +import { _INTERNAL_captureLog } from './internal'; +import { createConsoleTemplateAttributes, formatConsoleArgs, hasConsoleSubstitutions } from './utils'; interface CaptureConsoleOptions { levels: ConsoleLevel[]; @@ -36,9 +36,11 @@ const _consoleLoggingIntegration = ((options: Partial = { return; } + const firstArg = args[0]; + const followingArgs = args.slice(1); + if (level === 'assert') { - if (!args[0]) { - const followingArgs = args.slice(1); + if (!firstArg) { const assertionMessage = followingArgs.length > 0 ? `Assertion failed: ${formatConsoleArgs(followingArgs, normalizeDepth, normalizeMaxBreadth)}` @@ -49,11 +51,19 @@ const _consoleLoggingIntegration = ((options: Partial = { } const isLevelLog = level === 'log'; + + const shouldGenerateTemplate = + args.length > 1 && typeof args[0] === 'string' && !hasConsoleSubstitutions(args[0]); + const attributes = { + ...DEFAULT_ATTRIBUTES, + ...(shouldGenerateTemplate ? createConsoleTemplateAttributes(firstArg, followingArgs) : {}), + }; + _INTERNAL_captureLog({ level: isLevelLog ? 'info' : level, message: formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth), severityNumber: isLevelLog ? 10 : undefined, - attributes: DEFAULT_ATTRIBUTES, + attributes, }); }); }, diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/internal.ts similarity index 99% rename from packages/core/src/logs/exports.ts rename to packages/core/src/logs/internal.ts index 702e8605adf1..adcbf0dfb737 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/internal.ts @@ -116,10 +116,10 @@ export function _INTERNAL_captureSerializedLog(client: Client, serializedLog: Se */ export function _INTERNAL_captureLog( beforeLog: Log, - client: Client | undefined = getClient(), currentScope = getCurrentScope(), captureSerializedLog: (client: Client, log: SerializedLog) => void = _INTERNAL_captureSerializedLog, ): void { + const client = currentScope?.getClient() ?? getClient(); if (!client) { DEBUG_BUILD && debug.warn('No client available to capture log.'); return; diff --git a/packages/browser/src/log.ts b/packages/core/src/logs/public-api.ts similarity index 70% rename from packages/browser/src/log.ts rename to packages/core/src/logs/public-api.ts index c21477e378b3..27507ab3dfe7 100644 --- a/packages/browser/src/log.ts +++ b/packages/core/src/logs/public-api.ts @@ -1,5 +1,7 @@ -import type { Log, LogSeverityLevel, ParameterizedString } from '@sentry/core'; -import { _INTERNAL_captureLog } from '@sentry/core'; +import type { Scope } from '../scope'; +import type { Log, LogSeverityLevel } from '../types-hoist/log'; +import type { ParameterizedString } from '../types-hoist/parameterize'; +import { _INTERNAL_captureLog } from './internal'; /** * Capture a log with the given level. @@ -7,15 +9,24 @@ import { _INTERNAL_captureLog } from '@sentry/core'; * @param level - The level of the log. * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param scope - The scope to capture the log with. * @param severityNumber - The severity number of the log. */ function captureLog( level: LogSeverityLevel, message: ParameterizedString, attributes?: Log['attributes'], + scope?: Scope, severityNumber?: Log['severityNumber'], ): void { - _INTERNAL_captureLog({ level, message, attributes, severityNumber }); + _INTERNAL_captureLog({ level, message, attributes, severityNumber }, scope); +} + +/** + * Additional metadata to capture the log with. + */ +interface CaptureLogMetadata { + scope?: Scope; } /** @@ -23,6 +34,7 @@ function captureLog( * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. + * @param metadata - additional metadata to capture the log with. * * @example * @@ -43,8 +55,12 @@ function captureLog( * }); * ``` */ -export function trace(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('trace', message, attributes); +export function trace( + message: ParameterizedString, + attributes?: Log['attributes'], + { scope }: CaptureLogMetadata = {}, +): void { + captureLog('trace', message, attributes, scope); } /** @@ -52,6 +68,7 @@ export function trace(message: ParameterizedString, attributes?: Log['attributes * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. + * @param metadata - additional metadata to capture the log with. * * @example * @@ -73,8 +90,12 @@ export function trace(message: ParameterizedString, attributes?: Log['attributes * }); * ``` */ -export function debug(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('debug', message, attributes); +export function debug( + message: ParameterizedString, + attributes?: Log['attributes'], + { scope }: CaptureLogMetadata = {}, +): void { + captureLog('debug', message, attributes, scope); } /** @@ -82,6 +103,7 @@ export function debug(message: ParameterizedString, attributes?: Log['attributes * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. + * @param metadata - additional metadata to capture the log with. * * @example * @@ -103,8 +125,12 @@ export function debug(message: ParameterizedString, attributes?: Log['attributes * }); * ``` */ -export function info(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('info', message, attributes); +export function info( + message: ParameterizedString, + attributes?: Log['attributes'], + { scope }: CaptureLogMetadata = {}, +): void { + captureLog('info', message, attributes, scope); } /** @@ -112,6 +138,7 @@ export function info(message: ParameterizedString, attributes?: Log['attributes' * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. + * @param metadata - additional metadata to capture the log with. * * @example * @@ -134,8 +161,12 @@ export function info(message: ParameterizedString, attributes?: Log['attributes' * }); * ``` */ -export function warn(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('warn', message, attributes); +export function warn( + message: ParameterizedString, + attributes?: Log['attributes'], + { scope }: CaptureLogMetadata = {}, +): void { + captureLog('warn', message, attributes, scope); } /** @@ -143,6 +174,7 @@ export function warn(message: ParameterizedString, attributes?: Log['attributes' * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. + * @param metadata - additional metadata to capture the log with. * * @example * @@ -166,8 +198,12 @@ export function warn(message: ParameterizedString, attributes?: Log['attributes' * }); * ``` */ -export function error(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('error', message, attributes); +export function error( + message: ParameterizedString, + attributes?: Log['attributes'], + { scope }: CaptureLogMetadata = {}, +): void { + captureLog('error', message, attributes, scope); } /** @@ -175,6 +211,7 @@ export function error(message: ParameterizedString, attributes?: Log['attributes * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. + * @param metadata - additional metadata to capture the log with. * * @example * @@ -198,8 +235,12 @@ export function error(message: ParameterizedString, attributes?: Log['attributes * }); * ``` */ -export function fatal(message: ParameterizedString, attributes?: Log['attributes']): void { - captureLog('fatal', message, attributes); +export function fatal( + message: ParameterizedString, + attributes?: Log['attributes'], + { scope }: CaptureLogMetadata = {}, +): void { + captureLog('fatal', message, attributes, scope); } -export { fmt } from '@sentry/core'; +export { fmt } from '../utils/parameterize'; diff --git a/packages/core/src/logs/utils.ts b/packages/core/src/logs/utils.ts index c30bfd75530b..5f95e0db3aad 100644 --- a/packages/core/src/logs/utils.ts +++ b/packages/core/src/logs/utils.ts @@ -37,3 +37,35 @@ export function safeJoinConsoleArgs(values: unknown[], normalizeDepth: number, n ) .join(' '); } + +/** + * Checks if a string contains console substitution patterns like %s, %d, %i, %f, %o, %O, %c. + * + * @param str - The string to check + * @returns true if the string contains console substitution patterns + */ +export function hasConsoleSubstitutions(str: string): boolean { + // Match console substitution patterns: %s, %d, %i, %f, %o, %O, %c + return /%[sdifocO]/.test(str); +} + +/** + * Creates template attributes for multiple console arguments. + * + * @param args - The console arguments + * @returns An object with template and parameter attributes + */ +export function createConsoleTemplateAttributes(firstArg: unknown, followingArgs: unknown[]): Record { + const attributes: Record = {}; + + // Create template with placeholders for each argument + const template = new Array(followingArgs.length).fill('{}').join(' '); + attributes['sentry.message.template'] = `${firstArg} ${template}`; + + // Add each argument as a parameter + followingArgs.forEach((arg, index) => { + attributes[`sentry.message.parameter.${index}`] = arg; + }); + + return attributes; +} diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 190db6dd55fa..1ee93e8dcd5a 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -53,46 +53,28 @@ export function getMetadataForUrl(parser: StackParser, filename: string): any | * Metadata is injected by the Sentry bundler plugins using the `_experiments.moduleMetadata` config option. */ export function addMetadataToStackFrames(parser: StackParser, event: Event): void { - try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - event.exception!.values!.forEach(exception => { - if (!exception.stacktrace) { + event.exception?.values?.forEach(exception => { + exception.stacktrace?.frames?.forEach(frame => { + if (!frame.filename || frame.module_metadata) { return; } - for (const frame of exception.stacktrace.frames || []) { - if (!frame.filename || frame.module_metadata) { - continue; - } + const metadata = getMetadataForUrl(parser, frame.filename); - const metadata = getMetadataForUrl(parser, frame.filename); - - if (metadata) { - frame.module_metadata = metadata; - } + if (metadata) { + frame.module_metadata = metadata; } }); - } catch { - // To save bundle size we're just try catching here instead of checking for the existence of all the different objects. - } + }); } /** * Strips metadata from stack frames. */ export function stripMetadataFromStackFrames(event: Event): void { - try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - event.exception!.values!.forEach(exception => { - if (!exception.stacktrace) { - return; - } - - for (const frame of exception.stacktrace.frames || []) { - delete frame.module_metadata; - } + event.exception?.values?.forEach(exception => { + exception.stacktrace?.frames?.forEach(frame => { + delete frame.module_metadata; }); - } catch { - // To save bundle size we're just try catching here instead of checking for the existence of all the different objects. - } + }); } diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index d9c44ed7149d..44e608925535 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -2,7 +2,7 @@ import { createCheckInEnvelope } from './checkin'; import { _getTraceInfoFromScope, Client } from './client'; import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; -import { _INTERNAL_flushLogsBuffer } from './logs/exports'; +import { _INTERNAL_flushLogsBuffer } from './logs/internal'; import type { Scope } from './scope'; import { registerSpanErrorInstrumentation } from './tracing'; import type { CheckIn, MonitorConfig, SerializedCheckIn } from './types-hoist/checkin'; diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 11045e0da1af..b35e31322ecd 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -6,6 +6,7 @@ import type { Span } from '../types-hoist/span'; import type { StartSpanOptions } from '../types-hoist/startSpanOptions'; import { debug } from '../utils/debug-logger'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; +import { shouldIgnoreSpan } from '../utils/should-ignore-span'; import { _setSpanForScope } from '../utils/spanOnScope'; import { getActiveSpan, @@ -74,8 +75,16 @@ interface IdleSpanOptions { * Defaults to `false`. */ disableAutoFinish?: boolean; + /** Allows to configure a hook that is called when the idle span is ended, before it is processed. */ beforeSpanEnd?: (span: Span) => void; + + /** + * If set to `true`, the idle span will be trimmed to the latest span end timestamp of its children. + * + * @default `true`. + */ + trimIdleSpanEndTimestamp?: boolean; } /** @@ -107,6 +116,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti finalTimeout = TRACING_DEFAULTS.finalTimeout, childSpanTimeout = TRACING_DEFAULTS.childSpanTimeout, beforeSpanEnd, + trimIdleSpanEndTimestamp = true, } = options; const client = getClient(); @@ -150,19 +160,33 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // Ensure we end with the last span timestamp, if possible const spans = getSpanDescendants(span).filter(child => child !== span); + const spanJson = spanToJSON(span); + // If we have no spans, we just end, nothing else to do here - if (!spans.length) { + // Likewise, if users explicitly ended the span, we simply end the span without timestamp adjustment + if (!spans.length || !trimIdleSpanEndTimestamp) { onIdleSpanEnded(spanEndTimestamp); return Reflect.apply(target, thisArg, [spanEndTimestamp, ...rest]); } - const childEndTimestamps = spans - .map(span => spanToJSON(span).timestamp) - .filter(timestamp => !!timestamp) as number[]; - const latestSpanEndTimestamp = childEndTimestamps.length ? Math.max(...childEndTimestamps) : undefined; + const ignoreSpans = client.getOptions().ignoreSpans; + + const latestSpanEndTimestamp = spans?.reduce((acc: number | undefined, current) => { + const currentSpanJson = spanToJSON(current); + if (!currentSpanJson.timestamp) { + return acc; + } + // Ignored spans will get dropped later (in the client) but since we already adjust + // the idle span end timestamp here, we can already take to-be-ignored spans out of + // the calculation here. + if (ignoreSpans && shouldIgnoreSpan(currentSpanJson, ignoreSpans)) { + return acc; + } + return acc ? Math.max(acc, currentSpanJson.timestamp) : currentSpanJson.timestamp; + }, undefined); // In reality this should always exist here, but type-wise it may be undefined... - const spanStartTimestamp = spanToJSON(span).start_timestamp; + const spanStartTimestamp = spanJson.start_timestamp; // The final endTimestamp should: // * Never be before the span start timestamp diff --git a/packages/core/src/transports/base.ts b/packages/core/src/transports/base.ts index c475c338db2f..822020070b86 100644 --- a/packages/core/src/transports/base.ts +++ b/packages/core/src/transports/base.ts @@ -16,7 +16,6 @@ import { } from '../utils/envelope'; import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from '../utils/promisebuffer'; import { type RateLimits, isRateLimited, updateRateLimits } from '../utils/ratelimit'; -import { resolvedSyncPromise } from '../utils/syncpromise'; export const DEFAULT_TRANSPORT_BUFFER_SIZE = 64; @@ -51,7 +50,7 @@ export function createTransport( // Skip sending if envelope is empty after filtering out rate limited events if (filteredEnvelopeItems.length === 0) { - return resolvedSyncPromise({}); + return Promise.resolve({}); } const filteredEnvelope: Envelope = createEnvelope(envelope[0], filteredEnvelopeItems as (typeof envelope)[1]); @@ -87,7 +86,7 @@ export function createTransport( if (error === SENTRY_BUFFER_FULL_ERROR) { DEBUG_BUILD && debug.error('Skipped sending event because buffer is full.'); recordEnvelopeLoss('queue_overflow'); - return resolvedSyncPromise({}); + return Promise.resolve({}); } else { throw error; } diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts index 2a2952ce6ad8..ecb46d5f0d0d 100644 --- a/packages/core/src/utils/ai/utils.ts +++ b/packages/core/src/utils/ai/utils.ts @@ -20,6 +20,9 @@ export function getFinalOperationName(methodPath: string): string { if (methodPath.includes('models')) { return 'models'; } + if (methodPath.includes('chat')) { + return 'chat'; + } return methodPath.split('.').pop() || 'unknown'; } diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index a771dff4c75d..f24707c4cc92 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -1,4 +1,4 @@ -import { getCurrentScope } from '../../currentScopes'; +import { getClient } from '../../currentScopes'; import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; @@ -24,11 +24,10 @@ import { GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; -import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants'; +import { handleCallbackErrors } from '../handleCallbackErrors'; import { instrumentStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, - AnthropicAiIntegration, AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, @@ -195,21 +194,6 @@ function addResponseAttributes(span: Span, response: AnthropicAiResponse, record addMetadataAttributes(span, response); } -/** - * Get record options from the integration - */ -function getRecordingOptionsFromIntegration(): AnthropicAiOptions { - const scope = getCurrentScope(); - const client = scope.getClient(); - const integration = client?.getIntegrationByName(ANTHROPIC_AI_INTEGRATION_NAME) as AnthropicAiIntegration | undefined; - const shouldRecordInputsAndOutputs = integration ? Boolean(client?.getOptions().sendDefaultPii) : false; - - return { - recordInputs: integration?.options?.recordInputs ?? shouldRecordInputsAndOutputs, - recordOutputs: integration?.options?.recordOutputs ?? shouldRecordInputsAndOutputs, - }; -} - /** * Instrument a method with Sentry spans * Following Sentry AI Agents Manual Instrumentation conventions @@ -219,10 +203,9 @@ function instrumentMethod( originalMethod: (...args: T) => Promise, methodPath: AnthropicAiInstrumentedMethod, context: unknown, - options?: AnthropicAiOptions, + options: AnthropicAiOptions, ): (...args: T) => Promise { return async function instrumentedMethod(...args: T): Promise { - const finalOptions = options || getRecordingOptionsFromIntegration(); const requestAttributes = extractRequestAttributes(args, methodPath); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const operationName = getFinalOperationName(methodPath); @@ -238,9 +221,9 @@ function instrumentMethod( op: getSpanOperation(methodPath), attributes: requestAttributes as Record, }, - async (span: Span) => { + async span => { try { - if (finalOptions.recordInputs && params) { + if (options.recordInputs && params) { addPrivateRequestAttributes(span, params); } @@ -248,7 +231,7 @@ function instrumentMethod( return instrumentStream( result as AsyncIterable, span, - finalOptions.recordOutputs ?? false, + options.recordOutputs ?? false, ) as unknown as R; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); @@ -274,27 +257,27 @@ function instrumentMethod( op: getSpanOperation(methodPath), attributes: requestAttributes as Record, }, - async (span: Span) => { - try { - if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') { - addPrivateRequestAttributes(span, args[0] as Record); - } + span => { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } - const result = await originalMethod.apply(context, args); - addResponseAttributes(span, result, finalOptions.recordOutputs); - return result; - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic', - data: { - function: methodPath, + return handleCallbackErrors( + () => originalMethod.apply(context, args), + error => { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic', + data: { + function: methodPath, + }, }, - }, - }); - throw error; - } + }); + }, + () => {}, + result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), + ); }, ); }; @@ -303,7 +286,7 @@ function instrumentMethod( /** * Create a deep proxy for Anthropic AI client instrumentation */ -function createDeepProxy(target: T, currentPath = '', options?: AnthropicAiOptions): T { +function createDeepProxy(target: T, currentPath = '', options: AnthropicAiOptions): T { return new Proxy(target, { get(obj: object, prop: string): unknown { const value = (obj as Record)[prop]; @@ -336,6 +319,13 @@ function createDeepProxy(target: T, currentPath = '', options? * @param options - Optional configuration for recording inputs and outputs * @returns The instrumented client with the same type as the input */ -export function instrumentAnthropicAiClient(client: T, options?: AnthropicAiOptions): T { - return createDeepProxy(client, '', options); +export function instrumentAnthropicAiClient(anthropicAiClient: T, options?: AnthropicAiOptions): T { + const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii); + + const _options = { + recordInputs: sendDefaultPii, + recordOutputs: sendDefaultPii, + ...options, + }; + return createDeepProxy(anthropicAiClient, '', _options); } diff --git a/packages/core/src/utils/exports.ts b/packages/core/src/utils/exports.ts new file mode 100644 index 000000000000..588e758e88f9 --- /dev/null +++ b/packages/core/src/utils/exports.ts @@ -0,0 +1,47 @@ +/** + * Replaces constructor functions in module exports, handling read-only properties, + * and both default and named exports by wrapping them with the constructor. + * + * @param exports The module exports object to modify + * @param exportName The name of the export to replace (e.g., 'GoogleGenAI', 'Anthropic', 'OpenAI') + * @param wrappedConstructor The wrapped constructor function to replace the original with + * @returns void + */ +export function replaceExports( + exports: { [key: string]: unknown }, + exportName: string, + wrappedConstructor: unknown, +): void { + const original = exports[exportName]; + + if (typeof original !== 'function') { + return; + } + + // Replace the named export - handle read-only properties + try { + exports[exportName] = wrappedConstructor; + } catch (error) { + // If direct assignment fails, override the property descriptor + Object.defineProperty(exports, exportName, { + value: wrappedConstructor, + writable: true, + configurable: true, + enumerable: true, + }); + } + + // Replace the default export if it points to the original constructor + if (exports.default === original) { + try { + exports.default = wrappedConstructor; + } catch (error) { + Object.defineProperty(exports, 'default', { + value: wrappedConstructor, + writable: true, + configurable: true, + enumerable: true, + }); + } + } +} diff --git a/packages/core/src/utils/google-genai/constants.ts b/packages/core/src/utils/google-genai/constants.ts new file mode 100644 index 000000000000..8617460482c6 --- /dev/null +++ b/packages/core/src/utils/google-genai/constants.ts @@ -0,0 +1,10 @@ +export const GOOGLE_GENAI_INTEGRATION_NAME = 'Google_GenAI'; + +// https://ai.google.dev/api/rest/v1/models/generateContent +// https://ai.google.dev/api/rest/v1/chats/sendMessage +export const GOOGLE_GENAI_INSTRUMENTED_METHODS = ['models.generateContent', 'chats.create', 'sendMessage'] as const; + +// Constants for internal use +export const GOOGLE_GENAI_SYSTEM_NAME = 'google_genai'; +export const CHATS_CREATE_METHOD = 'chats.create'; +export const CHAT_PATH = 'chat'; diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts new file mode 100644 index 000000000000..cdad221ac60f --- /dev/null +++ b/packages/core/src/utils/google-genai/index.ts @@ -0,0 +1,315 @@ +import { getClient } from '../../currentScopes'; +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { startSpan } from '../../tracing/trace'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_K_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils'; +import { handleCallbackErrors } from '../handleCallbackErrors'; +import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; +import type { + Candidate, + ContentPart, + GoogleGenAIIstrumentedMethod, + GoogleGenAIOptions, + GoogleGenAIResponse, +} from './types'; +import { shouldInstrument } from './utils'; + +/** + * Extract model from parameters or chat context object + * For chat instances, the model is available on the chat object as 'model' (older versions) or 'modelVersion' (newer versions) + */ +export function extractModel(params: Record, context?: unknown): string { + if ('model' in params && typeof params.model === 'string') { + return params.model; + } + + // Try to get model from chat context object (chat instance has model property) + if (context && typeof context === 'object') { + const contextObj = context as Record; + + // Check for 'model' property (older versions, and streaming) + if ('model' in contextObj && typeof contextObj.model === 'string') { + return contextObj.model; + } + + // Check for 'modelVersion' property (newer versions) + if ('modelVersion' in contextObj && typeof contextObj.modelVersion === 'string') { + return contextObj.modelVersion; + } + } + + return 'unknown'; +} + +/** + * Extract generation config parameters + */ +function extractConfigAttributes(config: Record): Record { + const attributes: Record = {}; + + if ('temperature' in config && typeof config.temperature === 'number') { + attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = config.temperature; + } + if ('topP' in config && typeof config.topP === 'number') { + attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = config.topP; + } + if ('topK' in config && typeof config.topK === 'number') { + attributes[GEN_AI_REQUEST_TOP_K_ATTRIBUTE] = config.topK; + } + if ('maxOutputTokens' in config && typeof config.maxOutputTokens === 'number') { + attributes[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE] = config.maxOutputTokens; + } + if ('frequencyPenalty' in config && typeof config.frequencyPenalty === 'number') { + attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = config.frequencyPenalty; + } + if ('presencePenalty' in config && typeof config.presencePenalty === 'number') { + attributes[GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE] = config.presencePenalty; + } + + return attributes; +} + +/** + * Extract request attributes from method arguments + * Builds the base attributes for span creation including system info, model, and config + */ +function extractRequestAttributes( + args: unknown[], + methodPath: string, + context?: unknown, +): Record { + const attributes: Record = { + [GEN_AI_SYSTEM_ATTRIBUTE]: GOOGLE_GENAI_SYSTEM_NAME, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + }; + + if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { + const params = args[0] as Record; + + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = extractModel(params, context); + + // Extract generation config parameters + if ('config' in params && typeof params.config === 'object' && params.config) { + Object.assign(attributes, extractConfigAttributes(params.config as Record)); + } + } else { + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = extractModel({}, context); + } + + return attributes; +} + +/** + * Add private request attributes to spans. + * This is only recorded if recordInputs is true. + * Handles different parameter formats for different Google GenAI methods. + */ +function addPrivateRequestAttributes(span: Span, params: Record): void { + // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] + if ('contents' in params) { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.contents) }); + } + + // For chat.sendMessage: message can be string or Part[] + if ('message' in params) { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.message) }); + } + + // For chats.create: history contains the conversation history + if ('history' in params) { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.history) }); + } +} + +/** + * Add response attributes from the Google GenAI response + * @see https://github.com/googleapis/js-genai/blob/v1.19.0/src/types.ts#L2313 + */ +function addResponseAttributes(span: Span, response: GoogleGenAIResponse, recordOutputs?: boolean): void { + if (!response || typeof response !== 'object') return; + + // Add usage metadata if present + if (response.usageMetadata && typeof response.usageMetadata === 'object') { + const usage = response.usageMetadata; + if (typeof usage.promptTokenCount === 'number') { + span.setAttributes({ + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: usage.promptTokenCount, + }); + } + if (typeof usage.candidatesTokenCount === 'number') { + span.setAttributes({ + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: usage.candidatesTokenCount, + }); + } + if (typeof usage.totalTokenCount === 'number') { + span.setAttributes({ + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: usage.totalTokenCount, + }); + } + } + + // Add response text if recordOutputs is enabled + if (recordOutputs && Array.isArray(response.candidates) && response.candidates.length > 0) { + const responseTexts = response.candidates + .map((candidate: Candidate) => { + if (candidate.content?.parts && Array.isArray(candidate.content.parts)) { + return candidate.content.parts + .map((part: ContentPart) => (typeof part.text === 'string' ? part.text : '')) + .filter((text: string) => text.length > 0) + .join(''); + } + return ''; + }) + .filter((text: string) => text.length > 0); + + if (responseTexts.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: responseTexts.join(''), + }); + } + } +} + +/** + * Instrument any async or synchronous genai method with Sentry spans + * Handles operations like models.generateContent and chat.sendMessage and chats.create + * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation + */ +function instrumentMethod( + originalMethod: (...args: T) => R | Promise, + methodPath: GoogleGenAIIstrumentedMethod, + context: unknown, + options: GoogleGenAIOptions, +): (...args: T) => R | Promise { + const isSyncCreate = methodPath === CHATS_CREATE_METHOD; + + const run = (...args: T): R | Promise => { + const requestAttributes = extractRequestAttributes(args, methodPath, context); + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const operationName = getFinalOperationName(methodPath); + + // Single span for both sync and async operations + return startSpan( + { + name: isSyncCreate ? `${operationName} ${model} create` : `${operationName} ${model}`, + op: getSpanOperation(methodPath), + attributes: requestAttributes, + }, + (span: Span) => { + if (options.recordInputs && args[0] && typeof args[0] === 'object') { + addPrivateRequestAttributes(span, args[0] as Record); + } + + return handleCallbackErrors( + () => originalMethod.apply(context, args), + error => { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.google_genai', data: { function: methodPath } }, + }); + }, + () => {}, + result => { + // Only add response attributes for content-producing methods, not for chats.create + if (!isSyncCreate) { + addResponseAttributes(span, result, options.recordOutputs); + } + }, + ); + }, + ); + }; + + return run; +} + +/** + * Create a deep proxy for Google GenAI client instrumentation + * Recursively instruments methods and handles special cases like chats.create + */ +function createDeepProxy(target: T, currentPath = '', options: GoogleGenAIOptions): T { + return new Proxy(target, { + get: (t, prop, receiver) => { + const value = Reflect.get(t, prop, receiver); + const methodPath = buildMethodPath(currentPath, String(prop)); + + if (typeof value === 'function' && shouldInstrument(methodPath)) { + // Special case: chats.create is synchronous but needs both instrumentation AND result proxying + if (methodPath === CHATS_CREATE_METHOD) { + const instrumentedMethod = instrumentMethod(value as (...args: unknown[]) => unknown, methodPath, t, options); + return function instrumentedAndProxiedCreate(...args: unknown[]): unknown { + const result = instrumentedMethod(...args); + // If the result is an object (like a chat instance), proxy it too + if (result && typeof result === 'object') { + return createDeepProxy(result, CHAT_PATH, options); + } + return result; + }; + } + + return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, t, options); + } + + if (typeof value === 'function') { + // Bind non-instrumented functions to preserve the original `this` context + return value.bind(t); + } + + if (value && typeof value === 'object') { + return createDeepProxy(value, methodPath, options); + } + + return value; + }, + }) as T; +} + +/** + * Instrument a Google GenAI client with Sentry tracing + * Can be used across Node.js, Cloudflare Workers, and Vercel Edge + * + * @template T - The type of the client that extends client object + * @param client - The Google GenAI client to instrument + * @param options - Optional configuration for recording inputs and outputs + * @returns The instrumented client with the same type as the input + * + * @example + * ```typescript + * import { GoogleGenerativeAI } from '@google/genai'; + * import { instrumentGoogleGenAIClient } from '@sentry/core'; + * + * const genAI = new GoogleGenerativeAI({ apiKey: process.env.GOOGLE_GENAI_API_KEY }); + * const instrumentedClient = instrumentGoogleGenAIClient(genAI); + * + * // Now both chats.create and sendMessage will be instrumented + * const chat = instrumentedClient.chats.create({ model: 'gemini-1.5-pro' }); + * const response = await chat.sendMessage({ message: 'Hello' }); + * ``` + */ +export function instrumentGoogleGenAIClient(client: T, options?: GoogleGenAIOptions): T { + const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii); + + const _options = { + recordInputs: sendDefaultPii, + recordOutputs: sendDefaultPii, + ...options, + }; + return createDeepProxy(client, '', _options); +} diff --git a/packages/core/src/utils/google-genai/types.ts b/packages/core/src/utils/google-genai/types.ts new file mode 100644 index 000000000000..9a2138a7843d --- /dev/null +++ b/packages/core/src/utils/google-genai/types.ts @@ -0,0 +1,185 @@ +import type { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants'; + +export interface GoogleGenAIOptions { + /** + * Enable or disable input recording. + */ + recordInputs?: boolean; + /** + * Enable or disable output recording. + */ + recordOutputs?: boolean; +} + +/** + * Google GenAI Content Part + * @see https://ai.google.dev/api/rest/v1/Content#Part + * @see https://github.com/googleapis/js-genai/blob/v1.19.0/src/types.ts#L1061 + * + */ +export type ContentPart = { + /** Metadata for a given video. */ + videoMetadata?: unknown; + /** Indicates if the part is thought from the model. */ + thought?: boolean; + /** Optional. Inlined bytes data. */ + inlineData?: Blob; + /** Optional. URI based data. */ + fileData?: unknown; + /** An opaque signature for the thought so it can be reused in subsequent requests. + * @remarks Encoded as base64 string. */ + thoughtSignature?: string; + /** A predicted [FunctionCall] returned from the model that contains a string + representing the [FunctionDeclaration.name] and a structured JSON object + containing the parameters and their values. */ + functionCall?: { + /** The unique id of the function call. If populated, the client to execute the + `function_call` and return the response with the matching `id`. */ + id?: string; + /** Optional. The function parameters and values in JSON object format. See [FunctionDeclaration.parameters] for parameter details. */ + args?: Record; + /** Required. The name of the function to call. Matches [FunctionDeclaration.name]. */ + name?: string; + }; + /** Optional. Result of executing the [ExecutableCode]. */ + codeExecutionResult?: unknown; + /** Optional. Code generated by the model that is meant to be executed. */ + executableCode?: unknown; + /** Optional. The result output of a [FunctionCall] that contains a string representing the [FunctionDeclaration.name] and a structured JSON object containing any output from the function call. It is used as context to the model. */ + functionResponse?: unknown; + /** Optional. Text part (can be code). */ + text?: string; +}; + +/** + * Google GenAI Content + * @see https://ai.google.dev/api/rest/v1/Content + */ +type Content = { + /** List of parts that constitute a single message. + * Each part may have a different IANA MIME type. */ + parts?: ContentPart[]; + /** Optional. The producer of the content. Must be either 'user' or + * 'model'. Useful to set for multi-turn conversations, otherwise can be + * empty. If role is not specified, SDK will determine the role. + */ + role?: string; +}; + +type MediaModality = 'MODALITY_UNSPECIFIED' | 'TEXT' | 'IMAGE' | 'VIDEO' | 'AUDIO' | 'DOCUMENT'; + +/** + * Google GenAI Modality Token Count + * @see https://ai.google.dev/api/rest/v1/ModalityTokenCount + */ +type ModalityTokenCount = { + /** The modality associated with this token count. */ + modality?: MediaModality; + /** Number of tokens. */ + tokenCount?: number; +}; + +/** + * Google GenAI Usage Metadata + * @see https://ai.google.dev/api/rest/v1/GenerateContentResponse#UsageMetadata + */ +type GenerateContentResponseUsageMetadata = { + [key: string]: unknown; + /** Output only. List of modalities of the cached content in the request input. */ + cacheTokensDetails?: ModalityTokenCount[]; + /** Output only. Number of tokens in the cached part in the input (the cached content). */ + cachedContentTokenCount?: number; + /** Number of tokens in the response(s). */ + candidatesTokenCount?: number; + /** Output only. List of modalities that were returned in the response. */ + candidatesTokensDetails?: ModalityTokenCount[]; + /** Number of tokens in the request. When `cached_content` is set, this is still the total effective prompt size meaning this includes the number of tokens in the cached content. */ + promptTokenCount?: number; + /** Output only. List of modalities that were processed in the request input. */ + promptTokensDetails?: ModalityTokenCount[]; + /** Output only. Number of tokens present in thoughts output. */ + thoughtsTokenCount?: number; + /** Output only. Number of tokens present in tool-use prompt(s). */ + toolUsePromptTokenCount?: number; + /** Output only. List of modalities that were processed for tool-use request inputs. */ + toolUsePromptTokensDetails?: ModalityTokenCount[]; + /** Total token count for prompt, response candidates, and tool-use prompts (if present). */ + totalTokenCount?: number; +}; + +/** + * Google GenAI Candidate + * @see https://ai.google.dev/api/rest/v1/Candidate + * https://github.com/googleapis/js-genai/blob/v1.19.0/src/types.ts#L2237 + */ +export type Candidate = { + [key: string]: unknown; + /** + * Contains the multi-part content of the response. + */ + content?: Content; + /** + * The reason why the model stopped generating tokens. + * If empty, the model has not stopped generating the tokens. + */ + finishReason?: string; + /** + * Number of tokens for this candidate. + */ + tokenCount?: number; + /** + * The index of the candidate. + */ + index?: number; +}; + +/** + * Google GenAI Generate Content Response + * @see https://ai.google.dev/api/rest/v1/GenerateContentResponse + */ +type GenerateContentResponse = { + [key: string]: unknown; + /** Response variations returned by the model. */ + candidates?: Candidate[]; + /** Timestamp when the request is made to the server. */ + automaticFunctionCallingHistory?: Content[]; + /** Output only. The model version used to generate the response. */ + modelVersion?: string; + /** Output only. Content filter results for a prompt sent in the request. Note: Sent only in the first stream chunk. Only happens when no candidates were generated due to content violations. */ + promptFeedback?: Record; + /** Output only. response_id is used to identify each response. It is the encoding of the event_id. */ + responseId?: string; + /** Usage metadata about the response(s). */ + usageMetadata?: GenerateContentResponseUsageMetadata; +}; + +/** + * Basic interface for Google GenAI client with only the instrumented methods + * This provides type safety while being generic enough to work with different client implementations + */ +export interface GoogleGenAIClient { + models: { + generateContent: (...args: unknown[]) => Promise; + // https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateContentStream: (...args: unknown[]) => Promise>; + }; + chats: { + create: (...args: unknown[]) => GoogleGenAIChat; + }; +} + +/** + * Google GenAI Chat interface for chat instances created via chats.create() + */ +export interface GoogleGenAIChat { + sendMessage: (...args: unknown[]) => Promise; + // https://googleapis.github.io/js-genai/release_docs/classes/chats.Chat.html#sendmessagestream + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sendMessageStream: (...args: unknown[]) => Promise>; +} + +export type GoogleGenAIIstrumentedMethod = (typeof GOOGLE_GENAI_INSTRUMENTED_METHODS)[number]; + +// Export the response type for use in instrumentation +export type GoogleGenAIResponse = GenerateContentResponse; diff --git a/packages/core/src/utils/google-genai/utils.ts b/packages/core/src/utils/google-genai/utils.ts new file mode 100644 index 000000000000..c7a18477c7dd --- /dev/null +++ b/packages/core/src/utils/google-genai/utils.ts @@ -0,0 +1,16 @@ +import { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants'; +import type { GoogleGenAIIstrumentedMethod } from './types'; + +/** + * Check if a method path should be instrumented + */ +export function shouldInstrument(methodPath: string): methodPath is GoogleGenAIIstrumentedMethod { + // Check for exact matches first (like 'models.generateContent') + if (GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodPath as GoogleGenAIIstrumentedMethod)) { + return true; + } + + // Check for method name matches (like 'sendMessage' from chat instances) + const methodName = methodPath.split('.').pop(); + return GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodName as GoogleGenAIIstrumentedMethod); +} diff --git a/packages/core/src/utils/handleCallbackErrors.ts b/packages/core/src/utils/handleCallbackErrors.ts index 5675638e18f2..1a09e23a40aa 100644 --- a/packages/core/src/utils/handleCallbackErrors.ts +++ b/packages/core/src/utils/handleCallbackErrors.ts @@ -1,5 +1,30 @@ import { isThenable } from '../utils/is'; +/* eslint-disable */ +// Vendor "Awaited" in to be TS 3.8 compatible +type AwaitedPromise = T extends null | undefined + ? T // special case for `null | undefined` when not in `--strictNullChecks` mode + : T extends object & { then(onfulfilled: infer F, ...args: infer _): any } // `await` only unwraps object types with a callable `then`. Non-object types are not unwrapped + ? F extends (value: infer V, ...args: infer _) => any // if the argument to `then` is callable, extracts the first argument + ? V // normally this would recursively unwrap, but this is not possible in TS3.8 + : never // the argument to `then` was not callable + : T; // non-object or non-thenable +/* eslint-enable */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function handleCallbackErrors Promise, PromiseValue = AwaitedPromise>>( + fn: Fn, + onError: (error: unknown) => void, + onFinally?: () => void, + onSuccess?: (result: PromiseValue) => void, +): ReturnType; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function handleCallbackErrors any>( + fn: Fn, + onError: (error: unknown) => void, + onFinally?: () => void, + onSuccess?: (result: ReturnType) => void, +): ReturnType; /** * Wrap a callback function with error handling. * If an error is thrown, it will be passed to the `onError` callback and re-thrown. @@ -14,7 +39,13 @@ import { isThenable } from '../utils/is'; export function handleCallbackErrors< // eslint-disable-next-line @typescript-eslint/no-explicit-any Fn extends () => any, ->(fn: Fn, onError: (error: unknown) => void, onFinally: () => void = () => {}): ReturnType { + ValueType = ReturnType, +>( + fn: Fn, + onError: (error: unknown) => void, + onFinally: () => void = () => {}, + onSuccess: (result: ValueType | AwaitedPromise) => void = () => {}, +): ValueType { let maybePromiseResult: ReturnType; try { maybePromiseResult = fn(); @@ -24,7 +55,7 @@ export function handleCallbackErrors< throw e; } - return maybeHandlePromiseRejection(maybePromiseResult, onError, onFinally); + return maybeHandlePromiseRejection(maybePromiseResult, onError, onFinally, onSuccess); } /** @@ -37,12 +68,14 @@ function maybeHandlePromiseRejection( value: MaybePromise, onError: (error: unknown) => void, onFinally: () => void, + onSuccess: (result: MaybePromise | AwaitedPromise) => void, ): MaybePromise { if (isThenable(value)) { // @ts-expect-error - the isThenable check returns the "wrong" type here return value.then( res => { onFinally(); + onSuccess(res); return res; }, e => { @@ -54,5 +87,6 @@ function maybeHandlePromiseRejection( } onFinally(); + onSuccess(value); return value; } diff --git a/packages/core/src/utils/should-ignore-span.ts b/packages/core/src/utils/should-ignore-span.ts index 53aa109a18dc..a8d3ac0211c7 100644 --- a/packages/core/src/utils/should-ignore-span.ts +++ b/packages/core/src/utils/should-ignore-span.ts @@ -1,7 +1,13 @@ +import { DEBUG_BUILD } from '../debug-build'; import type { ClientOptions } from '../types-hoist/options'; import type { SpanJSON } from '../types-hoist/span'; +import { debug } from './debug-logger'; import { isMatchingPattern } from './string'; +function logIgnoredSpan(droppedSpan: Pick): void { + debug.log(`Ignoring span ${droppedSpan.op} - ${droppedSpan.description} because it matches \`ignoreSpans\`.`); +} + /** * Check if a span should be ignored based on the ignoreSpans configuration. */ @@ -16,6 +22,7 @@ export function shouldIgnoreSpan( for (const pattern of ignoreSpans) { if (isStringOrRegExp(pattern)) { if (isMatchingPattern(span.description, pattern)) { + DEBUG_BUILD && logIgnoredSpan(span); return true; } continue; @@ -33,6 +40,7 @@ export function shouldIgnoreSpan( // not both op and name actually have to match. This is the most efficient way to check // for all combinations of name and op patterns. if (nameMatches && opMatches) { + DEBUG_BUILD && logIgnoredSpan(span); return true; } } diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 89ecc6872efb..6e7c62c7631a 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -323,7 +323,7 @@ export function showSpanDropWarning(): void { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( - '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly.', + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', ); }); hasShownSpanDropWarning = true; diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index b7767a7e6c58..c7cbe7ab4a97 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -1445,7 +1445,7 @@ describe('Client', () => { expect(consoleWarnSpy).toHaveBeenCalledTimes(1); expect(consoleWarnSpy).toHaveBeenCalledWith( - '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly.', + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', ); consoleWarnSpy.mockRestore(); }); @@ -2200,11 +2200,7 @@ describe('Client', () => { client.on('afterSendEvent', callback); client.sendEvent(errorEvent); - vi.runAllTimers(); - // Wait for two ticks - // note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang - await undefined; - await undefined; + await vi.runAllTimersAsync(); expect(mockSend).toBeCalledTimes(1); expect(callback).toBeCalledTimes(1); @@ -2228,11 +2224,7 @@ describe('Client', () => { client.on('afterSendEvent', callback); client.sendEvent(transactionEvent); - vi.runAllTimers(); - // Wait for two ticks - // note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang - await undefined; - await undefined; + await vi.runAllTimersAsync(); expect(mockSend).toBeCalledTimes(1); expect(callback).toBeCalledTimes(1); @@ -2260,11 +2252,7 @@ describe('Client', () => { client.on('afterSendEvent', callback); client.sendEvent(errorEvent); - vi.runAllTimers(); - // Wait for two ticks - // note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang - await undefined; - await undefined; + await vi.runAllTimersAsync(); expect(mockSend).toBeCalledTimes(1); expect(callback).toBeCalledTimes(1); @@ -2409,10 +2397,8 @@ describe('Client', () => { client.emit('beforeEnvelope', mockEnvelope); }); - }); - describe('hook removal with `on`', () => { - it('should return a cleanup function that, when executed, unregisters a hook', async () => { + it('returns a cleanup function that, when executed, unregisters a hook', async () => { vi.useFakeTimers(); expect.assertions(8); @@ -2432,7 +2418,7 @@ describe('Client', () => { const callback = vi.fn(); const removeAfterSendEventListenerFn = client.on('afterSendEvent', callback); - expect(client['_hooks']['afterSendEvent']).toEqual([callback]); + expect(client['_hooks']['afterSendEvent']!.size).toBe(1); client.sendEvent(errorEvent); vi.runAllTimers(); @@ -2447,7 +2433,7 @@ describe('Client', () => { // Should unregister `afterSendEvent` callback. removeAfterSendEventListenerFn(); - expect(client['_hooks']['afterSendEvent']).toEqual([]); + expect(client['_hooks']['afterSendEvent']!.size).toBe(0); client.sendEvent(errorEvent); vi.runAllTimers(); @@ -2462,6 +2448,112 @@ describe('Client', () => { expect(callback).toBeCalledTimes(1); expect(callback).toBeCalledWith(errorEvent, { statusCode: 200 }); }); + + it('allows synchronously unregistering multiple callbacks from within the callback', () => { + const client = new TestClient(getDefaultTestClientOptions()); + + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const removeCallback1 = client.on('close', () => { + callback1(); + removeCallback1(); + }); + const removeCallback2 = client.on('close', () => { + callback2(); + removeCallback2(); + }); + + client.emit('close'); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + + callback1.mockReset(); + callback2.mockReset(); + + client.emit('close'); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + + it('allows synchronously unregistering other callbacks from within one callback', () => { + const client = new TestClient(getDefaultTestClientOptions()); + + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const removeCallback1 = client.on('close', () => { + callback1(); + removeCallback1(); + removeCallback2(); + }); + const removeCallback2 = client.on('close', () => { + callback2(); + removeCallback2(); + removeCallback1(); + }); + + client.emit('close'); + + expect(callback1).toHaveBeenCalledTimes(1); + // callback2 was already cancelled from within callback1, so it must not be called + expect(callback2).not.toHaveBeenCalled(); + + callback1.mockReset(); + callback2.mockReset(); + + client.emit('close'); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + + it('allows registering and unregistering the same callback multiple times', () => { + const client = new TestClient(getDefaultTestClientOptions()); + const callback = vi.fn(); + + const unregister1 = client.on('close', callback); + const unregister2 = client.on('close', callback); + + client.emit('close'); + + expect(callback).toHaveBeenCalledTimes(2); + + unregister1(); + + callback.mockReset(); + + client.emit('close'); + + expect(callback).toHaveBeenCalledTimes(1); + + unregister2(); + + callback.mockReset(); + client.emit('close'); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('handles unregistering a callback multiple times', () => { + const client = new TestClient(getDefaultTestClientOptions()); + const callback = vi.fn(); + + const unregister = client.on('close', callback); + client.emit('close'); + expect(callback).toHaveBeenCalledTimes(1); + + callback.mockReset(); + unregister(); + unregister(); + unregister(); + + client.emit('close'); + + expect(callback).not.toHaveBeenCalled(); + }); }); describe('withMonitor', () => { diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index 66830fb75e06..186e5fdc295e 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getClient } from '../../../src/currentScopes'; +import { getClient, getCurrentScope } from '../../../src/currentScopes'; import { createConsolaReporter } from '../../../src/integrations/consola'; -import { _INTERNAL_captureLog } from '../../../src/logs/exports'; +import { _INTERNAL_captureLog } from '../../../src/logs/internal'; import { formatConsoleArgs } from '../../../src/logs/utils'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; // Mock dependencies -vi.mock('../../../src/logs/exports', () => ({ +vi.mock('../../../src/logs/internal', () => ({ _INTERNAL_captureLog: vi.fn(), })); @@ -16,6 +16,7 @@ vi.mock('../../../src/logs/utils', async actual => ({ vi.mock('../../../src/currentScopes', () => ({ getClient: vi.fn(), + getCurrentScope: vi.fn(), })); describe('createConsolaReporter', () => { @@ -32,7 +33,12 @@ describe('createConsolaReporter', () => { normalizeMaxBreadth: 1000, }); + const mockScope = { + getClient: vi.fn().mockReturnValue(mockClient), + }; + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(getCurrentScope).mockReturnValue(mockScope as any); }); afterEach(() => { diff --git a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts index d68dbaeb5b56..2b5445a4544e 100644 --- a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts +++ b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts @@ -32,6 +32,19 @@ const eventWithThirdAndFirstPartyFrames: Event = { function: 'function', lineno: 2, }, + // The following frames are native/built-in frames which should be ignored by the integration + { + function: 'Array.forEach', + filename: '', + abs_path: '', + in_app: true, + }, + { + function: 'async Promise.all', + filename: 'index 1', + abs_path: 'index 1', + in_app: true, + }, ], }, type: 'SyntaxError', @@ -51,14 +64,25 @@ const eventWithOnlyFirstPartyFrames: Event = { colno: 1, filename: __filename, function: 'function', - lineno: 1, }, { - colno: 2, filename: __filename, function: 'function', lineno: 2, }, + // The following frames are native/built-in frames which should be ignored by the integration + { + function: 'Array.forEach', + filename: '', + abs_path: '', + in_app: true, + }, + { + function: 'async Promise.all', + filename: 'index 1', + abs_path: 'index 1', + in_app: true, + }, ], }, type: 'SyntaxError', @@ -86,6 +110,19 @@ const eventWithOnlyThirdPartyFrames: Event = { function: 'function', lineno: 2, }, + // The following frames are native/built-in frames which should be ignored by the integration + { + function: 'Array.forEach', + filename: '', + abs_path: '', + in_app: true, + }, + { + function: 'async Promise.all', + filename: 'index 1', + abs_path: 'index 1', + in_app: true, + }, ], }, type: 'SyntaxError', @@ -112,7 +149,7 @@ describe('ThirdPartyErrorFilter', () => { }); describe('drop-error-if-contains-third-party-frames', () => { - it('should keep event if there are exclusively first-party frames', async () => { + it('keeps event if there are exclusively first-party frames', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'drop-error-if-contains-third-party-frames', filterKeys: ['some-key'], @@ -123,7 +160,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result).toBeDefined(); }); - it('should drop event if there is at least one third-party frame', async () => { + it('drops event if there is at least one third-party frame', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'drop-error-if-contains-third-party-frames', filterKeys: ['some-key'], @@ -134,7 +171,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result).toBe(null); }); - it('should drop event if all frames are third-party frames', async () => { + it('drops event if all frames are third-party frames', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'drop-error-if-contains-third-party-frames', filterKeys: ['some-key'], @@ -147,7 +184,7 @@ describe('ThirdPartyErrorFilter', () => { }); describe('drop-error-if-exclusively-contains-third-party-frames', () => { - it('should keep event if there are exclusively first-party frames', async () => { + it('keeps event if there are exclusively first-party frames', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'drop-error-if-exclusively-contains-third-party-frames', filterKeys: ['some-key'], @@ -158,7 +195,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result).toBeDefined(); }); - it('should keep event if there is at least one first-party frame', async () => { + it('keeps event if there is at least one first-party frame', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'drop-error-if-exclusively-contains-third-party-frames', filterKeys: ['some-key'], @@ -169,7 +206,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result).toBeDefined(); }); - it('should drop event if all frames are third-party frames', async () => { + it('drops event if all frames are third-party frames', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'drop-error-if-exclusively-contains-third-party-frames', filterKeys: ['some-key'], @@ -182,7 +219,7 @@ describe('ThirdPartyErrorFilter', () => { }); describe('apply-tag-if-contains-third-party-frames', () => { - it('should not tag event if exclusively contains first-party frames', async () => { + it("doesn't tag event if exclusively contains first-party frames", async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'apply-tag-if-contains-third-party-frames', filterKeys: ['some-key'], @@ -193,7 +230,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result?.tags?.third_party_code).toBeUndefined(); }); - it('should tag event if contains at least one third-party frame', async () => { + it('tags event if contains at least one third-party frame', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'apply-tag-if-contains-third-party-frames', filterKeys: ['some-key'], @@ -204,7 +241,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result?.tags).toMatchObject({ third_party_code: true }); }); - it('should tag event if contains exclusively third-party frames', async () => { + it('tags event if contains exclusively third-party frames', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'apply-tag-if-contains-third-party-frames', filterKeys: ['some-key'], @@ -217,7 +254,7 @@ describe('ThirdPartyErrorFilter', () => { }); describe('apply-tag-if-exclusively-contains-third-party-frames', () => { - it('should not tag event if exclusively contains first-party frames', async () => { + it("doesn't tag event if exclusively contains first-party frames", async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', filterKeys: ['some-key'], @@ -228,7 +265,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result?.tags?.third_party_code).toBeUndefined(); }); - it('should not tag event if contains at least one first-party frame', async () => { + it("doesn't tag event if contains at least one first-party frame", async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', filterKeys: ['some-key'], @@ -239,7 +276,7 @@ describe('ThirdPartyErrorFilter', () => { expect(result?.tags?.third_party_code).toBeUndefined(); }); - it('should tag event if contains exclusively third-party frames', async () => { + it('tags event if contains exclusively third-party frames', async () => { const integration = thirdPartyErrorFilterIntegration({ behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', filterKeys: ['some-key'], diff --git a/packages/core/test/lib/logs/exports.test.ts b/packages/core/test/lib/logs/internal.test.ts similarity index 93% rename from packages/core/test/lib/logs/exports.test.ts rename to packages/core/test/lib/logs/internal.test.ts index c3369784c34a..49339e72b6b1 100644 --- a/packages/core/test/lib/logs/exports.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -5,7 +5,7 @@ import { _INTERNAL_flushLogsBuffer, _INTERNAL_getLogBuffer, logAttributeToSerializedLogAttribute, -} from '../../../src/logs/exports'; +} from '../../../src/logs/internal'; import type { Log } from '../../../src/types-hoist/log'; import * as loggerModule from '../../../src/utils/debug-logger'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -84,8 +84,10 @@ describe('_INTERNAL_captureLog', () => { it('captures and sends logs', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); - _INTERNAL_captureLog({ level: 'info', message: 'test log message' }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: 'test log message' }, scope); expect(_INTERNAL_getLogBuffer(client)).toHaveLength(1); expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual( expect.objectContaining({ @@ -103,8 +105,10 @@ describe('_INTERNAL_captureLog', () => { const logWarnSpy = vi.spyOn(loggerModule.debug, 'warn').mockImplementation(() => undefined); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); - _INTERNAL_captureLog({ level: 'info', message: 'test log message' }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: 'test log message' }, scope); expect(logWarnSpy).toHaveBeenCalledWith('logging option not enabled, log will not be captured.'); expect(_INTERNAL_getLogBuffer(client)).toBeUndefined(); @@ -116,12 +120,13 @@ describe('_INTERNAL_captureLog', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setPropagationContext({ traceId: '3d9355f71e9c444b81161599adac6e29', sampleRand: 1, }); - _INTERNAL_captureLog({ level: 'error', message: 'test log with trace' }, client, scope); + _INTERNAL_captureLog({ level: 'error', message: 'test log with trace' }, scope); expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual( expect.objectContaining({ @@ -139,8 +144,10 @@ describe('_INTERNAL_captureLog', () => { environment: 'test', }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); - _INTERNAL_captureLog({ level: 'info', message: 'test log with metadata' }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: 'test log with metadata' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({ @@ -161,6 +168,8 @@ describe('_INTERNAL_captureLog', () => { enableLogs: true, }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); // Mock getSdkMetadata to return SDK info vi.spyOn(client, 'getSdkMetadata').mockReturnValue({ sdk: { @@ -169,7 +178,7 @@ describe('_INTERNAL_captureLog', () => { }, }); - _INTERNAL_captureLog({ level: 'info', message: 'test log with SDK metadata' }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: 'test log with SDK metadata' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({ @@ -190,10 +199,12 @@ describe('_INTERNAL_captureLog', () => { enableLogs: true, }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); // Mock getSdkMetadata to return no SDK info vi.spyOn(client, 'getSdkMetadata').mockReturnValue({}); - _INTERNAL_captureLog({ level: 'info', message: 'test log without SDK metadata' }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: 'test log without SDK metadata' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).not.toEqual( @@ -207,6 +218,8 @@ describe('_INTERNAL_captureLog', () => { it('includes custom attributes in log', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); _INTERNAL_captureLog( { @@ -214,8 +227,7 @@ describe('_INTERNAL_captureLog', () => { message: 'test log with custom attributes', attributes: { userId: '123', component: 'auth' }, }, - client, - undefined, + scope, ); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; @@ -234,16 +246,18 @@ describe('_INTERNAL_captureLog', () => { it('flushes logs buffer when it reaches max size', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); // Fill the buffer to max size (100 is the MAX_LOG_BUFFER_SIZE constant in client.ts) for (let i = 0; i < 100; i++) { - _INTERNAL_captureLog({ level: 'info', message: `log message ${i}` }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: `log message ${i}` }, scope); } expect(_INTERNAL_getLogBuffer(client)).toHaveLength(100); // Add one more to trigger flush - _INTERNAL_captureLog({ level: 'info', message: 'trigger flush' }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: 'trigger flush' }, scope); expect(_INTERNAL_getLogBuffer(client)).toEqual([]); }); @@ -251,6 +265,7 @@ describe('_INTERNAL_captureLog', () => { it('does not flush logs buffer when it is empty', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); + const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {}); _INTERNAL_flushLogsBuffer(client); expect(mockSendEnvelope).not.toHaveBeenCalled(); @@ -259,10 +274,12 @@ describe('_INTERNAL_captureLog', () => { it('handles parameterized strings correctly', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); const parameterizedMessage = fmt`Hello ${'John'}, welcome to ${'Sentry'}`; - _INTERNAL_captureLog({ level: 'info', message: parameterizedMessage }, client, undefined); + _INTERNAL_captureLog({ level: 'info', message: parameterizedMessage }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({ @@ -284,8 +301,10 @@ describe('_INTERNAL_captureLog', () => { it('does not set the template attribute if there are no parameters', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); - _INTERNAL_captureLog({ level: 'debug', message: fmt`User logged in` }, client, undefined); + _INTERNAL_captureLog({ level: 'debug', message: fmt`User logged in` }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({}); @@ -304,6 +323,8 @@ describe('_INTERNAL_captureLog', () => { beforeSendLog, }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); _INTERNAL_captureLog( { @@ -311,8 +332,7 @@ describe('_INTERNAL_captureLog', () => { message: 'original message', attributes: { original: true }, }, - client, - undefined, + scope, ); expect(beforeSendLog).toHaveBeenCalledWith({ @@ -351,14 +371,15 @@ describe('_INTERNAL_captureLog', () => { beforeSendLog, }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); _INTERNAL_captureLog( { level: 'info', message: 'test message', }, - client, - undefined, + scope, ); expect(beforeSendLog).toHaveBeenCalled(); @@ -374,6 +395,8 @@ describe('_INTERNAL_captureLog', () => { const beforeCaptureLogSpy = vi.spyOn(TestClient.prototype, 'emit'); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); const log: Log = { level: 'info', @@ -381,7 +404,7 @@ describe('_INTERNAL_captureLog', () => { attributes: {}, }; - _INTERNAL_captureLog(log, client, undefined); + _INTERNAL_captureLog(log, scope); expect(beforeCaptureLogSpy).toHaveBeenCalledWith('beforeCaptureLog', log); expect(beforeCaptureLogSpy).toHaveBeenCalledWith('afterCaptureLog', log); @@ -396,13 +419,14 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({ id: '123', email: 'user@example.com', username: 'testuser', }); - _INTERNAL_captureLog({ level: 'info', message: 'test log with user' }, client, scope); + _INTERNAL_captureLog({ level: 'info', message: 'test log with user' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({ @@ -429,12 +453,13 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({ id: '123', // email and username are missing }); - _INTERNAL_captureLog({ level: 'info', message: 'test log with partial user' }, client, scope); + _INTERNAL_captureLog({ level: 'info', message: 'test log with partial user' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({ @@ -453,13 +478,14 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({ email: 'user@example.com', username: 'testuser', // id is missing }); - _INTERNAL_captureLog({ level: 'info', message: 'test log with email and username' }, client, scope); + _INTERNAL_captureLog({ level: 'info', message: 'test log with email and username' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({ @@ -482,9 +508,10 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({}); - _INTERNAL_captureLog({ level: 'info', message: 'test log with empty user' }, client, scope); + _INTERNAL_captureLog({ level: 'info', message: 'test log with empty user' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({}); @@ -500,6 +527,7 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({ id: '123', email: 'user@example.com', @@ -511,7 +539,6 @@ describe('_INTERNAL_captureLog', () => { message: 'test log with user and other attributes', attributes: { component: 'auth', action: 'login' }, }, - client, scope, ); @@ -552,13 +579,14 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({ id: 123, // number instead of string email: 'user@example.com', username: undefined, // undefined value }); - _INTERNAL_captureLog({ level: 'info', message: 'test log with non-string user values' }, client, scope); + _INTERNAL_captureLog({ level: 'info', message: 'test log with non-string user values' }, scope); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; expect(logAttributes).toEqual({ @@ -581,6 +609,7 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({ id: '123', email: 'user@example.com', @@ -595,7 +624,6 @@ describe('_INTERNAL_captureLog', () => { 'user.custom': 'custom-value', // This should be preserved }, }, - client, scope, ); @@ -624,6 +652,7 @@ describe('_INTERNAL_captureLog', () => { }); const client = new TestClient(options); const scope = new Scope(); + scope.setClient(client); scope.setUser({ id: 'scope-id', email: 'scope@example.com', @@ -639,7 +668,6 @@ describe('_INTERNAL_captureLog', () => { 'other.attr': 'value', }, }, - client, scope, ); @@ -673,7 +701,6 @@ describe('_INTERNAL_captureLog', () => { environment: 'sdk-environment', }); const client = new TestClient(options); - // Mock getSdkMetadata to return SDK info vi.spyOn(client, 'getSdkMetadata').mockReturnValue({ sdk: { @@ -683,6 +710,7 @@ describe('_INTERNAL_captureLog', () => { }); const scope = new Scope(); + scope.setClient(client); _INTERNAL_captureLog( { @@ -696,7 +724,6 @@ describe('_INTERNAL_captureLog', () => { 'user.custom': 'preserved-value', }, }, - client, scope, ); diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 708ac8716070..9fcb431af864 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, test, vi } from 'vitest'; import { createTransport, Scope } from '../../src'; -import { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from '../../src/logs/exports'; +import { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from '../../src/logs/internal'; import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; import { ServerRuntimeClient } from '../../src/server-runtime-client'; import type { Event, EventHint } from '../../src/types-hoist/event'; @@ -214,12 +214,14 @@ describe('ServerRuntimeClient', () => { enableLogs: true, }); client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); // Create a large log message that will exceed the 800KB threshold const largeMessage = 'x'.repeat(400_000); // 400KB string - _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, client); + _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, scope); expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); expect(client['_logWeight']).toBe(0); // Weight should be reset after flush @@ -231,12 +233,14 @@ describe('ServerRuntimeClient', () => { enableLogs: true, }); client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); // Create a log message that won't exceed the threshold const message = 'x'.repeat(100_000); // 100KB string - _INTERNAL_captureLog({ message, level: 'info' }, client); + _INTERNAL_captureLog({ message, level: 'info' }, scope); expect(sendEnvelopeSpy).not.toHaveBeenCalled(); expect(client['_logWeight']).toBeGreaterThan(0); @@ -248,12 +252,14 @@ describe('ServerRuntimeClient', () => { enableLogs: true, }); client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); // Add some logs - _INTERNAL_captureLog({ message: 'test1', level: 'info' }, client); - _INTERNAL_captureLog({ message: 'test2', level: 'info' }, client); + _INTERNAL_captureLog({ message: 'test1', level: 'info' }, scope); + _INTERNAL_captureLog({ message: 'test2', level: 'info' }, scope); // Trigger flush directly _INTERNAL_flushLogsBuffer(client); @@ -267,12 +273,14 @@ describe('ServerRuntimeClient', () => { dsn: PUBLIC_DSN, }); client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); // Create a large log message const largeMessage = 'x'.repeat(400_000); - _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, client); + _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, scope); expect(sendEnvelopeSpy).not.toHaveBeenCalled(); expect(client['_logWeight']).toBe(0); @@ -284,12 +292,14 @@ describe('ServerRuntimeClient', () => { enableLogs: true, }); client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); // Add some logs - _INTERNAL_captureLog({ message: 'test1', level: 'info' }, client); - _INTERNAL_captureLog({ message: 'test2', level: 'info' }, client); + _INTERNAL_captureLog({ message: 'test1', level: 'info' }, scope); + _INTERNAL_captureLog({ message: 'test2', level: 'info' }, scope); // Trigger flush event client.emit('flush'); diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 601e25be0d23..4b70e1c3ef97 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -190,7 +190,7 @@ describe('SentrySpan', () => { expect(recordDroppedEventSpy).not.toHaveBeenCalled(); expect(consoleWarnSpy).toHaveBeenCalledWith( - '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly.', + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', ); consoleWarnSpy.mockRestore(); }); diff --git a/packages/core/test/lib/utils/handleCallbackErrors.test.ts b/packages/core/test/lib/utils/handleCallbackErrors.test.ts index 6c4815f35680..c310ae0f7c03 100644 --- a/packages/core/test/lib/utils/handleCallbackErrors.test.ts +++ b/packages/core/test/lib/utils/handleCallbackErrors.test.ts @@ -148,4 +148,94 @@ describe('handleCallbackErrors', () => { expect(onFinally).toHaveBeenCalledTimes(1); }); }); + + describe('onSuccess', () => { + it('triggers after successful sync callback', () => { + const onError = vi.fn(); + const onFinally = vi.fn(); + const onSuccess = vi.fn(); + + const fn = vi.fn(() => 'aa'); + + const res = handleCallbackErrors(fn, onError, onFinally, onSuccess); + + expect(res).toBe('aa'); + expect(fn).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith('aa'); + }); + + it('does not trigger onSuccess after error in sync callback', () => { + const error = new Error('test error'); + + const onError = vi.fn(); + const onFinally = vi.fn(); + const onSuccess = vi.fn(); + + const fn = vi.fn(() => { + throw error; + }); + + expect(() => handleCallbackErrors(fn, onError, onFinally, onSuccess)).toThrow(error); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('triggers after successful async callback', async () => { + const onError = vi.fn(); + const onFinally = vi.fn(); + const onSuccess = vi.fn(); + + const fn = vi.fn(async () => 'aa'); + + const res = handleCallbackErrors(fn, onError, onFinally, onSuccess); + + expect(res).toBeInstanceOf(Promise); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(onFinally).not.toHaveBeenCalled(); + + const value = await res; + expect(value).toBe('aa'); + + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith('aa'); + }); + + it('does not trigger onSuccess after error in async callback', async () => { + const onError = vi.fn(); + const onFinally = vi.fn(); + const onSuccess = vi.fn(); + + const error = new Error('test error'); + + const fn = vi.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + throw error; + }); + + const res = handleCallbackErrors(fn, onError, onFinally, onSuccess); + + expect(res).toBeInstanceOf(Promise); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(onFinally).not.toHaveBeenCalled(); + + await expect(res).rejects.toThrow(error); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onSuccess).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/test/lib/utils/should-ignore-span.test.ts b/packages/core/test/lib/utils/should-ignore-span.test.ts index 92dc0a1435ee..dc03d6e032ea 100644 --- a/packages/core/test/lib/utils/should-ignore-span.test.ts +++ b/packages/core/test/lib/utils/should-ignore-span.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ClientOptions, SpanJSON } from '../../../src'; +import { debug } from '../../../src/utils/debug-logger'; import { reparentChildSpans, shouldIgnoreSpan } from '../../../src/utils/should-ignore-span'; describe('shouldIgnoreSpan', () => { @@ -87,6 +88,16 @@ describe('shouldIgnoreSpan', () => { expect(shouldIgnoreSpan(span11, ignoreSpans)).toBe(false); expect(shouldIgnoreSpan(span12, ignoreSpans)).toBe(false); }); + + it('emits a debug log when a span is ignored', () => { + const debugLogSpy = vi.spyOn(debug, 'log'); + const span = { description: 'testDescription', op: 'testOp' }; + const ignoreSpans = [/test/]; + expect(shouldIgnoreSpan(span, ignoreSpans)).toBe(true); + expect(debugLogSpy).toHaveBeenCalledWith( + 'Ignoring span testOp - testDescription because it matches `ignoreSpans`.', + ); + }); }); describe('reparentChildSpans', () => { diff --git a/packages/deno/src/integrations/deno-cron.ts b/packages/deno/src/integrations/deno-cron.ts index ad856479aaee..b94c68bf967e 100644 --- a/packages/deno/src/integrations/deno-cron.ts +++ b/packages/deno/src/integrations/deno-cron.ts @@ -15,13 +15,11 @@ const _denoCronIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { - // eslint-disable-next-line deprecation/deprecation if (!Deno.cron) { // The cron API is not available in this Deno version use --unstable flag! return; } - // eslint-disable-next-line deprecation/deprecation Deno.cron = new Proxy(Deno.cron, { apply(target, thisArg, argArray: CronParams) { const [monitorSlug, schedule, opt1, opt2] = argArray; diff --git a/packages/deno/src/transports/index.ts b/packages/deno/src/transports/index.ts index f6c0ed8d1c52..c5b6594b1c4d 100644 --- a/packages/deno/src/transports/index.ts +++ b/packages/deno/src/transports/index.ts @@ -1,5 +1,5 @@ import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; -import { consoleSandbox, createTransport, debug, rejectedSyncPromise, suppressTracing } from '@sentry/core'; +import { consoleSandbox, createTransport, debug, suppressTracing } from '@sentry/core'; export interface DenoTransportOptions extends BaseTransportOptions { /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ @@ -48,7 +48,7 @@ export function makeFetchTransport(options: DenoTransportOptions): Transport { }); }); } catch (e) { - return rejectedSyncPromise(e); + return Promise.reject(e); } } diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index e8042e4260a8..fc0fe353b919 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -123,6 +123,7 @@ export { profiler, amqplibIntegration, anthropicAIIntegration, + googleGenAIIntegration, childProcessIntegration, createSentryWinstonTransport, vercelAIIntegration, diff --git a/packages/nextjs/src/config/getBuildPluginOptions.ts b/packages/nextjs/src/config/getBuildPluginOptions.ts index 3dfef3bbad08..6de802917015 100644 --- a/packages/nextjs/src/config/getBuildPluginOptions.ts +++ b/packages/nextjs/src/config/getBuildPluginOptions.ts @@ -2,38 +2,264 @@ import type { Options as SentryBuildPluginOptions } from '@sentry/bundler-plugin import * as path from 'path'; import type { SentryBuildOptions } from './types'; +const LOGGER_PREFIXES = { + 'webpack-nodejs': '[@sentry/nextjs - Node.js]', + 'webpack-edge': '[@sentry/nextjs - Edge]', + 'webpack-client': '[@sentry/nextjs - Client]', + 'after-production-compile-webpack': '[@sentry/nextjs - After Production Compile (Webpack)]', + 'after-production-compile-turbopack': '[@sentry/nextjs - After Production Compile (Turbopack)]', +} as const; + +// File patterns for source map operations +// We use both glob patterns and directory paths for the sourcemap upload and deletion +// -> Direct CLI invocation handles file paths better than glob patterns +// -> Webpack/Bundler needs glob patterns as this is the format that is used by the plugin +const FILE_PATTERNS = { + SERVER: { + GLOB: 'server/**', + PATH: 'server', + }, + SERVERLESS: 'serverless/**', + STATIC_CHUNKS: { + GLOB: 'static/chunks/**', + PATH: 'static/chunks', + }, + STATIC_CHUNKS_PAGES: { + GLOB: 'static/chunks/pages/**', + PATH: 'static/chunks/pages', + }, + STATIC_CHUNKS_APP: { + GLOB: 'static/chunks/app/**', + PATH: 'static/chunks/app', + }, + MAIN_CHUNKS: 'static/chunks/main-*', + FRAMEWORK_CHUNKS: 'static/chunks/framework-*', + FRAMEWORK_CHUNKS_DOT: 'static/chunks/framework.*', + POLYFILLS_CHUNKS: 'static/chunks/polyfills-*', + WEBPACK_CHUNKS: 'static/chunks/webpack-*', +} as const; + +// Source map file extensions to delete +const SOURCEMAP_EXTENSIONS = ['*.js.map', '*.mjs.map', '*.cjs.map'] as const; + +type BuildTool = keyof typeof LOGGER_PREFIXES; + +/** + * Normalizes Windows paths to POSIX format for glob patterns + */ +export function normalizePathForGlob(distPath: string): string { + return distPath.replace(/\\/g, '/'); +} + +/** + * These functions are used to get the correct pattern for the sourcemap upload based on the build tool and the usage context + * -> Direct CLI invocation handles file paths better than glob patterns + */ +function getServerPattern({ useDirectoryPath = false }: { useDirectoryPath?: boolean }): string { + return useDirectoryPath ? FILE_PATTERNS.SERVER.PATH : FILE_PATTERNS.SERVER.GLOB; +} + +function getStaticChunksPattern({ useDirectoryPath = false }: { useDirectoryPath?: boolean }): string { + return useDirectoryPath ? FILE_PATTERNS.STATIC_CHUNKS.PATH : FILE_PATTERNS.STATIC_CHUNKS.GLOB; +} + +function getStaticChunksPagesPattern({ useDirectoryPath = false }: { useDirectoryPath?: boolean }): string { + return useDirectoryPath ? FILE_PATTERNS.STATIC_CHUNKS_PAGES.PATH : FILE_PATTERNS.STATIC_CHUNKS_PAGES.GLOB; +} + +function getStaticChunksAppPattern({ useDirectoryPath = false }: { useDirectoryPath?: boolean }): string { + return useDirectoryPath ? FILE_PATTERNS.STATIC_CHUNKS_APP.PATH : FILE_PATTERNS.STATIC_CHUNKS_APP.GLOB; +} + /** - * Get Sentry Build Plugin options for the runAfterProductionCompile hook. + * Creates file patterns for source map uploads based on build tool and options + */ +function createSourcemapUploadAssetPatterns( + normalizedDistPath: string, + buildTool: BuildTool, + widenClientFileUpload: boolean = false, +): string[] { + const assets: string[] = []; + + if (buildTool.startsWith('after-production-compile')) { + assets.push(path.posix.join(normalizedDistPath, getServerPattern({ useDirectoryPath: true }))); + + if (buildTool === 'after-production-compile-turbopack') { + // In turbopack we always want to upload the full static chunks directory + // as the build output is not split into pages|app chunks + assets.push(path.posix.join(normalizedDistPath, getStaticChunksPattern({ useDirectoryPath: true }))); + } else { + // Webpack client builds in after-production-compile mode + if (widenClientFileUpload) { + assets.push(path.posix.join(normalizedDistPath, getStaticChunksPattern({ useDirectoryPath: true }))); + } else { + assets.push( + path.posix.join(normalizedDistPath, getStaticChunksPagesPattern({ useDirectoryPath: true })), + path.posix.join(normalizedDistPath, getStaticChunksAppPattern({ useDirectoryPath: true })), + ); + } + } + } else { + if (buildTool === 'webpack-nodejs' || buildTool === 'webpack-edge') { + // Server builds + assets.push( + path.posix.join(normalizedDistPath, getServerPattern({ useDirectoryPath: false })), + path.posix.join(normalizedDistPath, FILE_PATTERNS.SERVERLESS), + ); + } else if (buildTool === 'webpack-client') { + // Client builds + if (widenClientFileUpload) { + assets.push(path.posix.join(normalizedDistPath, getStaticChunksPattern({ useDirectoryPath: false }))); + } else { + assets.push( + path.posix.join(normalizedDistPath, getStaticChunksPagesPattern({ useDirectoryPath: false })), + path.posix.join(normalizedDistPath, getStaticChunksAppPattern({ useDirectoryPath: false })), + ); + } + } + } + + return assets; +} + +/** + * Creates ignore patterns for source map uploads + */ +function createSourcemapUploadIgnorePattern( + normalizedDistPath: string, + widenClientFileUpload: boolean = false, +): string[] { + const ignore: string[] = []; + + // We only add main-* files if the user has not opted into it + if (!widenClientFileUpload) { + ignore.push(path.posix.join(normalizedDistPath, FILE_PATTERNS.MAIN_CHUNKS)); + } + + // Always ignore these patterns + ignore.push( + path.posix.join(normalizedDistPath, FILE_PATTERNS.FRAMEWORK_CHUNKS), + path.posix.join(normalizedDistPath, FILE_PATTERNS.FRAMEWORK_CHUNKS_DOT), + path.posix.join(normalizedDistPath, FILE_PATTERNS.POLYFILLS_CHUNKS), + path.posix.join(normalizedDistPath, FILE_PATTERNS.WEBPACK_CHUNKS), + ); + + return ignore; +} + +/** + * Creates file patterns for deletion after source map upload + */ +function createFilesToDeleteAfterUploadPattern( + normalizedDistPath: string, + buildTool: BuildTool, + deleteSourcemapsAfterUpload: boolean, + useRunAfterProductionCompileHook: boolean = false, +): string[] | undefined { + if (!deleteSourcemapsAfterUpload) { + return undefined; + } + + // We don't want to delete source maps for server builds as this led to errors on Vercel in the past + // See: https://github.com/getsentry/sentry-javascript/issues/13099 + if (buildTool === 'webpack-nodejs' || buildTool === 'webpack-edge') { + return undefined; + } + + // Skip deletion for webpack client builds when using the experimental hook + if (buildTool === 'webpack-client' && useRunAfterProductionCompileHook) { + return undefined; + } + + return SOURCEMAP_EXTENSIONS.map(ext => path.posix.join(normalizedDistPath, 'static', '**', ext)); +} + +/** + * Determines if sourcemap uploads should be skipped + */ +function shouldSkipSourcemapUpload(buildTool: BuildTool, useRunAfterProductionCompileHook: boolean = false): boolean { + return useRunAfterProductionCompileHook && buildTool.startsWith('webpack'); +} + +/** + * Source rewriting function for webpack sources + */ +function rewriteWebpackSources(source: string): string { + return source.replace(/^webpack:\/\/(?:_N_E\/)?/, ''); +} + +/** + * Creates release configuration + */ +function createReleaseConfig( + releaseName: string | undefined, + sentryBuildOptions: SentryBuildOptions, +): SentryBuildPluginOptions['release'] { + if (releaseName !== undefined) { + return { + inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. + name: releaseName, + create: sentryBuildOptions.release?.create, + finalize: sentryBuildOptions.release?.finalize, + dist: sentryBuildOptions.release?.dist, + vcsRemote: sentryBuildOptions.release?.vcsRemote, + setCommits: sentryBuildOptions.release?.setCommits, + deploy: sentryBuildOptions.release?.deploy, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, + }; + } + + return { + inject: false, + create: false, + finalize: false, + }; +} + +/** + * Get Sentry Build Plugin options for both webpack and turbopack builds. + * These options can be used in two ways: + * 1. The options can be built in a single operation after the production build completes + * 2. The options can be built in multiple operations, one for each webpack build */ export function getBuildPluginOptions({ sentryBuildOptions, releaseName, distDirAbsPath, + buildTool, + useRunAfterProductionCompileHook, }: { sentryBuildOptions: SentryBuildOptions; releaseName: string | undefined; distDirAbsPath: string; + buildTool: BuildTool; + useRunAfterProductionCompileHook?: boolean; // Whether the user has opted into using the experimental hook }): SentryBuildPluginOptions { - const sourcemapUploadAssets: string[] = []; - const sourcemapUploadIgnore: string[] = []; - - const filesToDeleteAfterUpload: string[] = []; - // We need to convert paths to posix because Glob patterns use `\` to escape // glob characters. This clashes with Windows path separators. // See: https://www.npmjs.com/package/glob - const normalizedDistDirAbsPath = distDirAbsPath.replace(/\\/g, '/'); + const normalizedDistDirAbsPath = normalizePathForGlob(distDirAbsPath); - sourcemapUploadAssets.push( - path.posix.join(normalizedDistDirAbsPath, '**'), // Next.js build output + const loggerPrefix = LOGGER_PREFIXES[buildTool]; + const widenClientFileUpload = sentryBuildOptions.widenClientFileUpload ?? false; + const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false; + + const sourcemapUploadAssets = createSourcemapUploadAssetPatterns( + normalizedDistDirAbsPath, + buildTool, + widenClientFileUpload, ); - if (sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload) { - filesToDeleteAfterUpload.push( - path.posix.join(normalizedDistDirAbsPath, '**', '*.js.map'), - path.posix.join(normalizedDistDirAbsPath, '**', '*.mjs.map'), - path.posix.join(normalizedDistDirAbsPath, '**', '*.cjs.map'), - ); - } + + const sourcemapUploadIgnore = createSourcemapUploadIgnorePattern(normalizedDistDirAbsPath, widenClientFileUpload); + + const filesToDeleteAfterUpload = createFilesToDeleteAfterUploadPattern( + normalizedDistDirAbsPath, + buildTool, + deleteSourcemapsAfterUpload, + useRunAfterProductionCompileHook, + ); + + const skipSourcemapsUpload = shouldSkipSourcemapUpload(buildTool, useRunAfterProductionCompileHook); return { authToken: sentryBuildOptions.authToken, @@ -43,51 +269,28 @@ export function getBuildPluginOptions({ telemetry: sentryBuildOptions.telemetry, debug: sentryBuildOptions.debug, errorHandler: sentryBuildOptions.errorHandler, - reactComponentAnnotation: { - ...sentryBuildOptions.reactComponentAnnotation, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, - }, + reactComponentAnnotation: buildTool.startsWith('after-production-compile') + ? undefined + : { + ...sentryBuildOptions.reactComponentAnnotation, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, + }, silent: sentryBuildOptions.silent, url: sentryBuildOptions.sentryUrl, sourcemaps: { - disable: sentryBuildOptions.sourcemaps?.disable, - rewriteSources(source) { - if (source.startsWith('webpack://_N_E/')) { - return source.replace('webpack://_N_E/', ''); - } else if (source.startsWith('webpack://')) { - return source.replace('webpack://', ''); - } else { - return source; - } - }, + disable: skipSourcemapsUpload ? true : (sentryBuildOptions.sourcemaps?.disable ?? false), + rewriteSources: rewriteWebpackSources, assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets, ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore, filesToDeleteAfterUpload, ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps, }, - release: - releaseName !== undefined - ? { - inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. - name: releaseName, - create: sentryBuildOptions.release?.create, - finalize: sentryBuildOptions.release?.finalize, - dist: sentryBuildOptions.release?.dist, - vcsRemote: sentryBuildOptions.release?.vcsRemote, - setCommits: sentryBuildOptions.release?.setCommits, - deploy: sentryBuildOptions.release?.deploy, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, - } - : { - inject: false, - create: false, - finalize: false, - }, + release: createReleaseConfig(releaseName, sentryBuildOptions), bundleSizeOptimizations: { ...sentryBuildOptions.bundleSizeOptimizations, }, _metaOptions: { - loggerPrefixOverride: '[@sentry/nextjs]', + loggerPrefixOverride: loggerPrefix, telemetry: { metaFramework: 'nextjs', }, diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index 01979b497c72..c8dc35918198 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -11,12 +11,6 @@ export async function handleRunAfterProductionCompile( { releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' }, sentryBuildOptions: SentryBuildOptions, ): Promise { - // We don't want to do anything for webpack at this point because the plugin already handles this - // TODO: Actually implement this for webpack as well - if (buildTool === 'webpack') { - return; - } - if (sentryBuildOptions.debug) { // eslint-disable-next-line no-console console.debug('[@sentry/nextjs] Running runAfterProductionCompile logic.'); @@ -36,17 +30,17 @@ export async function handleRunAfterProductionCompile( return; } - const sentryBuildPluginManager = createSentryBuildPluginManager( - getBuildPluginOptions({ - sentryBuildOptions, - releaseName, - distDirAbsPath: distDir, - }), - { - buildTool, - loggerPrefix: '[@sentry/nextjs]', - }, - ); + const options = getBuildPluginOptions({ + sentryBuildOptions, + releaseName, + distDirAbsPath: distDir, + buildTool: `after-production-compile-${buildTool}`, + }); + + const sentryBuildPluginManager = createSentryBuildPluginManager(options, { + buildTool, + loggerPrefix: '[@sentry/nextjs - After Production Compile]', + }); await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); await sentryBuildPluginManager.createRelease(); diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 55f080fb433e..1fa245412f2c 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -501,22 +501,23 @@ export type SentryBuildOptions = { */ disableSentryWebpackConfig?: boolean; + /** + * When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads + * into a single operation after builds complete, reducing build time. + * + * When false, use the traditional approach of uploading sourcemaps during each webpack build. For Turbopack no sourcemaps will be uploaded. + * + * @default true for Turbopack, false for Webpack + */ + useRunAfterProductionCompileHook?: boolean; + /** * Contains a set of experimental flags that might change in future releases. These flags enable * features that are still in development and may be modified, renamed, or removed without notice. * Use with caution in production environments. */ _experimental?: Partial<{ - /** - * When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads - * into a single operation after turbopack builds complete, reducing build time. - * - * When false, use the traditional approach of uploading sourcemaps during each webpack build. - * - * @default false - */ - useRunAfterProductionCompileHook?: boolean; - thirdPartyOriginStackFrames: boolean; + thirdPartyOriginStackFrames?: boolean; }>; }; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 8741efe81194..6ba07cd09f8f 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { sync as resolveSync } from 'resolve'; import type { VercelCronsConfig } from '../common/types'; +import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions'; import type { RouteManifest } from './manifest/types'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our // circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306. @@ -22,7 +23,6 @@ import type { WebpackEntryProperty, } from './types'; import { getNextjsVersion } from './util'; -import { getWebpackPluginOptions } from './webpackPluginOptions'; // Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain // warnings 3 times, we keep track of them here. @@ -40,13 +40,21 @@ let showedMissingGlobalErrorWarningMsg = false; * @param userSentryOptions The user's SentryWebpackPlugin config, as passed to `withSentryConfig` * @returns The function to set as the nextjs config's `webpack` value */ -export function constructWebpackConfigFunction( - userNextConfig: NextConfigObject = {}, - userSentryOptions: SentryBuildOptions = {}, - releaseName: string | undefined, - routeManifest: RouteManifest | undefined, - nextJsVersion: string | undefined, -): WebpackConfigFunction { +export function constructWebpackConfigFunction({ + userNextConfig = {}, + userSentryOptions = {}, + releaseName, + routeManifest, + nextJsVersion, + useRunAfterProductionCompileHook, +}: { + userNextConfig: NextConfigObject; + userSentryOptions: SentryBuildOptions; + releaseName: string | undefined; + routeManifest: RouteManifest | undefined; + nextJsVersion: string | undefined; + useRunAfterProductionCompileHook: boolean | undefined; +}): WebpackConfigFunction { // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that // `incomingConfig` and `buildContext` are referred to as `config` and `options` in the nextjs docs. @@ -408,9 +416,22 @@ export function constructWebpackConfigFunction( } newConfig.plugins = newConfig.plugins || []; + const { config: userNextConfig, dir, nextRuntime } = buildContext; + const buildTool = isServer ? (nextRuntime === 'edge' ? 'webpack-edge' : 'webpack-nodejs') : 'webpack-client'; + const projectDir = normalizePathForGlob(dir); + const distDir = normalizePathForGlob((userNextConfig as NextConfigObject).distDir ?? '.next'); + const distDirAbsPath = path.posix.join(projectDir, distDir); + const sentryWebpackPluginInstance = sentryWebpackPlugin( - getWebpackPluginOptions(buildContext, userSentryOptions, releaseName), + getBuildPluginOptions({ + sentryBuildOptions: userSentryOptions, + releaseName, + distDirAbsPath, + buildTool, + useRunAfterProductionCompileHook, + }), ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access sentryWebpackPluginInstance._name = 'sentry-webpack-plugin'; // For tests and debugging. Serves no other purpose. newConfig.plugins.push(sentryWebpackPluginInstance); diff --git a/packages/nextjs/src/config/webpackPluginOptions.ts b/packages/nextjs/src/config/webpackPluginOptions.ts deleted file mode 100644 index f4ff4363cdb7..000000000000 --- a/packages/nextjs/src/config/webpackPluginOptions.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { SentryWebpackPluginOptions } from '@sentry/webpack-plugin'; -import * as path from 'path'; -import type { BuildContext, NextConfigObject, SentryBuildOptions } from './types'; - -/** - * Combine default and user-provided SentryWebpackPlugin options, accounting for whether we're building server files or - * client files. - */ -export function getWebpackPluginOptions( - buildContext: BuildContext, - sentryBuildOptions: SentryBuildOptions, - releaseName: string | undefined, -): SentryWebpackPluginOptions { - const { isServer, config: userNextConfig, dir, nextRuntime } = buildContext; - - const prefixInsert = !isServer ? 'Client' : nextRuntime === 'edge' ? 'Edge' : 'Node.js'; - - // We need to convert paths to posix because Glob patterns use `\` to escape - // glob characters. This clashes with Windows path separators. - // See: https://www.npmjs.com/package/glob - const projectDir = dir.replace(/\\/g, '/'); - // `.next` is the default directory - const distDir = (userNextConfig as NextConfigObject).distDir?.replace(/\\/g, '/') ?? '.next'; - const distDirAbsPath = path.posix.join(projectDir, distDir); - - const sourcemapUploadAssets: string[] = []; - const sourcemapUploadIgnore: string[] = []; - - if (isServer) { - sourcemapUploadAssets.push( - path.posix.join(distDirAbsPath, 'server', '**'), // This is normally where Next.js outputs things - path.posix.join(distDirAbsPath, 'serverless', '**'), // This was the output location for serverless Next.js - ); - } else { - if (sentryBuildOptions.widenClientFileUpload) { - sourcemapUploadAssets.push(path.posix.join(distDirAbsPath, 'static', 'chunks', '**')); - } else { - sourcemapUploadAssets.push( - path.posix.join(distDirAbsPath, 'static', 'chunks', 'pages', '**'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'app', '**'), - ); - } - - // We want to include main-* files if widenClientFileUpload is true as they have proven to be useful - if (!sentryBuildOptions.widenClientFileUpload) { - sourcemapUploadIgnore.push(path.posix.join(distDirAbsPath, 'static', 'chunks', 'main-*')); - } - - // Always ignore framework, polyfills, and webpack files - sourcemapUploadIgnore.push( - path.posix.join(distDirAbsPath, 'static', 'chunks', 'framework-*'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'framework.*'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'polyfills-*'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'webpack-*'), - ); - } - - return { - authToken: sentryBuildOptions.authToken, - headers: sentryBuildOptions.headers, - org: sentryBuildOptions.org, - project: sentryBuildOptions.project, - telemetry: sentryBuildOptions.telemetry, - debug: sentryBuildOptions.debug, - errorHandler: sentryBuildOptions.errorHandler, - reactComponentAnnotation: { - ...sentryBuildOptions.reactComponentAnnotation, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, - }, - silent: sentryBuildOptions.silent, - url: sentryBuildOptions.sentryUrl, - sourcemaps: { - disable: sentryBuildOptions.sourcemaps?.disable, - rewriteSources(source) { - if (source.startsWith('webpack://_N_E/')) { - return source.replace('webpack://_N_E/', ''); - } else if (source.startsWith('webpack://')) { - return source.replace('webpack://', ''); - } else { - return source; - } - }, - assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets, - ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore, - filesToDeleteAfterUpload: sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload - ? [ - // We only care to delete client bundle source maps because they would be the ones being served. - // Removing the server source maps crashes Vercel builds for (thus far) unknown reasons: - // https://github.com/getsentry/sentry-javascript/issues/13099 - path.posix.join(distDirAbsPath, 'static', '**', '*.js.map'), - path.posix.join(distDirAbsPath, 'static', '**', '*.mjs.map'), - path.posix.join(distDirAbsPath, 'static', '**', '*.cjs.map'), - ] - : undefined, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps, - }, - release: - releaseName !== undefined - ? { - inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. - name: releaseName, - create: sentryBuildOptions.release?.create, - finalize: sentryBuildOptions.release?.finalize, - dist: sentryBuildOptions.release?.dist, - vcsRemote: sentryBuildOptions.release?.vcsRemote, - setCommits: sentryBuildOptions.release?.setCommits, - deploy: sentryBuildOptions.release?.deploy, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, - } - : { - inject: false, - create: false, - finalize: false, - }, - bundleSizeOptimizations: { - ...sentryBuildOptions.bundleSizeOptimizations, - }, - _metaOptions: { - loggerPrefixOverride: `[@sentry/nextjs - ${prefixInsert}]`, - telemetry: { - metaFramework: 'nextjs', - }, - }, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions, - }; -} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 4558e5349c5a..b5c2be2f25bb 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -294,7 +294,11 @@ function getFinalConfigObject( } } - if (userSentryOptions?._experimental?.useRunAfterProductionCompileHook === true && supportsProductionCompileHook()) { + // If not explicitly set, turbopack uses the runAfterProductionCompile hook (as there are no alternatives), webpack does not. + const shouldUseRunAfterProductionCompileHook = + userSentryOptions?.useRunAfterProductionCompileHook ?? (isTurbopack ? true : false); + + if (shouldUseRunAfterProductionCompileHook && supportsProductionCompileHook()) { if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { incomingUserNextConfigObject.compiler ??= {}; incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { @@ -329,16 +333,21 @@ function getFinalConfigObject( if (isTurbopackSupported && isTurbopack && !userSentryOptions.sourcemaps?.disable) { // Only set if not already configured by user if (incomingUserNextConfigObject.productionBrowserSourceMaps === undefined) { - // eslint-disable-next-line no-console - console.log('[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.'); + if (userSentryOptions.debug) { + // eslint-disable-next-line no-console + console.log('[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.'); + } incomingUserNextConfigObject.productionBrowserSourceMaps = true; // Enable source map deletion if not explicitly disabled if (userSentryOptions.sourcemaps?.deleteSourcemapsAfterUpload === undefined) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', - ); + if (userSentryOptions.debug) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', + ); + } + userSentryOptions.sourcemaps = { ...userSentryOptions.sourcemaps, deleteSourcemapsAfterUpload: true, @@ -368,13 +377,14 @@ function getFinalConfigObject( webpack: isTurbopack || userSentryOptions.disableSentryWebpackConfig ? incomingUserNextConfigObject.webpack // just return the original webpack config - : constructWebpackConfigFunction( - incomingUserNextConfigObject, + : constructWebpackConfigFunction({ + userNextConfig: incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest, nextJsVersion, - ), + useRunAfterProductionCompileHook: shouldUseRunAfterProductionCompileHook, + }), ...(isTurbopackSupported && isTurbopack ? { turbopack: constructTurbopackConfig({ diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 82d475a719c6..5866f014ec69 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -106,9 +106,12 @@ export function init(options: NodeOptions): NodeClient | undefined { }), ); - // Turn off Next.js' own fetch instrumentation + // Turn off Next.js' own fetch instrumentation (only when we manage OTEL) // https://github.com/lforst/nextjs-fork/blob/1994fd186defda77ad971c36dc3163db263c993f/packages/next/src/server/lib/patch-fetch.ts#L245 - process.env.NEXT_OTEL_FETCH_DISABLED = '1'; + // Enable with custom OTel setup: https://github.com/getsentry/sentry-javascript/issues/17581 + if (!options.skipOpenTelemetrySetup) { + process.env.NEXT_OTEL_FETCH_DISABLED = '1'; + } // This value is injected at build time, based on the output directory specified in the build config. Though a default // is set there, we set it here as well, just in case something has gone wrong with the injection. diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts index 1120084ec76e..0281624584d0 100644 --- a/packages/nextjs/test/config/getBuildPluginOptions.test.ts +++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts @@ -7,7 +7,7 @@ describe('getBuildPluginOptions', () => { const mockDistDirAbsPath = '/path/to/.next'; describe('basic functionality', () => { - it('returns correct build plugin options with minimal configuration', () => { + it('returns correct build plugin options with minimal configuration for after-production-compile-webpack', () => { const sentryBuildOptions: SentryBuildOptions = { org: 'test-org', project: 'test-project', @@ -18,6 +18,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-webpack', }); expect(result).toMatchObject({ @@ -25,9 +26,15 @@ describe('getBuildPluginOptions', () => { org: 'test-org', project: 'test-project', sourcemaps: { - assets: ['/path/to/.next/**'], - ignore: [], - filesToDeleteAfterUpload: [], + assets: ['/path/to/.next/server', '/path/to/.next/static/chunks/pages', '/path/to/.next/static/chunks/app'], + ignore: [ + '/path/to/.next/static/chunks/main-*', + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ], + filesToDeleteAfterUpload: undefined, rewriteSources: expect.any(Function), }, release: { @@ -37,16 +44,17 @@ describe('getBuildPluginOptions', () => { finalize: undefined, }, _metaOptions: { - loggerPrefixOverride: '[@sentry/nextjs]', + loggerPrefixOverride: '[@sentry/nextjs - After Production Compile (Webpack)]', telemetry: { metaFramework: 'nextjs', }, }, bundleSizeOptimizations: {}, + reactComponentAnnotation: undefined, // Should be undefined for after-production-compile }); }); - it('normalizes Windows paths to posix for glob patterns', () => { + it('normalizes Windows paths to posix for glob patterns in after-production-compile builds', () => { const windowsPath = 'C:\\Users\\test\\.next'; const sentryBuildOptions: SentryBuildOptions = { org: 'test-org', @@ -57,14 +65,253 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: windowsPath, + buildTool: 'after-production-compile-webpack', }); - expect(result.sourcemaps?.assets).toEqual(['C:/Users/test/.next/**']); + expect(result.sourcemaps?.assets).toEqual([ + 'C:/Users/test/.next/server', + 'C:/Users/test/.next/static/chunks/pages', + 'C:/Users/test/.next/static/chunks/app', + ]); + }); + + it('normalizes Windows paths to posix for webpack builds', () => { + const windowsPath = 'C:\\Users\\test\\.next'; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: windowsPath, + buildTool: 'webpack-client', + }); + + expect(result.sourcemaps?.assets).toEqual([ + 'C:/Users/test/.next/static/chunks/pages/**', + 'C:/Users/test/.next/static/chunks/app/**', + ]); + }); + }); + + describe('build tool specific behavior', () => { + const baseSentryOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + it('configures webpack-client build correctly', () => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', + }); + + expect(result._metaOptions?.loggerPrefixOverride).toBe('[@sentry/nextjs - Client]'); + expect(result.sourcemaps?.assets).toEqual([ + '/path/to/.next/static/chunks/pages/**', + '/path/to/.next/static/chunks/app/**', + ]); + expect(result.sourcemaps?.ignore).toEqual([ + '/path/to/.next/static/chunks/main-*', + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ]); + expect(result.reactComponentAnnotation).toBeDefined(); + }); + + it('configures webpack-client build with widenClientFileUpload correctly', () => { + const result = getBuildPluginOptions({ + sentryBuildOptions: { + ...baseSentryOptions, + widenClientFileUpload: true, + }, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', + }); + + expect(result.sourcemaps?.assets).toEqual(['/path/to/.next/static/chunks/**']); + expect(result.sourcemaps?.ignore).toEqual([ + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ]); + }); + + it('configures webpack-nodejs build correctly', () => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-nodejs', + }); + + expect(result._metaOptions?.loggerPrefixOverride).toBe('[@sentry/nextjs - Node.js]'); + expect(result.sourcemaps?.assets).toEqual(['/path/to/.next/server/**', '/path/to/.next/serverless/**']); + expect(result.sourcemaps?.ignore).toEqual([ + '/path/to/.next/static/chunks/main-*', + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ]); + expect(result.reactComponentAnnotation).toBeDefined(); + }); + + it('configures webpack-edge build correctly', () => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-edge', + }); + + expect(result._metaOptions?.loggerPrefixOverride).toBe('[@sentry/nextjs - Edge]'); + expect(result.sourcemaps?.assets).toEqual(['/path/to/.next/server/**', '/path/to/.next/serverless/**']); + expect(result.sourcemaps?.ignore).toEqual([ + '/path/to/.next/static/chunks/main-*', + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ]); + expect(result.reactComponentAnnotation).toBeDefined(); + }); + + it('configures after-production-compile-webpack build correctly', () => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-webpack', + }); + + expect(result._metaOptions?.loggerPrefixOverride).toBe('[@sentry/nextjs - After Production Compile (Webpack)]'); + expect(result.sourcemaps?.assets).toEqual([ + '/path/to/.next/server', + '/path/to/.next/static/chunks/pages', + '/path/to/.next/static/chunks/app', + ]); + expect(result.sourcemaps?.ignore).toEqual([ + '/path/to/.next/static/chunks/main-*', + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ]); + expect(result.reactComponentAnnotation).toBeUndefined(); + }); + + it('configures after-production-compile-turbopack build correctly', () => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-turbopack', + }); + + expect(result._metaOptions?.loggerPrefixOverride).toBe('[@sentry/nextjs - After Production Compile (Turbopack)]'); + expect(result.sourcemaps?.assets).toEqual([ + '/path/to/.next/server', + '/path/to/.next/static/chunks', // Turbopack uses broader pattern + ]); + expect(result.sourcemaps?.ignore).toEqual([ + '/path/to/.next/static/chunks/main-*', + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ]); + expect(result.reactComponentAnnotation).toBeUndefined(); + }); + }); + + describe('useRunAfterProductionCompileHook functionality', () => { + const baseSentryOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + it('disables sourcemaps when useRunAfterProductionCompileHook is true for webpack builds', () => { + const webpackBuildTools = ['webpack-client', 'webpack-nodejs', 'webpack-edge'] as const; + + webpackBuildTools.forEach(buildTool => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool, + useRunAfterProductionCompileHook: true, + }); + + expect(result.sourcemaps?.disable).toBe(true); + }); + }); + + it('does not disable sourcemaps when useRunAfterProductionCompileHook is true for after-production-compile builds', () => { + const afterProductionCompileBuildTools = [ + 'after-production-compile-webpack', + 'after-production-compile-turbopack', + ] as const; + + afterProductionCompileBuildTools.forEach(buildTool => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool, + useRunAfterProductionCompileHook: true, + }); + + expect(result.sourcemaps?.disable).toBe(false); + }); + }); + + it('does not disable sourcemaps when useRunAfterProductionCompileHook is false', () => { + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', + useRunAfterProductionCompileHook: false, + }); + + expect(result.sourcemaps?.disable).toBe(false); }); }); describe('sourcemap configuration', () => { - it('configures file deletion when deleteSourcemapsAfterUpload is enabled', () => { + it('configures file deletion when deleteSourcemapsAfterUpload is enabled for after-production-compile-webpack', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-webpack', + }); + + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ + '/path/to/.next/static/**/*.js.map', + '/path/to/.next/static/**/*.mjs.map', + '/path/to/.next/static/**/*.cjs.map', + ]); + }); + + it('configures file deletion when deleteSourcemapsAfterUpload is enabled for after-production-compile-turbopack', () => { const sentryBuildOptions: SentryBuildOptions = { org: 'test-org', project: 'test-project', @@ -77,15 +324,84 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-turbopack', }); expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ - '/path/to/.next/**/*.js.map', - '/path/to/.next/**/*.mjs.map', - '/path/to/.next/**/*.cjs.map', + '/path/to/.next/static/**/*.js.map', + '/path/to/.next/static/**/*.mjs.map', + '/path/to/.next/static/**/*.cjs.map', ]); }); + it('configures file deletion when deleteSourcemapsAfterUpload is enabled for webpack-client without useRunAfterProductionCompileHook', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', + useRunAfterProductionCompileHook: false, + }); + + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ + '/path/to/.next/static/**/*.js.map', + '/path/to/.next/static/**/*.mjs.map', + '/path/to/.next/static/**/*.cjs.map', + ]); + }); + + it('does not configure file deletion when deleteSourcemapsAfterUpload is enabled for webpack-client with useRunAfterProductionCompileHook', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', + useRunAfterProductionCompileHook: true, + }); + + // File deletion should be undefined when using the hook + expect(result.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + + it('does not configure file deletion for server builds even when deleteSourcemapsAfterUpload is enabled', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const serverBuildTools = ['webpack-nodejs', 'webpack-edge'] as const; + + serverBuildTools.forEach(buildTool => { + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool, + }); + + expect(result.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + }); + it('does not configure file deletion when deleteSourcemapsAfterUpload is disabled', () => { const sentryBuildOptions: SentryBuildOptions = { org: 'test-org', @@ -99,9 +415,10 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); - expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([]); + expect(result.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); }); it('uses custom sourcemap assets when provided', () => { @@ -118,6 +435,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result.sourcemaps?.assets).toEqual(customAssets); @@ -137,6 +455,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result.sourcemaps?.ignore).toEqual(customIgnore); @@ -155,6 +474,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result.sourcemaps?.disable).toBe(true); @@ -172,6 +492,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); const rewriteSources = result.sourcemaps?.rewriteSources; @@ -209,6 +530,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result.release).toMatchObject({ @@ -230,6 +552,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: undefined, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result.release).toMatchObject({ @@ -263,6 +586,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); // The unstable_sentryWebpackPluginOptions.release is spread at the end and may override base properties @@ -272,7 +596,7 @@ describe('getBuildPluginOptions', () => { }); describe('react component annotation', () => { - it('merges react component annotation options correctly', () => { + it('merges react component annotation options correctly for webpack builds', () => { const sentryBuildOptions: SentryBuildOptions = { org: 'test-org', project: 'test-project', @@ -290,11 +614,38 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); // The unstable options override the base options - in this case enabled should be false expect(result.reactComponentAnnotation).toHaveProperty('enabled', false); }); + + it('sets react component annotation to undefined for after-production-compile builds', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + reactComponentAnnotation: { + enabled: true, + }, + }; + + const afterProductionCompileBuildTools = [ + 'after-production-compile-webpack', + 'after-production-compile-turbopack', + ] as const; + + afterProductionCompileBuildTools.forEach(buildTool => { + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool, + }); + + expect(result.reactComponentAnnotation).toBeUndefined(); + }); + }); }); describe('other configuration options', () => { @@ -318,6 +669,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result).toMatchObject({ @@ -352,6 +704,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result).toMatchObject({ @@ -374,6 +727,7 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: undefined, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'webpack-client', }); expect(result.release).toMatchObject({ @@ -394,13 +748,20 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-webpack', }); expect(result.sourcemaps).toMatchObject({ - disable: undefined, - assets: ['/path/to/.next/**'], - ignore: [], - filesToDeleteAfterUpload: [], + disable: false, + assets: ['/path/to/.next/server', '/path/to/.next/static/chunks/pages', '/path/to/.next/static/chunks/app'], + ignore: [ + '/path/to/.next/static/chunks/main-*', + '/path/to/.next/static/chunks/framework-*', + '/path/to/.next/static/chunks/framework.*', + '/path/to/.next/static/chunks/polyfills-*', + '/path/to/.next/static/chunks/webpack-*', + ], + filesToDeleteAfterUpload: undefined, rewriteSources: expect.any(Function), }); }); @@ -419,13 +780,14 @@ describe('getBuildPluginOptions', () => { sentryBuildOptions, releaseName: mockReleaseName, distDirAbsPath: complexPath, + buildTool: 'after-production-compile-turbopack', }); - expect(result.sourcemaps?.assets).toEqual([`${complexPath}/**`]); + expect(result.sourcemaps?.assets).toEqual([`${complexPath}/server`, `${complexPath}/static/chunks`]); expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ - `${complexPath}/**/*.js.map`, - `${complexPath}/**/*.mjs.map`, - `${complexPath}/**/*.cjs.map`, + `${complexPath}/static/**/*.js.map`, + `${complexPath}/static/**/*.mjs.map`, + `${complexPath}/static/**/*.cjs.map`, ]); }); }); diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index 22973cb6f15b..f32eb28ddcfc 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -79,7 +79,7 @@ describe('handleRunAfterProductionCompile', () => { }), { buildTool: 'turbopack', - loggerPrefix: '[@sentry/nextjs]', + loggerPrefix: '[@sentry/nextjs - After Production Compile]', }, ); }); @@ -108,7 +108,7 @@ describe('handleRunAfterProductionCompile', () => { }); describe('webpack builds', () => { - it('skips execution for webpack builds', async () => { + it('executes all build steps for webpack builds', async () => { await handleRunAfterProductionCompile( { releaseName: 'test-release', @@ -118,11 +118,16 @@ describe('handleRunAfterProductionCompile', () => { mockSentryBuildOptions, ); - expect(loadModule).not.toHaveBeenCalled(); - expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + expect(mockSentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal).toHaveBeenCalledTimes(1); + expect(mockSentryBuildPluginManager.createRelease).toHaveBeenCalledTimes(1); + expect(mockSentryBuildPluginManager.injectDebugIds).toHaveBeenCalledWith(['/path/to/.next']); + expect(mockSentryBuildPluginManager.uploadSourcemaps).toHaveBeenCalledWith(['/path/to/.next'], { + prepareArtifacts: false, + }); + expect(mockSentryBuildPluginManager.deleteArtifacts).toHaveBeenCalledTimes(1); }); - it('does not log debug message for webpack builds when debug is enabled', async () => { + it('logs debug message for webpack builds when debug is enabled', async () => { const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); const debugOptions = { @@ -139,7 +144,7 @@ describe('handleRunAfterProductionCompile', () => { debugOptions, ); - expect(consoleSpy).not.toHaveBeenCalledWith('[@sentry/nextjs] Running runAfterProductionCompile logic.'); + expect(consoleSpy).toHaveBeenCalledWith('[@sentry/nextjs] Running runAfterProductionCompile logic.'); consoleSpy.mockRestore(); }); diff --git a/packages/nextjs/test/config/testUtils.ts b/packages/nextjs/test/config/testUtils.ts index 19e2a8f1c326..c3769dfdf00f 100644 --- a/packages/nextjs/test/config/testUtils.ts +++ b/packages/nextjs/test/config/testUtils.ts @@ -56,6 +56,10 @@ export async function materializeFinalWebpackConfig(options: { incomingWebpackConfig: WebpackConfigObject; incomingWebpackBuildContext: BuildContext; sentryBuildTimeOptions?: SentryBuildOptions; + releaseName?: string; + routeManifest?: any; + nextJsVersion?: string; + useRunAfterProductionCompileHook?: boolean; }): Promise { const { exportedNextConfig, incomingWebpackConfig, incomingWebpackBuildContext } = options; @@ -66,11 +70,15 @@ export async function materializeFinalWebpackConfig(options: { : exportedNextConfig; // get the webpack config function we'd normally pass back to next - const webpackConfigFunction = constructWebpackConfigFunction( - materializedUserNextConfig, - options.sentryBuildTimeOptions, - undefined, - ); + const webpackConfigFunction = constructWebpackConfigFunction({ + userNextConfig: materializedUserNextConfig, + userSentryOptions: options.sentryBuildTimeOptions || {}, + releaseName: options.releaseName, + routeManifest: options.routeManifest, + nextJsVersion: options.nextJsVersion, + useRunAfterProductionCompileHook: + options.useRunAfterProductionCompileHook ?? options.sentryBuildTimeOptions?.useRunAfterProductionCompileHook, + }); // call it to get concrete values for comparison const finalWebpackConfigValue = webpackConfigFunction(incomingWebpackConfig, incomingWebpackBuildContext); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 7371d35c859a..b8cfb4015512 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -2,8 +2,8 @@ import '../mocks'; import * as core from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; +import * as getBuildPluginOptionsModule from '../../../src/config/getBuildPluginOptions'; import * as util from '../../../src/config/util'; -import * as getWebpackPluginOptionsModule from '../../../src/config/webpackPluginOptions'; import { CLIENT_SDK_CONFIG_FILE, clientBuildContext, @@ -55,7 +55,7 @@ describe('constructWebpackConfigFunction()', () => { }); it('automatically enables deleteSourcemapsAfterUpload for client builds when not explicitly set', async () => { - const getWebpackPluginOptionsSpy = vi.spyOn(getWebpackPluginOptionsModule, 'getWebpackPluginOptions'); + const getBuildPluginOptionsSpy = vi.spyOn(getBuildPluginOptionsModule, 'getBuildPluginOptions'); vi.spyOn(core, 'loadModule').mockImplementation(() => ({ sentryWebpackPlugin: () => ({ _name: 'sentry-webpack-plugin', @@ -71,19 +71,96 @@ describe('constructWebpackConfigFunction()', () => { }, }); - expect(getWebpackPluginOptionsSpy).toHaveBeenCalledWith( + expect(getBuildPluginOptionsSpy).toHaveBeenCalledWith( expect.objectContaining({ - isServer: false, + sentryBuildOptions: expect.objectContaining({ + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }), + buildTool: 'webpack-client', + distDirAbsPath: expect.any(String), + releaseName: undefined, + }), + ); + + getBuildPluginOptionsSpy.mockRestore(); + }); + + it('passes useRunAfterProductionCompileHook to getBuildPluginOptions when enabled', async () => { + const getBuildPluginOptionsSpy = vi.spyOn(getBuildPluginOptionsModule, 'getBuildPluginOptions'); + vi.spyOn(core, 'loadModule').mockImplementation(() => ({ + sentryWebpackPlugin: () => ({ + _name: 'sentry-webpack-plugin', }), + })); + + await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: { + useRunAfterProductionCompileHook: true, + }, + }); + + expect(getBuildPluginOptionsSpy).toHaveBeenCalledWith( expect.objectContaining({ - sourcemaps: { - deleteSourcemapsAfterUpload: true, - }, + useRunAfterProductionCompileHook: true, + }), + ); + + getBuildPluginOptionsSpy.mockRestore(); + }); + + it('passes useRunAfterProductionCompileHook to getBuildPluginOptions when disabled', async () => { + const getBuildPluginOptionsSpy = vi.spyOn(getBuildPluginOptionsModule, 'getBuildPluginOptions'); + vi.spyOn(core, 'loadModule').mockImplementation(() => ({ + sentryWebpackPlugin: () => ({ + _name: 'sentry-webpack-plugin', + }), + })); + + await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: { + useRunAfterProductionCompileHook: false, + }, + }); + + expect(getBuildPluginOptionsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + useRunAfterProductionCompileHook: false, + }), + ); + + getBuildPluginOptionsSpy.mockRestore(); + }); + + it('passes useRunAfterProductionCompileHook as undefined when not specified', async () => { + const getBuildPluginOptionsSpy = vi.spyOn(getBuildPluginOptionsModule, 'getBuildPluginOptions'); + vi.spyOn(core, 'loadModule').mockImplementation(() => ({ + sentryWebpackPlugin: () => ({ + _name: 'sentry-webpack-plugin', + }), + })); + + await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: {}, + }); + + expect(getBuildPluginOptionsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + useRunAfterProductionCompileHook: undefined, }), - undefined, ); - getWebpackPluginOptionsSpy.mockRestore(); + getBuildPluginOptionsSpy.mockRestore(); }); it('preserves unrelated webpack config options', async () => { diff --git a/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts b/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts deleted file mode 100644 index e95ab5c82bf8..000000000000 --- a/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { BuildContext, NextConfigObject } from '../../../src/config/types'; -import { getWebpackPluginOptions } from '../../../src/config/webpackPluginOptions'; - -function generateBuildContext(overrides: { - dir?: string; - isServer: boolean; - nextjsConfig?: NextConfigObject; -}): BuildContext { - return { - dev: false, // The plugin is not included in dev mode - isServer: overrides.isServer, - buildId: 'test-build-id', - dir: overrides.dir ?? '/my/project/dir', - config: overrides.nextjsConfig ?? {}, - totalPages: 2, - defaultLoaders: true, - webpack: { - version: '4.0.0', - DefinePlugin: {} as any, - }, - }; -} - -describe('getWebpackPluginOptions()', () => { - it('forwards relevant options', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions( - buildContext, - { - authToken: 'my-auth-token', - headers: { 'my-test-header': 'test' }, - org: 'my-org', - project: 'my-project', - telemetry: false, - reactComponentAnnotation: { - enabled: true, - ignoredComponents: ['myComponent'], - }, - silent: false, - debug: true, - sentryUrl: 'my-url', - sourcemaps: { - assets: ['my-asset'], - ignore: ['my-ignore'], - }, - release: { - name: 'my-release', - create: false, - finalize: false, - dist: 'my-dist', - vcsRemote: 'my-origin', - setCommits: { - auto: true, - }, - deploy: { - env: 'my-env', - }, - }, - }, - 'my-release', - ); - - expect(generatedPluginOptions.authToken).toBe('my-auth-token'); - expect(generatedPluginOptions.debug).toBe(true); - expect(generatedPluginOptions.headers).toStrictEqual({ 'my-test-header': 'test' }); - expect(generatedPluginOptions.org).toBe('my-org'); - expect(generatedPluginOptions.project).toBe('my-project'); - expect(generatedPluginOptions.reactComponentAnnotation?.enabled).toBe(true); - expect(generatedPluginOptions.reactComponentAnnotation?.ignoredComponents).toStrictEqual(['myComponent']); - expect(generatedPluginOptions.release?.create).toBe(false); - expect(generatedPluginOptions.release?.deploy?.env).toBe('my-env'); - expect(generatedPluginOptions.release?.dist).toBe('my-dist'); - expect(generatedPluginOptions.release?.finalize).toBe(false); - expect(generatedPluginOptions.release?.name).toBe('my-release'); - expect(generatedPluginOptions.release?.setCommits?.auto).toBe(true); - expect(generatedPluginOptions.release?.vcsRemote).toBe('my-origin'); - expect(generatedPluginOptions.silent).toBe(false); - expect(generatedPluginOptions.sourcemaps?.assets).toStrictEqual(['my-asset']); - expect(generatedPluginOptions.sourcemaps?.ignore).toStrictEqual(['my-ignore']); - expect(generatedPluginOptions.telemetry).toBe(false); - expect(generatedPluginOptions.url).toBe('my-url'); - - expect(generatedPluginOptions).toMatchObject({ - authToken: 'my-auth-token', - debug: true, - headers: { - 'my-test-header': 'test', - }, - org: 'my-org', - project: 'my-project', - reactComponentAnnotation: { - enabled: true, - ignoredComponents: ['myComponent'], - }, - release: { - create: false, - deploy: { - env: 'my-env', - }, - dist: 'my-dist', - finalize: false, - inject: false, - name: 'my-release', - setCommits: { - auto: true, - }, - vcsRemote: 'my-origin', - }, - silent: false, - sourcemaps: { - assets: ['my-asset'], - ignore: ['my-ignore'], - }, - telemetry: false, - url: 'my-url', - }); - }); - - it('forwards bundleSizeOptimization options', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions( - buildContext, - { - bundleSizeOptimizations: { - excludeTracing: true, - excludeReplayShadowDom: false, - }, - }, - undefined, - ); - - expect(generatedPluginOptions).toMatchObject({ - bundleSizeOptimizations: { - excludeTracing: true, - excludeReplayShadowDom: false, - }, - }); - }); - - it('forwards errorHandler option', () => { - const buildContext = generateBuildContext({ isServer: false }); - const mockErrorHandler = (err: Error) => { - throw err; - }; - - const generatedPluginOptions = getWebpackPluginOptions( - buildContext, - { - errorHandler: mockErrorHandler, - }, - undefined, - ); - - expect(generatedPluginOptions.errorHandler).toBe(mockErrorHandler); - }); - - it('returns the right `assets` and `ignore` values during the server build', () => { - const buildContext = generateBuildContext({ isServer: true }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}, undefined); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['/my/project/dir/.next/server/**', '/my/project/dir/.next/serverless/**'], - ignore: [], - }); - }); - - it('returns the right `assets` and `ignore` values during the client build', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}, undefined); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['/my/project/dir/.next/static/chunks/pages/**', '/my/project/dir/.next/static/chunks/app/**'], - ignore: [ - '/my/project/dir/.next/static/chunks/main-*', - '/my/project/dir/.next/static/chunks/framework-*', - '/my/project/dir/.next/static/chunks/framework.*', - '/my/project/dir/.next/static/chunks/polyfills-*', - '/my/project/dir/.next/static/chunks/webpack-*', - ], - }); - }); - - it('returns the right `assets` and `ignore` values during the client build with `widenClientFileUpload`', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }, undefined); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['/my/project/dir/.next/static/chunks/**'], - ignore: [ - '/my/project/dir/.next/static/chunks/framework-*', - '/my/project/dir/.next/static/chunks/framework.*', - '/my/project/dir/.next/static/chunks/polyfills-*', - '/my/project/dir/.next/static/chunks/webpack-*', - ], - }); - }); - - it('sets `sourcemaps.disable` plugin options to true when `sourcemaps.disable` is true', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { sourcemaps: { disable: true } }, undefined); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - disable: true, - }); - }); - - it('passes posix paths to the plugin', () => { - const buildContext = generateBuildContext({ - dir: 'C:\\my\\windows\\project\\dir', - nextjsConfig: { distDir: '.dist\\v1' }, - isServer: false, - }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }, undefined); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['C:/my/windows/project/dir/.dist/v1/static/chunks/**'], - ignore: [ - 'C:/my/windows/project/dir/.dist/v1/static/chunks/framework-*', - 'C:/my/windows/project/dir/.dist/v1/static/chunks/framework.*', - 'C:/my/windows/project/dir/.dist/v1/static/chunks/polyfills-*', - 'C:/my/windows/project/dir/.dist/v1/static/chunks/webpack-*', - ], - }); - }); - - it('sets options to not create a release or do any release operations when releaseName is undefined', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}, undefined); - - expect(generatedPluginOptions).toMatchObject({ - release: { - inject: false, - create: false, - finalize: false, - }, - }); - }); -}); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index 9303223c97bc..0697fc56b9e4 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -457,7 +457,11 @@ describe('withSentryConfig', () => { const cleanConfig = { ...exportedNextConfig }; delete cleanConfig.productionBrowserSourceMaps; - materializeFinalNextConfig(cleanConfig); + const sentryOptions = { + debug: true, + }; + + materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); expect(consoleSpy).toHaveBeenCalledWith( '[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.', @@ -476,6 +480,7 @@ describe('withSentryConfig', () => { delete cleanConfig.productionBrowserSourceMaps; const sentryOptions = { + debug: true, sourcemaps: {}, // triggers automatic deletion }; @@ -764,27 +769,27 @@ describe('withSentryConfig', () => { vi.restoreAllMocks(); }); - it('sets up runAfterProductionCompile hook when experimental flag is enabled and version is supported', () => { + it('sets up runAfterProductionCompile hook when flag is enabled and version is supported', () => { vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; - const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + // Use a clean copy of the config to avoid test interference + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); }); - it('does not set up hook when experimental flag is disabled', () => { + it('does not set up hook when flag is disabled', () => { vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: false, - }, + useRunAfterProductionCompileHook: false, }; const cleanConfig = { ...exportedNextConfig }; @@ -799,9 +804,7 @@ describe('withSentryConfig', () => { vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; const cleanConfig = { ...exportedNextConfig }; @@ -824,9 +827,7 @@ describe('withSentryConfig', () => { }; const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; const finalConfig = materializeFinalNextConfig(configWithExistingHook, undefined, sentryOptions); @@ -847,9 +848,7 @@ describe('withSentryConfig', () => { }; const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; materializeFinalNextConfig(configWithInvalidHook, undefined, sentryOptions); @@ -868,9 +867,7 @@ describe('withSentryConfig', () => { delete configWithoutCompiler.compiler; const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; const finalConfig = materializeFinalNextConfig(configWithoutCompiler, undefined, sentryOptions); @@ -879,69 +876,44 @@ describe('withSentryConfig', () => { expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); }); - it('works with turbopack builds when TURBOPACK env is set', () => { + it('defaults to true for turbopack when useRunAfterProductionCompileHook is not specified', () => { process.env.TURBOPACK = '1'; vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); - const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, - }; + const sentryOptions = {}; // No useRunAfterProductionCompileHook specified - const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); delete process.env.TURBOPACK; }); - it('works with webpack builds when TURBOPACK env is not set', () => { + it('defaults to false for webpack when useRunAfterProductionCompileHook is not specified', () => { delete process.env.TURBOPACK; vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); - const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, - }; - - const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); - - expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); - }); - }); - - describe('experimental flag handling', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('respects useRunAfterProductionCompileHook: true', () => { - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); - - const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, - }; + const sentryOptions = {}; // No useRunAfterProductionCompileHook specified const cleanConfig = { ...exportedNextConfig }; delete cleanConfig.compiler; const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); - expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); }); - it('respects useRunAfterProductionCompileHook: false', () => { + it('respects explicit false setting for turbopack', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: false, - }, + useRunAfterProductionCompileHook: false, }; const cleanConfig = { ...exportedNextConfig }; @@ -950,61 +922,50 @@ describe('withSentryConfig', () => { const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + + delete process.env.TURBOPACK; }); - it('does not set up hook when experimental flag is undefined', () => { + it('respects explicit true setting for webpack', () => { + delete process.env.TURBOPACK; vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - _experimental: { - // useRunAfterProductionCompileHook not specified - }, + useRunAfterProductionCompileHook: true, }; - const cleanConfig = { ...exportedNextConfig }; - delete cleanConfig.compiler; - - const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); - expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); }); - it('does not set up hook when _experimental is undefined', () => { + it('works with turbopack builds when TURBOPACK env is set', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - // no _experimental property + useRunAfterProductionCompileHook: true, }; - const cleanConfig = { ...exportedNextConfig }; - delete cleanConfig.compiler; + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); - const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); - expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + delete process.env.TURBOPACK; }); - it('combines experimental flag with other configurations correctly', () => { - process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + it('works with webpack builds when TURBOPACK env is not set', () => { + delete process.env.TURBOPACK; vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, - sourcemaps: {}, - tunnelRoute: '/tunnel', + useRunAfterProductionCompileHook: true, }; const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); - // Should have both turbopack sourcemap config AND runAfterProductionCompile hook - expect(finalConfig.productionBrowserSourceMaps).toBe(true); expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); - expect(finalConfig.rewrites).toBeInstanceOf(Function); - - delete process.env.TURBOPACK; }); }); }); diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index d763ead2fd26..aca94a70db7a 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -266,6 +266,8 @@ const PLATFORM_NAMES: { [platform: string]: string } = { openbsd: 'OpenBSD', sunos: 'SunOS', win32: 'Windows', + ohos: 'OpenHarmony', + android: 'Android', }; /** Linux version file to check for a distribution. */ diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 8f81f28c8eb6..e89af730302d 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, RequestOptions } from 'node:http'; -import { defineIntegration } from '@sentry/core'; +import { debug, defineIntegration } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; import { generateInstrumentOnce } from '../../otel/instrument'; import type { SentryHttpInstrumentationOptions } from './SentryHttpInstrumentation'; import { SentryHttpInstrumentation } from './SentryHttpInstrumentation'; @@ -62,10 +63,10 @@ interface HttpOptions { /** * Do not capture spans for incoming HTTP requests with the given status codes. - * By default, spans with 404 status code are ignored. + * By default, spans with some 3xx and 4xx status codes are ignored (see @default). * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. * - * @default `[[401, 404], [300, 399]]` + * @default `[[401, 404], [301, 303], [305, 399]]` */ dropSpansForIncomingRequestStatusCodes?: (number | [number, number])[]; @@ -115,7 +116,9 @@ export const instrumentSentryHttp = generateInstrumentOnce { const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [ [401, 404], - [300, 399], + // 300 and 304 are possibly valid status codes we do not want to filter + [301, 303], + [305, 399], ]; return { @@ -133,18 +136,12 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => // Drop transaction if it has a status code that should be ignored if (event.type === 'transaction') { const statusCode = event.contexts?.trace?.data?.['http.response.status_code']; - if ( - typeof statusCode === 'number' && - dropSpansForIncomingRequestStatusCodes.some(code => { - if (typeof code === 'number') { - return code === statusCode; - } - - const [min, max] = code; - return statusCode >= min && statusCode <= max; - }) - ) { - return null; + if (typeof statusCode === 'number') { + const shouldDrop = shouldFilterStatusCode(statusCode, dropSpansForIncomingRequestStatusCodes); + if (shouldDrop) { + DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode); + return null; + } } } @@ -152,3 +149,17 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => }, }; }); + +/** + * If the given status code should be filtered for the given list of status codes/ranges. + */ +function shouldFilterStatusCode(statusCode: number, dropForStatusCodes: (number | [number, number])[]): boolean { + return dropForStatusCodes.some(code => { + if (typeof code === 'number') { + return code === statusCode; + } + + const [min, max] = code; + return statusCode >= min && statusCode <= max; + }); +} diff --git a/packages/node-core/src/logs/capture.ts b/packages/node-core/src/logs/capture.ts index 17f94399f9bf..4c2fdc73a34c 100644 --- a/packages/node-core/src/logs/capture.ts +++ b/packages/node-core/src/logs/capture.ts @@ -1,10 +1,28 @@ import { format } from 'node:util'; -import type { Log, LogSeverityLevel, ParameterizedString } from '@sentry/core'; +import type { Log, LogSeverityLevel, ParameterizedString, Scope } from '@sentry/core'; import { _INTERNAL_captureLog } from '@sentry/core'; -export type CaptureLogArgs = - | [message: ParameterizedString, attributes?: Log['attributes']] - | [messageTemplate: string, messageParams: Array, attributes?: Log['attributes']]; +/** + * Additional metadata to capture the log with. + */ +interface CaptureLogMetadata { + scope?: Scope; +} + +type CaptureLogArgWithTemplate = [ + messageTemplate: string, + messageParams: Array, + attributes?: Log['attributes'], + metadata?: CaptureLogMetadata, +]; + +type CaptureLogArgWithoutTemplate = [ + message: ParameterizedString, + attributes?: Log['attributes'], + metadata?: CaptureLogMetadata, +]; + +export type CaptureLogArgs = CaptureLogArgWithTemplate | CaptureLogArgWithoutTemplate; /** * Capture a log with the given level. @@ -14,16 +32,19 @@ export type CaptureLogArgs = * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. */ export function captureLog(level: LogSeverityLevel, ...args: CaptureLogArgs): void { - const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributes] = args; + const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributesOrMetadata, maybeMetadata] = args; if (Array.isArray(paramsOrAttributes)) { - const attributes = { ...maybeAttributes }; + const attributes = { ...(maybeAttributesOrMetadata as Log['attributes']) }; attributes['sentry.message.template'] = messageOrMessageTemplate; paramsOrAttributes.forEach((param, index) => { attributes[`sentry.message.parameter.${index}`] = param; }); const message = format(messageOrMessageTemplate, ...paramsOrAttributes); - _INTERNAL_captureLog({ level, message, attributes }); + _INTERNAL_captureLog({ level, message, attributes }, maybeMetadata?.scope); } else { - _INTERNAL_captureLog({ level, message: messageOrMessageTemplate, attributes: paramsOrAttributes }); + _INTERNAL_captureLog( + { level, message: messageOrMessageTemplate, attributes: paramsOrAttributes }, + maybeMetadata?.scope, + ); } } diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index 0d7bea423ace..e631508c7392 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -77,9 +77,9 @@ export class NodeClient extends ServerRuntimeClient { return tracer; } - // Eslint ignore explanation: This is already documented in super. - // eslint-disable-next-line jsdoc/require-jsdoc - public async flush(timeout?: number): Promise { + /** @inheritDoc */ + // @ts-expect-error - PromiseLike is a subset of Promise + public async flush(timeout?: number): PromiseLike { await this.traceProvider?.forceFlush(); if (this.getOptions().sendClientReports) { @@ -89,9 +89,9 @@ export class NodeClient extends ServerRuntimeClient { return super.flush(timeout); } - // Eslint ignore explanation: This is already documented in super. - // eslint-disable-next-line jsdoc/require-jsdoc - public close(timeout?: number | undefined): PromiseLike { + /** @inheritDoc */ + // @ts-expect-error - PromiseLike is a subset of Promise + public async close(timeout?: number | undefined): PromiseLike { if (this._clientReportInterval) { clearInterval(this._clientReportInterval); } @@ -104,11 +104,12 @@ export class NodeClient extends ServerRuntimeClient { process.off('beforeExit', this._logOnExitFlushListener); } - return super - .close(timeout) - .then(allEventsSent => - this.traceProvider ? this.traceProvider.shutdown().then(() => allEventsSent) : allEventsSent, - ); + const allEventsSent = await super.close(timeout); + if (this.traceProvider) { + await this.traceProvider.shutdown(); + } + + return allEventsSent; } /** diff --git a/packages/node-core/test/logs/exports.test.ts b/packages/node-core/test/logs/exports.test.ts index 9e1cc4900e29..45da1722abc8 100644 --- a/packages/node-core/test/logs/exports.test.ts +++ b/packages/node-core/test/logs/exports.test.ts @@ -36,110 +36,140 @@ describe('Node Logger', () => { it('should call _INTERNAL_captureLog with trace level', () => { nodeLogger.trace('Test trace message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'trace', - message: 'Test trace message', - attributes: { key: 'value' }, - }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'trace', + message: 'Test trace message', + attributes: { key: 'value' }, + }, + undefined, + ); }); it('should call _INTERNAL_captureLog with debug level', () => { nodeLogger.debug('Test debug message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'debug', - message: 'Test debug message', - attributes: { key: 'value' }, - }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'debug', + message: 'Test debug message', + attributes: { key: 'value' }, + }, + undefined, + ); }); it('should call _INTERNAL_captureLog with info level', () => { nodeLogger.info('Test info message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test info message', - attributes: { key: 'value' }, - }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'info', + message: 'Test info message', + attributes: { key: 'value' }, + }, + undefined, + ); }); it('should call _INTERNAL_captureLog with warn level', () => { nodeLogger.warn('Test warn message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'warn', - message: 'Test warn message', - attributes: { key: 'value' }, - }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'warn', + message: 'Test warn message', + attributes: { key: 'value' }, + }, + undefined, + ); }); it('should call _INTERNAL_captureLog with error level', () => { nodeLogger.error('Test error message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'error', - message: 'Test error message', - attributes: { key: 'value' }, - }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'error', + message: 'Test error message', + attributes: { key: 'value' }, + }, + undefined, + ); }); it('should call _INTERNAL_captureLog with fatal level', () => { nodeLogger.fatal('Test fatal message', { key: 'value' }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'fatal', - message: 'Test fatal message', - attributes: { key: 'value' }, - }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'fatal', + message: 'Test fatal message', + attributes: { key: 'value' }, + }, + undefined, + ); }); }); describe('Template string logging', () => { it('should handle template strings with parameters', () => { nodeLogger.info('Hello %s, your balance is %d', ['John', 100], { userId: 123 }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Hello John, your balance is 100', - attributes: { - userId: 123, - 'sentry.message.template': 'Hello %s, your balance is %d', - 'sentry.message.parameter.0': 'John', - 'sentry.message.parameter.1': 100, + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'info', + message: 'Hello John, your balance is 100', + attributes: { + userId: 123, + 'sentry.message.template': 'Hello %s, your balance is %d', + 'sentry.message.parameter.0': 'John', + 'sentry.message.parameter.1': 100, + }, }, - }); + undefined, + ); }); it('should handle template strings without additional attributes', () => { nodeLogger.debug('User %s logged in from %s', ['Alice', 'mobile']); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'debug', - message: 'User Alice logged in from mobile', - attributes: { - 'sentry.message.template': 'User %s logged in from %s', - 'sentry.message.parameter.0': 'Alice', - 'sentry.message.parameter.1': 'mobile', + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'debug', + message: 'User Alice logged in from mobile', + attributes: { + 'sentry.message.template': 'User %s logged in from %s', + 'sentry.message.parameter.0': 'Alice', + 'sentry.message.parameter.1': 'mobile', + }, }, - }); + undefined, + ); }); it('should handle parameterized strings with parameters', () => { nodeLogger.info(nodeLogger.fmt`Hello ${'John'}, your balance is ${100}`, { userId: 123 }); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: expect.objectContaining({ - __sentry_template_string__: 'Hello %s, your balance is %s', - __sentry_template_values__: ['John', 100], - }), - attributes: { - userId: 123, + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'info', + message: expect.objectContaining({ + __sentry_template_string__: 'Hello %s, your balance is %s', + __sentry_template_values__: ['John', 100], + }), + attributes: { + userId: 123, + }, }, - }); + undefined, + ); }); it('should handle parameterized strings without additional attributes', () => { nodeLogger.debug(nodeLogger.fmt`User ${'Alice'} logged in from ${'mobile'}`); - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'debug', - message: expect.objectContaining({ - __sentry_template_string__: 'User %s logged in from %s', - __sentry_template_values__: ['Alice', 'mobile'], - }), - }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'debug', + message: expect.objectContaining({ + __sentry_template_string__: 'User %s logged in from %s', + __sentry_template_values__: ['Alice', 'mobile'], + }), + }, + undefined, + ); }); }); }); diff --git a/packages/node-core/test/sdk/client.test.ts b/packages/node-core/test/sdk/client.test.ts index 33548d621c13..01623f49f0a3 100644 --- a/packages/node-core/test/sdk/client.test.ts +++ b/packages/node-core/test/sdk/client.test.ts @@ -362,9 +362,7 @@ describe('NodeClient', () => { expect(result).toBe(true); - // once call directly in close to stop client reports, - // the other in core client `_isClientDoneProcessing` - expect(clearIntervalSpy).toHaveBeenCalledTimes(2); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); // removes `_clientReportOnExitFlushListener` expect(processOffSpy).toHaveBeenNthCalledWith(1, 'beforeExit', expect.any(Function)); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 84603db7e575..853ec8dbac2f 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -25,6 +25,7 @@ export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; +export { googleGenAIIntegration } from './integrations/tracing/google-genai'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler, diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 60999b108872..dc7b48b4862c 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -3,7 +3,7 @@ import { diag } from '@opentelemetry/api'; import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-http'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import type { Span } from '@sentry/core'; -import { defineIntegration, getClient, hasSpansEnabled } from '@sentry/core'; +import { debug, defineIntegration, getClient, hasSpansEnabled } from '@sentry/core'; import type { HTTPModuleRequestIncomingMessage, NodeClient } from '@sentry/node-core'; import { type SentryHttpInstrumentationOptions, @@ -13,6 +13,7 @@ import { NODE_VERSION, SentryHttpInstrumentation, } from '@sentry/node-core'; +import { DEBUG_BUILD } from '../debug-build'; import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'Http'; @@ -84,10 +85,10 @@ interface HttpOptions { /** * Do not capture spans for incoming HTTP requests with the given status codes. - * By default, spans with 404 status code are ignored. + * By default, spans with some 3xx and 4xx status codes are ignored (see @default). * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. * - * @default `[[401, 404], [300, 399]]` + * @default `[[401, 404], [301, 303], [305, 399]]` */ dropSpansForIncomingRequestStatusCodes?: (number | [number, number])[]; @@ -195,7 +196,9 @@ export function _shouldUseOtelHttpInstrumentation( export const httpIntegration = defineIntegration((options: HttpOptions = {}) => { const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [ [401, 404], - [300, 399], + // 300 and 304 are possibly valid status codes we do not want to filter + [301, 303], + [305, 399], ]; return { @@ -225,18 +228,12 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => // Drop transaction if it has a status code that should be ignored if (event.type === 'transaction') { const statusCode = event.contexts?.trace?.data?.['http.response.status_code']; - if ( - typeof statusCode === 'number' && - dropSpansForIncomingRequestStatusCodes.some(code => { - if (typeof code === 'number') { - return code === statusCode; - } - - const [min, max] = code; - return statusCode >= min && statusCode <= max; - }) - ) { - return null; + if (typeof statusCode === 'number') { + const shouldDrop = shouldFilterStatusCode(statusCode, dropSpansForIncomingRequestStatusCodes); + if (shouldDrop) { + DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode); + return null; + } } } @@ -282,3 +279,17 @@ function getConfigWithDefaults(options: Partial = {}): HttpInstrume return instrumentationConfig; } + +/** + * If the given status code should be filtered for the given list of status codes/ranges. + */ +function shouldFilterStatusCode(statusCode: number, dropForStatusCodes: (number | [number, number])[]): boolean { + return dropForStatusCodes.some(code => { + if (typeof code === 'number') { + return code === statusCode; + } + + const [min, max] = code; + return statusCode >= min && statusCode <= max; + }); +} diff --git a/packages/node/src/integrations/tracing/anthropic-ai/index.ts b/packages/node/src/integrations/tracing/anthropic-ai/index.ts index b9ec00013f49..65b7d72a869a 100644 --- a/packages/node/src/integrations/tracing/anthropic-ai/index.ts +++ b/packages/node/src/integrations/tracing/anthropic-ai/index.ts @@ -3,9 +3,9 @@ import { ANTHROPIC_AI_INTEGRATION_NAME, defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; import { SentryAnthropicAiInstrumentation } from './instrumentation'; -export const instrumentAnthropicAi = generateInstrumentOnce( +export const instrumentAnthropicAi = generateInstrumentOnce( ANTHROPIC_AI_INTEGRATION_NAME, - () => new SentryAnthropicAiInstrumentation({}), + options => new SentryAnthropicAiInstrumentation(options), ); const _anthropicAIIntegration = ((options: AnthropicAiOptions = {}) => { @@ -13,7 +13,7 @@ const _anthropicAIIntegration = ((options: AnthropicAiOptions = {}) => { name: ANTHROPIC_AI_INTEGRATION_NAME, options, setupOnce() { - instrumentAnthropicAi(); + instrumentAnthropicAi(options); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts index 99fd2c546dd2..d55689415aee 100644 --- a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -4,14 +4,12 @@ import { InstrumentationBase, InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; -import type { AnthropicAiClient, AnthropicAiOptions, Integration } from '@sentry/core'; -import { ANTHROPIC_AI_INTEGRATION_NAME, getCurrentScope, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; +import type { AnthropicAiClient, AnthropicAiOptions } from '@sentry/core'; +import { getClient, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=0.19.2 <1.0.0']; -export interface AnthropicAiIntegration extends Integration { - options: AnthropicAiOptions; -} +type AnthropicAiInstrumentationOptions = InstrumentationConfig & AnthropicAiOptions; /** * Represents the patched shape of the Anthropic AI module export. @@ -21,23 +19,11 @@ interface PatchedModuleExports { Anthropic: abstract new (...args: unknown[]) => AnthropicAiClient; } -/** - * Determines telemetry recording settings. - */ -function determineRecordingSettings( - integrationOptions: AnthropicAiOptions | undefined, - defaultEnabled: boolean, -): { recordInputs: boolean; recordOutputs: boolean } { - const recordInputs = integrationOptions?.recordInputs ?? defaultEnabled; - const recordOutputs = integrationOptions?.recordOutputs ?? defaultEnabled; - return { recordInputs, recordOutputs }; -} - /** * Sentry Anthropic AI instrumentation using OpenTelemetry. */ -export class SentryAnthropicAiInstrumentation extends InstrumentationBase { - public constructor(config: InstrumentationConfig = {}) { +export class SentryAnthropicAiInstrumentation extends InstrumentationBase { + public constructor(config: AnthropicAiInstrumentationOptions = {}) { super('@sentry/instrumentation-anthropic-ai', SDK_VERSION, config); } @@ -59,14 +45,15 @@ export class SentryAnthropicAiInstrumentation extends InstrumentationBase(ANTHROPIC_AI_INTEGRATION_NAME); - const integrationOpts = integration?.options; - const defaultPii = Boolean(scopeClient?.getOptions().sendDefaultPii); + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); - const { recordInputs, recordOutputs } = determineRecordingSettings(integrationOpts, defaultPii); + const recordInputs = config.recordInputs ?? defaultPii; + const recordOutputs = config.recordOutputs ?? defaultPii; return instrumentAnthropicAiClient(instance as AnthropicAiClient, { recordInputs, diff --git a/packages/node/src/integrations/tracing/google-genai/index.ts b/packages/node/src/integrations/tracing/google-genai/index.ts new file mode 100644 index 000000000000..5c1ad09d2fcd --- /dev/null +++ b/packages/node/src/integrations/tracing/google-genai/index.ts @@ -0,0 +1,73 @@ +import type { GoogleGenAIOptions, IntegrationFn } from '@sentry/core'; +import { defineIntegration, GOOGLE_GENAI_INTEGRATION_NAME } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryGoogleGenAiInstrumentation } from './instrumentation'; + +export const instrumentGoogleGenAI = generateInstrumentOnce( + GOOGLE_GENAI_INTEGRATION_NAME, + options => new SentryGoogleGenAiInstrumentation(options), +); + +const _googleGenAIIntegration = ((options: GoogleGenAIOptions = {}) => { + return { + name: GOOGLE_GENAI_INTEGRATION_NAME, + setupOnce() { + instrumentGoogleGenAI(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the Google Generative AI SDK. + * + * This integration is enabled by default. + * + * When configured, this integration automatically instruments Google GenAI SDK client instances + * to capture telemetry data following OpenTelemetry Semantic Conventions for Generative AI. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * + * Sentry.init({ + * integrations: [Sentry.googleGenAiIntegration()], + * }); + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record prompt messages (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```javascript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.googleGenAiIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.googleGenAiIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + */ +export const googleGenAIIntegration = defineIntegration(_googleGenAIIntegration); diff --git a/packages/node/src/integrations/tracing/google-genai/instrumentation.ts b/packages/node/src/integrations/tracing/google-genai/instrumentation.ts new file mode 100644 index 000000000000..cfdb68973be6 --- /dev/null +++ b/packages/node/src/integrations/tracing/google-genai/instrumentation.ts @@ -0,0 +1,102 @@ +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { + type InstrumentationModuleDefinition, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import type { GoogleGenAIClient, GoogleGenAIOptions } from '@sentry/core'; +import { getClient, instrumentGoogleGenAIClient, replaceExports, SDK_VERSION } from '@sentry/core'; + +const supportedVersions = ['>=0.10.0 <2']; + +/** + * Represents the patched shape of the Google GenAI module export. + */ +interface PatchedModuleExports { + [key: string]: unknown; + GoogleGenAI?: unknown; +} + +type GoogleGenAIInstrumentationOptions = GoogleGenAIOptions & InstrumentationConfig; + +/** + * Sentry Google GenAI instrumentation using OpenTelemetry. + */ +export class SentryGoogleGenAiInstrumentation extends InstrumentationBase { + public constructor(config: GoogleGenAIInstrumentationOptions = {}) { + super('@sentry/instrumentation-google-genai', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + */ + public init(): InstrumentationModuleDefinition { + const module = new InstrumentationNodeModuleDefinition( + '@google/genai', + supportedVersions, + exports => this._patch(exports), + exports => exports, + // In CJS, @google/genai re-exports from (dist/node/index.cjs) file. + // Patching only the root module sometimes misses the real implementation or + // gets overwritten when that file is loaded. We add a file-level patch so that + // _patch runs again on the concrete implementation + [ + new InstrumentationNodeModuleFile( + '@google/genai/dist/node/index.cjs', + supportedVersions, + exports => this._patch(exports), + exports => exports, + ), + ], + ); + return module; + } + + /** + * Core patch logic applying instrumentation to the Google GenAI client constructor. + */ + private _patch(exports: PatchedModuleExports): PatchedModuleExports | void { + const Original = exports.GoogleGenAI; + const config = this.getConfig(); + + if (typeof Original !== 'function') { + return exports; + } + + const WrappedGoogleGenAI = function (this: unknown, ...args: unknown[]): GoogleGenAIClient { + const instance = Reflect.construct(Original, args); + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const typedConfig = config as GoogleGenAIInstrumentationOptions; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordInputs = typedConfig?.recordInputs ?? defaultPii; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordOutputs = typedConfig?.recordOutputs ?? defaultPii; + + return instrumentGoogleGenAIClient(instance, { + recordInputs, + recordOutputs, + }); + }; + + // Preserve static and prototype chains + Object.setPrototypeOf(WrappedGoogleGenAI, Original); + Object.setPrototypeOf(WrappedGoogleGenAI.prototype, Original.prototype); + + for (const key of Object.getOwnPropertyNames(Original)) { + if (!['length', 'name', 'prototype'].includes(key)) { + const descriptor = Object.getOwnPropertyDescriptor(Original, key); + if (descriptor) { + Object.defineProperty(WrappedGoogleGenAI, key, descriptor); + } + } + } + + // Replace google genai exports with the wrapped constructor + replaceExports(exports, 'GoogleGenAI', WrappedGoogleGenAI); + + return exports; + } +} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 5341bfff3b78..e4dd84fc266e 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -7,6 +7,7 @@ import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; import { firebaseIntegration, instrumentFirebase } from './firebase'; import { genericPoolIntegration, instrumentGenericPool } from './genericPool'; +import { googleGenAIIntegration, instrumentGoogleGenAI } from './google-genai'; import { graphqlIntegration, instrumentGraphql } from './graphql'; import { hapiIntegration, instrumentHapi } from './hapi'; import { instrumentKafka, kafkaIntegration } from './kafka'; @@ -52,6 +53,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { postgresJsIntegration(), firebaseIntegration(), anthropicAIIntegration(), + googleGenAIIntegration(), ]; } @@ -87,5 +89,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentPostgresJs, instrumentFirebase, instrumentAnthropicAi, + instrumentGoogleGenAI, ]; } diff --git a/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index 76385009f5ba..23df5bb66c35 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -5,7 +5,7 @@ import { InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { Integration, OpenAiClient, OpenAiOptions } from '@sentry/core'; -import { getCurrentScope, instrumentOpenAiClient, OPENAI_INTEGRATION_NAME, SDK_VERSION } from '@sentry/core'; +import { getClient, instrumentOpenAiClient, OPENAI_INTEGRATION_NAME, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=4.0.0 <6']; @@ -57,10 +57,10 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase(OPENAI_INTEGRATION_NAME); + const client = getClient(); + const integration = client?.getIntegrationByName(OPENAI_INTEGRATION_NAME); const integrationOpts = integration?.options; - const defaultPii = Boolean(scopeClient?.getOptions().sendDefaultPii); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); const { recordInputs, recordOutputs } = determineRecordingSettings(integrationOpts, defaultPii); diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index bf32d417c42e..872e0153edba 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -7,9 +7,8 @@ import { addNonEnumerableProperty, captureException, getActiveSpan, - getCurrentScope, + getClient, handleCallbackErrors, - isThenable, SDK_VERSION, withScope, } from '@sentry/core'; @@ -212,57 +211,50 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { this._callbacks.forEach(callback => callback()); this._callbacks = []; - function generatePatch(originalMethod: (...args: MethodArgs) => unknown) { - return (...args: MethodArgs) => { - const existingExperimentalTelemetry = args[0].experimental_telemetry || {}; - const isEnabled = existingExperimentalTelemetry.isEnabled; - - const client = getCurrentScope().getClient(); - const integration = client?.getIntegrationByName(INTEGRATION_NAME); - const integrationOptions = integration?.options; - const shouldRecordInputsAndOutputs = integration ? Boolean(client?.getOptions().sendDefaultPii) : false; - - const { recordInputs, recordOutputs } = determineRecordingSettings( - integrationOptions, - existingExperimentalTelemetry, - isEnabled, - shouldRecordInputsAndOutputs, - ); - - args[0].experimental_telemetry = { - ...existingExperimentalTelemetry, - isEnabled: isEnabled !== undefined ? isEnabled : true, - recordInputs, - recordOutputs, - }; - - return handleCallbackErrors( - () => { - // @ts-expect-error we know that the method exists - const result = originalMethod.apply(this, args); - - if (isThenable(result)) { - // check for tool errors when the promise resolves, keep the original promise identity - result.then(checkResultForToolErrors, () => {}); - return result; - } - - // check for tool errors when the result is synchronous - checkResultForToolErrors(result); - return result; - }, - error => { - // This error bubbles up to unhandledrejection handler (if not handled before), - // where we do not know the active span anymore - // So to circumvent this, we set the active span on the error object - // which is picked up by the unhandledrejection handler - if (error && typeof error === 'object') { - addNonEnumerableProperty(error, '_sentry_active_span', getActiveSpan()); - } - }, - ); - }; - } + const generatePatch = unknown>(originalMethod: T): T => { + return new Proxy(originalMethod, { + apply: (target, thisArg, args: MethodArgs) => { + const existingExperimentalTelemetry = args[0].experimental_telemetry || {}; + const isEnabled = existingExperimentalTelemetry.isEnabled; + + const client = getClient(); + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + const integrationOptions = integration?.options; + const shouldRecordInputsAndOutputs = integration ? Boolean(client?.getOptions().sendDefaultPii) : false; + + const { recordInputs, recordOutputs } = determineRecordingSettings( + integrationOptions, + existingExperimentalTelemetry, + isEnabled, + shouldRecordInputsAndOutputs, + ); + + args[0].experimental_telemetry = { + ...existingExperimentalTelemetry, + isEnabled: isEnabled !== undefined ? isEnabled : true, + recordInputs, + recordOutputs, + }; + + return handleCallbackErrors( + () => Reflect.apply(target, thisArg, args), + error => { + // This error bubbles up to unhandledrejection handler (if not handled before), + // where we do not know the active span anymore + // So to circumvent this, we set the active span on the error object + // which is picked up by the unhandledrejection handler + if (error && typeof error === 'object') { + addNonEnumerableProperty(error, '_sentry_active_span', getActiveSpan()); + } + }, + () => {}, + result => { + checkResultForToolErrors(result); + }, + ); + }, + }); + }; // Is this an ESM module? // https://tc39.es/ecma262/#sec-module-namespace-objects diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 13ed4973c236..b4c158a2e5dd 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -51,8 +51,8 @@ "@sentry/cloudflare": "10.12.0", "@sentry/core": "10.12.0", "@sentry/node": "10.12.0", - "@sentry/rollup-plugin": "^4.1.1", - "@sentry/vite-plugin": "^4.1.0", + "@sentry/rollup-plugin": "^4.3.0", + "@sentry/vite-plugin": "^4.3.0", "@sentry/vue": "10.12.0" }, "devDependencies": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 36497d3fa367..626160dd12e6 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -50,7 +50,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/browser": "10.12.0", - "@sentry/cli": "^2.52.0", + "@sentry/cli": "^2.53.0", "@sentry/core": "10.12.0", "@sentry/node": "10.12.0", "@sentry/react": "10.12.0", diff --git a/packages/remix/package.json b/packages/remix/package.json index 26788497e330..41efc6387e6b 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -68,7 +68,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", - "@sentry/cli": "^2.52.0", + "@sentry/cli": "^2.53.0", "@sentry/core": "10.12.0", "@sentry/node": "10.12.0", "@sentry/react": "10.12.0", diff --git a/packages/remix/src/server/errors.ts b/packages/remix/src/server/errors.ts index e622e743b74c..0c078c6c6230 100644 --- a/packages/remix/src/server/errors.ts +++ b/packages/remix/src/server/errors.ts @@ -1,11 +1,4 @@ -import type { - ActionFunction, - ActionFunctionArgs, - EntryContext, - HandleDocumentRequestFunction, - LoaderFunction, - LoaderFunctionArgs, -} from '@remix-run/node'; +import type { ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs } from '@remix-run/node'; import { isRouteErrorResponse } from '@remix-run/router'; import type { RequestEventData, Span } from '@sentry/core'; import { @@ -79,37 +72,6 @@ export async function captureRemixServerException(err: unknown, name: string, re }); } -/** - * Wraps the original `HandleDocumentRequestFunction` with error handling. - * - * @param origDocumentRequestFunction The original `HandleDocumentRequestFunction`. - * @param requestContext The request context. - * - * @returns The wrapped `HandleDocumentRequestFunction`. - */ -export function errorHandleDocumentRequestFunction( - this: unknown, - origDocumentRequestFunction: HandleDocumentRequestFunction, - requestContext: { - request: Request; - responseStatusCode: number; - responseHeaders: Headers; - context: EntryContext; - loadContext?: Record; - }, -): HandleDocumentRequestFunction { - const { request, responseStatusCode, responseHeaders, context, loadContext } = requestContext; - - return handleCallbackErrors( - () => { - return origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context, loadContext); - }, - err => { - throw err; - }, - ); -} - /** * Wraps the original `DataFunction` with error handling. * This function also stores the form data keys if the action is being called. diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index 109c3e0f3672..fda9b3f10b75 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -6,7 +6,6 @@ import type { ActionFunctionArgs, AppLoadContext, CreateRequestHandlerFunction, - EntryContext, HandleDocumentRequestFunction, LoaderFunction, LoaderFunctionArgs, @@ -39,7 +38,7 @@ import { import { DEBUG_BUILD } from '../utils/debug-build'; import { createRoutes, getTransactionName } from '../utils/utils'; import { extractData, isResponse, json } from '../utils/vendor/response'; -import { captureRemixServerException, errorHandleDataFunction, errorHandleDocumentRequestFunction } from './errors'; +import { captureRemixServerException, errorHandleDataFunction } from './errors'; type AppData = unknown; type RemixRequest = Parameters[0]; @@ -119,22 +118,7 @@ function getTraceAndBaggage(): { function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) { return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction { - return async function ( - this: unknown, - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - context: EntryContext, - loadContext?: Record, - ): Promise { - const documentRequestContext = { - request, - responseStatusCode, - responseHeaders, - context, - loadContext, - }; - + return async function (this: unknown, request: Request, ...args: unknown[]): Promise { if (instrumentTracing) { const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); @@ -155,11 +139,11 @@ function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) { }, }, () => { - return errorHandleDocumentRequestFunction.call(this, origDocumentRequestFunction, documentRequestContext); + return origDocumentRequestFunction.call(this, request, ...args); }, ); } else { - return errorHandleDocumentRequestFunction.call(this, origDocumentRequestFunction, documentRequestContext); + return origDocumentRequestFunction.call(this, request, ...args); } }; }; diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index 5edb94f721f9..4f40934f37d3 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -1,5 +1,5 @@ import type { RateLimits, ReplayEvent, TransportMakeRequestResponse } from '@sentry/core'; -import { getClient, getCurrentScope, isRateLimited, resolvedSyncPromise, updateRateLimits } from '@sentry/core'; +import { getClient, getCurrentScope, isRateLimited, updateRateLimits } from '@sentry/core'; import { REPLAY_EVENT_NAME, UNABLE_TO_SEND_REPLAY } from '../constants'; import { DEBUG_BUILD } from '../debug-build'; import type { SendReplayData } from '../types'; @@ -34,7 +34,7 @@ export async function sendReplayRequest({ const dsn = client?.getDsn(); if (!client || !transport || !dsn || !session.sampled) { - return resolvedSyncPromise({}); + return Promise.resolve({}); } const baseEvent: ReplayEvent = { @@ -55,7 +55,7 @@ export async function sendReplayRequest({ // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions client.recordDroppedEvent('event_processor', 'replay'); DEBUG_BUILD && debug.log('An event processor returned `null`, will not send event.'); - return resolvedSyncPromise({}); + return Promise.resolve({}); } /* diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 3e432c06c1fd..032e39f1b203 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -95,6 +95,7 @@ export { consoleLoggingIntegration, createConsolaReporter, featureFlagsIntegration, + logger, } from '@sentry/core'; export { VercelEdgeClient } from './client'; @@ -102,5 +103,3 @@ export { getDefaultIntegrations, init } from './sdk'; export { winterCGFetchIntegration } from './integrations/wintercg-fetch'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; - -export * as logger from './logs/exports'; diff --git a/yarn.lock b/yarn.lock index f1bedd816167..46e96703fc7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -328,6 +328,13 @@ resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d" integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww== +"@anthropic-ai/sdk@0.63.0": + version "0.63.0" + resolved "https://registry.yarnpkg.com/@anthropic-ai/sdk/-/sdk-0.63.0.tgz#725ea136ebf2b0fc7ebfdcb655a1d69b60bd3927" + integrity sha512-g2KzDcVXxT2d/SMuVJHeJ6T2loj6jFMt+Nj+I6bfwXWNDMoOP0HhiWr+5RivRV7Yv++jBurDGr76XBCc66R79A== + dependencies: + json-schema-to-ts "^3.1.1" + "@apollo/protobufjs@1.2.6": version "1.2.6" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.6.tgz#d601e65211e06ae1432bf5993a1a0105f2862f27" @@ -2595,10 +2602,10 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.8.4": - version "7.27.6" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" - integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.8.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== "@babel/standalone@^7.23.8": version "7.24.7" @@ -4266,6 +4273,14 @@ resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-2.0.3.tgz#f934b5cdc939e3c7039ff62b9caaf59a9d89e3a8" integrity sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw== +"@google/genai@^1.20.0": + version "1.20.0" + resolved "https://registry.npmjs.org/@google/genai/-/genai-1.20.0.tgz#b728bdb383fc58fbb1b92eff26e831ff598688c0" + integrity sha512-QdShxO9LX35jFogy3iKprQNqgKKveux4H2QjOnyIvyHRuGi6PHiz3fjNf8Y0VPY8o5V2fHqR2XqiSVoz7yZs0w== + dependencies: + google-auth-library "^9.14.2" + ws "^8.18.0" + "@graphql-tools/merge@8.3.1": version "8.3.1" resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.1.tgz#06121942ad28982a14635dbc87b5d488a041d722" @@ -6973,49 +6988,11 @@ fflate "^0.4.4" mitt "^3.0.0" -"@sentry/babel-plugin-component-annotate@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.0.tgz#6e7168f5fa59f53ac4b68e3f79c5fd54adc13f2e" - integrity sha512-UkcnqC7Bp9ODyoBN7BKcRotd1jz/I2vyruE/qjNfRC7UnP+jIRItUWYaXxQPON1fTw+N+egKdByk0M1y2OPv/Q== - -"@sentry/babel-plugin-component-annotate@4.1.1": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.1.tgz#371415afc602f6b2ba0987b51123bd34d1603193" - integrity sha512-HUpqrCK7zDVojTV6KL6BO9ZZiYrEYQqvYQrscyMsq04z+WCupXaH6YEliiNRvreR8DBJgdsG3lBRpebhUGmvfA== - "@sentry/babel-plugin-component-annotate@4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== -"@sentry/bundler-plugin-core@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.1.0.tgz#c1b2f7a890a44e5ac5decc984a133aacf6147dd4" - integrity sha512-/5XBtCF6M+9frEXrrvfSWOdOC2q6I1L7oY7qbUVegNkp3kYVGihNZZnJIXGzo9rmwnA0IV7jI3o0pF/HDRqPeA== - dependencies: - "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "4.1.0" - "@sentry/cli" "^2.51.0" - dotenv "^16.3.1" - find-up "^5.0.0" - glob "^9.3.2" - magic-string "0.30.8" - unplugin "1.0.1" - -"@sentry/bundler-plugin-core@4.1.1": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.1.1.tgz#7e273b83cc8b44f4067f05ab9ed5a7ec7ac6d625" - integrity sha512-Hx9RgXaD1HEYmL5aYoWwCKkVvPp4iklwfD9mvmdpQtcwLg6b6oLnPVDQaOry1ak6Pxt8smlrWcKy4IiKASlvig== - dependencies: - "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "4.1.1" - "@sentry/cli" "^2.51.0" - dotenv "^16.3.1" - find-up "^5.0.0" - glob "^9.3.2" - magic-string "0.30.8" - unplugin "1.0.1" - "@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" @@ -7030,50 +7007,50 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.52.0.tgz#05178cd819c2a33eb22a6e90bf7bb8f853f1b476" - integrity sha512-ieQs/p4yTHT27nBzy0wtAb8BSISfWlpXdgsACcwXimYa36NJRwyCqgOXUaH/BYiTdwWSHpuANbUHGJW6zljzxw== - -"@sentry/cli-linux-arm64@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.52.0.tgz#1979141afc93022614f868374ecc4d3090e84833" - integrity sha512-RxT5uzxjCkcvplmx0bavJIEYerRex2Rg/2RAVBdVvWLKFOcmeerTn/VVxPZVuDIVMVyjlZsteWPYwfUm+Ia3wQ== - -"@sentry/cli-linux-arm@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.52.0.tgz#6957e11af62e50d1040488ec75b3d96ae33fbb5a" - integrity sha512-tWMLU+hj+iip5Akx+S76biAOE1eMMWTDq8c0MqMv/ahHgb6/HiVngMcUsp59Oz3EczJGbTkcnS3vRTDodEcMDw== - -"@sentry/cli-linux-i686@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.52.0.tgz#e369ce3afa4b83a482d34cfd25fae4af792b211a" - integrity sha512-sKcJmIg7QWFtlNU5Bs5OZprwdIzzyYMRpFkWioPZ4TE82yvP1+2SAX31VPUlTx+7NLU6YVEWNwvSxh8LWb7iOw== - -"@sentry/cli-linux-x64@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.52.0.tgz#2b447afac1bb96624823a49c0d9f23c54475bff2" - integrity sha512-aPZ7bP02zGkuEqTiOAm4np/ggfgtzrq4ti1Xze96Csi/DV3820SCfLrPlsvcvnqq7x69IL9cI3kXjdEpgrfGxw== - -"@sentry/cli-win32-arm64@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.52.0.tgz#059063774ab5437ea05d82ce316faa77582b8b51" - integrity sha512-90hrB5XdwJVhRpCmVrEcYoKW8nl5/V9OfVvOGeKUPvUkApLzvsInK74FYBZEVyAn1i/NdUv+Xk9q2zqUGK1aLQ== - -"@sentry/cli-win32-i686@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.52.0.tgz#bee3cded721fcf45db2e77bf84ea8653e4d803d9" - integrity sha512-HXlSE4CaLylNrELx4KVmOQjV5bURCNuky6sjCWiTH7HyDqHEak2Rk8iLE0JNLj5RETWMvmaZnZZFfmyGlY1opg== - -"@sentry/cli-win32-x64@2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.52.0.tgz#16e501e5f00834b1f64765774c59740580043dfc" - integrity sha512-hJT0C3FwHk1Mt9oFqcci88wbO1D+yAWUL8J29HEGM5ZAqlhdh7sAtPDIC3P2LceUJOjnXihow47Bkj62juatIQ== - -"@sentry/cli@^2.51.0", "@sentry/cli@^2.52.0": - version "2.52.0" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.52.0.tgz#5162900bbfae57ddfc414bbe5780837622125aed" - integrity sha512-PXyo7Yv7+rVMSBGZfI/eFEzzhiKedTs25sDCjz4a3goAZ/F5R5tn3MKq30pnze5wNnoQmLujAa0uUjfNcWP+uQ== +"@sentry/cli-darwin@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.53.0.tgz#0584f5a4a376c9373f91ad5e1d9194278be2aed6" + integrity sha512-NNPfpILMwKgpHiyJubHHuauMKltkrgLQ5tvMdxNpxY60jBNdo5VJtpESp4XmXlnidzV4j1z61V4ozU6ttDgt5Q== + +"@sentry/cli-linux-arm64@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.53.0.tgz#04a73b2592edf10d6e06957905becc98692605b1" + integrity sha512-xY/CZ1dVazsSCvTXzKpAgXaRqfljVfdrFaYZRUaRPf1ZJRGa3dcrivoOhSIeG/p5NdYtMvslMPY9Gm2MT0M83A== + +"@sentry/cli-linux-arm@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.53.0.tgz#caa1dceb23ee40e9d0c82a7c6156c3f010eebc0e" + integrity sha512-NdRzQ15Ht83qG0/Lyu11ciy/Hu/oXbbtJUgwzACc7bWvHQA8xEwTsehWexqn1529Kfc5EjuZ0Wmj3MHmp+jOWw== + +"@sentry/cli-linux-i686@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.53.0.tgz#989dc766b098e94c6751bad3efcd4ca0fe1a2565" + integrity sha512-0REmBibGAB4jtqt9S6JEsFF4QybzcXHPcHtJjgMi5T0ueh952uG9wLzjSxQErCsxTKF+fL8oG0Oz5yKBuCwCCQ== + +"@sentry/cli-linux-x64@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.53.0.tgz#2a94361233ed24e4a32f08919011a591aea4cb6b" + integrity sha512-9UGJL+Vy5N/YL1EWPZ/dyXLkShlNaDNrzxx4G7mTS9ywjg+BIuemo6rnN7w43K1NOjObTVO6zY0FwumJ1pCyLg== + +"@sentry/cli-win32-arm64@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.53.0.tgz#946609eabd318657521c4b3ef15a420cc00f1c60" + integrity sha512-G1kjOjrjMBY20rQcJV2GA8KQE74ufmROCDb2GXYRfjvb1fKAsm4Oh8N5+Tqi7xEHdjQoLPkE4CNW0aH68JSUDQ== + +"@sentry/cli-win32-i686@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.53.0.tgz#f51937d73cefad16b9d2e89acc4c9f178da36cc6" + integrity sha512-qbGTZUzesuUaPtY9rPXdNfwLqOZKXrJRC1zUFn52hdo6B+Dmv0m/AHwRVFHZP53Tg1NCa8bDei2K/uzRN0dUZw== + +"@sentry/cli-win32-x64@2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.53.0.tgz#d89cde8354b4eb8e89f2c11dc6a6fb5e7392e2ae" + integrity sha512-1TXYxYHtwgUq5KAJt3erRzzUtPqg7BlH9T7MdSPHjJatkrr/kwZqnVe2H6Arr/5NH891vOlIeSPHBdgJUAD69g== + +"@sentry/cli@^2.51.0", "@sentry/cli@^2.53.0": + version "2.53.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.53.0.tgz#fd5b65b9f6f06f0ed16345acf3ecf0720bd7bcf8" + integrity sha512-n2ZNb+5Z6AZKQSI0SusQ7ZzFL637mfw3Xh4C3PEyVSn9LiF683fX0TTq8OeGmNZQS4maYfS95IFD+XpydU0dEA== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -7081,29 +7058,29 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.52.0" - "@sentry/cli-linux-arm" "2.52.0" - "@sentry/cli-linux-arm64" "2.52.0" - "@sentry/cli-linux-i686" "2.52.0" - "@sentry/cli-linux-x64" "2.52.0" - "@sentry/cli-win32-arm64" "2.52.0" - "@sentry/cli-win32-i686" "2.52.0" - "@sentry/cli-win32-x64" "2.52.0" - -"@sentry/rollup-plugin@^4.1.1": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.1.1.tgz#ece90c337d1f78a2a445d3986b63321877fd4e41" - integrity sha512-AAZ9OzR2gsJRxgKN2k5jB+MxT13Uj2GJeSofi0EHbgu/yUdod8zTGX+4NRB90aXZIEOAc0Xrwnw1sm8nZYvaFw== + "@sentry/cli-darwin" "2.53.0" + "@sentry/cli-linux-arm" "2.53.0" + "@sentry/cli-linux-arm64" "2.53.0" + "@sentry/cli-linux-i686" "2.53.0" + "@sentry/cli-linux-x64" "2.53.0" + "@sentry/cli-win32-arm64" "2.53.0" + "@sentry/cli-win32-i686" "2.53.0" + "@sentry/cli-win32-x64" "2.53.0" + +"@sentry/rollup-plugin@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.3.0.tgz#d23fe49e48fa68dafa2b0933a8efabcc964b1df9" + integrity sha512-Ebk6cTGTNohnLEvHtwDKYlMRs8Qit/ybOflIKlQziBHjd51GtxG9TPIu9NYU0fJXa428aYNluto3BfgdMp+c+Q== dependencies: - "@sentry/bundler-plugin-core" "4.1.1" + "@sentry/bundler-plugin-core" "4.3.0" unplugin "1.0.1" -"@sentry/vite-plugin@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.1.0.tgz#a94eaf2a294b9f16dec99b088cb05d37b364dcf5" - integrity sha512-uLZxOAW79sOQH77yWiQct8f3i+LUi36wn2fK62cejZfrGaHu5P+9R4f0Es1L70I3MrsPXOvJ0A6r5PkVS9562g== +"@sentry/vite-plugin@^4.1.0", "@sentry/vite-plugin@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.3.0.tgz#ced993a1f59046404aa26fb57b12078d13680ffa" + integrity sha512-MeTAHMmTOgBPMAjeW7/ONyXwgScZdaFFtNiALKcAODnVqC7eoHdSRIWeH5mkLr2Dvs7nqtBaDpKxRjUBgfm9LQ== dependencies: - "@sentry/bundler-plugin-core" "4.1.0" + "@sentry/bundler-plugin-core" "4.3.0" unplugin "1.0.1" "@sentry/webpack-plugin@^4.1.1", "@sentry/webpack-plugin@^4.3.0": @@ -17497,6 +17474,17 @@ gaxios@^4.0.0: is-stream "^2.0.0" node-fetch "^2.3.0" +gaxios@^6.0.0, gaxios@^6.1.1: + version "6.7.1" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz#ebd9f7093ede3ba502685e73390248bb5b7f71fb" + integrity sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ== + dependencies: + extend "^3.0.2" + https-proxy-agent "^7.0.1" + is-stream "^2.0.0" + node-fetch "^2.6.9" + uuid "^9.0.1" + gcp-metadata@^4.2.0: version "4.2.1" resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-4.2.1.tgz#31849fbcf9025ef34c2297c32a89a1e7e9f2cd62" @@ -17505,6 +17493,15 @@ gcp-metadata@^4.2.0: gaxios "^4.0.0" json-bigint "^1.0.0" +gcp-metadata@^6.1.0: + version "6.1.1" + resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz#f65aa69f546bc56e116061d137d3f5f90bdec494" + integrity sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A== + dependencies: + gaxios "^6.1.1" + google-logging-utils "^0.0.2" + json-bigint "^1.0.0" + generate-function@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" @@ -18016,6 +18013,23 @@ google-auth-library@^7.0.2: jws "^4.0.0" lru-cache "^6.0.0" +google-auth-library@^9.14.2: + version "9.15.1" + resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz#0c5d84ed1890b2375f1cd74f03ac7b806b392928" + integrity sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng== + dependencies: + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + gaxios "^6.1.1" + gcp-metadata "^6.1.0" + gtoken "^7.0.0" + jws "^4.0.0" + +google-logging-utils@^0.0.2: + version "0.0.2" + resolved "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz#5fd837e06fa334da450433b9e3e1870c1594466a" + integrity sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ== + google-p12-pem@^3.0.3: version "3.1.4" resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.1.4.tgz#123f7b40da204de4ed1fbf2fd5be12c047fc8b3b" @@ -18091,6 +18105,14 @@ gtoken@^5.0.4: google-p12-pem "^3.0.3" jws "^4.0.0" +gtoken@^7.0.0: + version "7.1.0" + resolved "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz#d61b4ebd10132222817f7222b1e6064bd463fc26" + integrity sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw== + dependencies: + gaxios "^6.0.0" + jws "^4.0.0" + gud@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" @@ -18823,7 +18845,7 @@ https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: agent-base "6" debug "4" -https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.5: +https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.5: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== @@ -20142,6 +20164,14 @@ json-parse-even-better-errors@^3.0.0: resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz#2cb2ee33069a78870a0c7e3da560026b89669cf7" integrity sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA== +json-schema-to-ts@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz#81f3acaf5a34736492f6f5f51870ef9ece1ca853" + integrity sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g== + dependencies: + "@babel/runtime" "^7.18.3" + ts-algebra "^2.0.0" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -23089,7 +23119,7 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" -node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.6.9: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -28521,7 +28551,7 @@ string-template@~0.2.1: string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: emoji-regex "^8.0.0" @@ -28631,7 +28661,7 @@ stringify-object@^3.2.1: strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" @@ -29575,6 +29605,11 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876" integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g== +ts-algebra@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ts-algebra/-/ts-algebra-2.0.0.tgz#4e3e0953878f26518fce7f6bb115064a65388b7a" + integrity sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw== + ts-api-utils@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331" @@ -31695,7 +31730,7 @@ wrangler@4.22.0: wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0"