From c67c870ccfc806ad93b06dec70b55b729dd8edc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:19:54 +0200 Subject: [PATCH 01/28] feat(deps): bump @sentry/cli from 2.52.0 to 2.53.0 (#17652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@sentry/cli](https://github.com/getsentry/sentry-cli) from 2.52.0 to 2.53.0.
Release notes

Sourced from @​sentry/cli's releases.

2.53.0

Various fixes & improvements

Changes from 2.53.0-alpha

2.53.0-alpha reintroduced the build (previously named mobile-app) commands. 2.53.0 is the first stable release to reintroduce them.

Please note, the build commands are still experimental, and are therefore subject to breaking changes, including removal, in any release, without notice.

2.53.0-alpha

This release reintroduces the build (previously named mobile-app) commands.

Various fixes & improvements

Changelog

Sourced from @​sentry/cli's changelog.

2.53.0

Various fixes & improvements

Changes from 2.53.0-alpha

2.53.0-alpha reintroduced the build (previously named mobile-app) commands. 2.53.0 is the first stable release to reintroduce them.

Please note, the build commands are still experimental, and are therefore subject to breaking changes, including removal, in any release, without notice.

2.53.0-alpha

This release reintroduces the build (previously named mobile-app) commands.

Various fixes & improvements

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sentry/cli&package-manager=npm_and_yarn&previous-version=2.52.0&new-version=2.53.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/react-router/package.json | 2 +- packages/remix/package.json | 2 +- yarn.lock | 105 +++++++++++++++-------------- 3 files changed, 55 insertions(+), 54 deletions(-) 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/yarn.lock b/yarn.lock index f1bedd816167..2fcc0a51cfcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7030,50 +7030,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,14 +7081,14 @@ 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/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.1.1": version "4.1.1" @@ -28808,6 +28808,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 660d533d02c17ed1dd0415cc82db4d46b206324d Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:33:21 +0200 Subject: [PATCH 02/28] ci(test-matrix): Add logs for `getTestMatrix` (#17673) Adds some test for generating the test matrix to be able to easily debug it. --- dev-packages/e2e-tests/lib/getTestMatrix.ts | 42 ++++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.ts index 1261e7d5b3ac..8d02e79310af 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,11 +145,22 @@ 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; + } + + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.error(`Nx affected projects (${affectedProjects.length}): ${JSON.stringify(affectedProjects)}`); // If something in e2e tests themselves are changed, check if only test applications were changed if (affectedProjects.includes('@sentry-internal/e2e-tests')) { @@ -150,12 +169,19 @@ function getAffectedTestApplications( // Shared code was changed, run all tests if (changedTestApps === false) { + // eslint-disable-next-line no-console + console.error('Shared e2e code changed. Running all test applications.'); return testApplications; } // Only test applications that were changed, run selectively if (changedTestApps.size > 0) { - return testApplications.filter(testApp => changedTestApps.has(testApp)); + const selected = testApplications.filter(testApp => changedTestApps.has(testApp)); + // eslint-disable-next-line no-console + console.error( + `Only changed test applications will run (${selected.length}): ${JSON.stringify(Array.from(changedTestApps))}`, + ); + return selected; } } catch (error) { // eslint-disable-next-line no-console @@ -182,6 +208,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/'; From c0f0375d4c9a79b2afdfa4a008b8d56e9a0c78d4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 17 Sep 2025 14:21:04 +0200 Subject: [PATCH 03/28] fix(node): Fix `this` context for vercel AI instrumentation (#17681) Noticed while working on https://github.com/getsentry/sentry-javascript/pull/17679, we incorrectly changed the `this` context here (which is also why TS complained). I rewrote this to a proxy which should keep the context properly. --- .../tracing/anthropic-ai/instrumentation.ts | 8 +- .../tracing/openai/instrumentation.ts | 8 +- .../tracing/vercelai/instrumentation.ts | 103 +++++++++--------- 3 files changed, 60 insertions(+), 59 deletions(-) diff --git a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts index 99fd2c546dd2..a10a01b3debf 100644 --- a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -5,7 +5,7 @@ import { 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 { ANTHROPIC_AI_INTEGRATION_NAME, getClient, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=0.19.2 <1.0.0']; @@ -61,10 +61,10 @@ export class SentryAnthropicAiInstrumentation extends InstrumentationBase(ANTHROPIC_AI_INTEGRATION_NAME); + const client = getClient(); + const integration = client?.getIntegrationByName(ANTHROPIC_AI_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/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..cf2b0d7eeadc 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -7,7 +7,7 @@ import { addNonEnumerableProperty, captureException, getActiveSpan, - getCurrentScope, + getClient, handleCallbackErrors, isThenable, SDK_VERSION, @@ -212,57 +212,58 @@ 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, () => {}); + 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( + () => { + const result = Reflect.apply(target, thisArg, 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; - } - - // 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()); - } - }, - ); - }; - } + }, + 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()); + } + }, + ); + }, + }); + }; // Is this an ESM module? // https://tc39.es/ecma262/#sec-module-namespace-objects From 3410a08065386295089fb8d658db2d0114166aee Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 17 Sep 2025 14:37:31 +0200 Subject: [PATCH 04/28] ref(remix): Avoid unnecessary error wrapping `HandleDocumentRequestFunction` (#17680) Noticed while working on https://github.com/getsentry/sentry-javascript/pull/17679, this was actually incorrect here. `handleCallbackErrors` does not actually do anything, you need to still call the function you want to call in the error case! Actually we can just drop this as this error is handled by some other handler anyhow, I think, according to the tests! --- packages/remix/src/server/errors.ts | 40 +------------------ packages/remix/src/server/instrumentServer.ts | 24 ++--------- 2 files changed, 5 insertions(+), 59 deletions(-) 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); } }; }; From 14246fa26a9b56c67fd2dc904b4c5207376b747f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 17 Sep 2025 14:52:09 +0200 Subject: [PATCH 05/28] chore(nuxt): Bump Vite and Rollup plugins (#17671) https://github.com/getsentry/sentry-javascript/pull/17653 failed in CI because of a non-breaking plugin option change in 4.3.0. Because we use both Vite and Rollup plugins in Nuxt, the combined `getPluginOptions` function would throw a type error if the respective option types differed. This PR bumps both dependencies at once which should fix the type error. --- packages/nuxt/package.json | 4 +-- yarn.lock | 58 +++++++------------------------------- 2 files changed, 12 insertions(+), 50 deletions(-) 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/yarn.lock b/yarn.lock index 2fcc0a51cfcb..ebdb2b198675 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6973,49 +6973,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" @@ -7090,20 +7052,20 @@ "@sentry/cli-win32-i686" "2.53.0" "@sentry/cli-win32-x64" "2.53.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/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": From a48cf89eefe1d999b5a0295b082457deacd394b4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 17 Sep 2025 15:24:47 +0200 Subject: [PATCH 06/28] feat(core): Allow to pass `onSuccess` to `handleCallbackErrors` (#17679) I've seen a few places where we are wrapping things that could be sync or async, and we want to do something with the return value. By adding an onSuccess handler to `handelCallbackErrors` we can handle this more generically in the future: ```js const wrapped = handleCallbackErrors( () => fn(), // error handler (_err) => {}, // finally handler () => {}, // success handler, gets value or resolved value if function returns a promise (_value) => {} ); ``` While scanning a bit for places where we could already use this, I also found two bugs around this, opened separate PRs for them: * https://github.com/getsentry/sentry-javascript/pull/17680 * https://github.com/getsentry/sentry-javascript/pull/17681 --- packages/core/src/utils/anthropic-ai/index.ts | 41 ++++----- .../core/src/utils/handleCallbackErrors.ts | 38 +++++++- .../lib/utils/handleCallbackErrors.test.ts | 90 +++++++++++++++++++ .../tracing/vercelai/instrumentation.ts | 19 ++-- 4 files changed, 152 insertions(+), 36 deletions(-) diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index a771dff4c75d..cf99b12c1062 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -24,6 +24,7 @@ import { GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; +import { handleCallbackErrors } from '../handleCallbackErrors'; import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -238,7 +239,7 @@ function instrumentMethod( op: getSpanOperation(methodPath), attributes: requestAttributes as Record, }, - async (span: Span) => { + async span => { try { if (finalOptions.recordInputs && params) { addPrivateRequestAttributes(span, params); @@ -274,27 +275,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 (finalOptions.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, finalOptions.recordOutputs), + ); }, ); }; 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/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/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index cf2b0d7eeadc..872e0153edba 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -9,7 +9,6 @@ import { getActiveSpan, getClient, handleCallbackErrors, - isThenable, SDK_VERSION, withScope, } from '@sentry/core'; @@ -238,19 +237,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { }; return handleCallbackErrors( - () => { - const result = Reflect.apply(target, thisArg, 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; - }, + () => 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 @@ -260,6 +247,10 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { addNonEnumerableProperty(error, '_sentry_active_span', getActiveSpan()); } }, + () => {}, + result => { + checkResultForToolErrors(result); + }, ); }, }); From c0229722b4d95324be143da6f3b1b431ca20ab92 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 17 Sep 2025 15:27:27 +0200 Subject: [PATCH 07/28] ref: Avoid some usage of `SyncPromise` where not needed (#17641) We make quite heavy use of it in event processing, not touching this for now, but in other places it should not be needed IMHO. These are all places that will never be sync generally speaking so it is fine to make them "proper" async. Somewhat related to https://github.com/getsentry/sentry-javascript/pull/17634 --------- Co-authored-by: Lukas Stracke --- .../test/profiling/integration.test.ts | 7 +- packages/bun/src/transports/index.ts | 4 +- packages/core/src/client.ts | 74 ++++++++++--------- packages/core/src/transports/base.ts | 5 +- packages/core/test/lib/client.test.ts | 18 +---- packages/deno/src/integrations/deno-cron.ts | 2 - packages/deno/src/transports/index.ts | 4 +- packages/node-core/src/sdk/client.ts | 23 +++--- packages/node-core/test/sdk/client.test.ts | 4 +- .../src/util/sendReplayRequest.ts | 6 +- 10 files changed, 65 insertions(+), 82 deletions(-) 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/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/core/src/client.ts b/packages/core/src/client.ts index 75a0d5ed49d2..924bd1810ea3 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."; @@ -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; } /** @@ -872,18 +875,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 +944,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. */ 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/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index b7767a7e6c58..b903b689fb70 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -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); 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/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/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/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({}); } /* From 2cde2a419fb1b570f9be6df3efe95551548b819b Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 17 Sep 2025 15:59:09 +0200 Subject: [PATCH 08/28] feat(node): Do not drop 300 and 304 status codes by default (#17686) Looking at https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#redirection_messages, it seems that we should not filter out 300, 302 and 304 status codes by default: > [300 Multiple Choices](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/300) In [agent-driven content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Content_negotiation#agent-driven_negotiation), the request has more than one possible response and the user agent or user should choose one of them. There is no standardized way for clients to automatically choose one of the responses, so this is rarely used. > [304 Not Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304) This is used for caching purposes. It tells the client that the response has not been modified, so the client can continue to use the same [cached](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching) version of the response. In contrast, the others seem safe to continue to filter: > [301 Moved Permanently](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/301) The URL of the requested resource has been changed permanently. The new URL is given in the response. > [302 Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302) This response code means that the URI of requested resource has been changed temporarily. Further changes in the URI might be made in the future, so the same URI should be used by the client in future requests. > [303 See Other](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/303) The server sent this response to direct the client to get the requested resource at another URI with a [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/GET) request. > [305 Use Proxy Deprecated](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#305_use_proxy) Defined in a previous version of the HTTP specification to indicate that a requested response must be accessed by a proxy. It has been deprecated due to security concerns regarding in-band configuration of a proxy. > [306 unused](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#306_unused) This response code is no longer used; but is reserved. It was used in a previous version of the HTTP/1.1 specification. > [307 Temporary Redirect](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/307) The server sends this response to direct the client to get the requested resource at another URI with the same method that was used in the prior request. This has the same semantics as the 302 Found response code, with the exception that the user agent must not change the HTTP method used: if a [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/POST) was used in the first request, a POST must be used in the redirected request. > [308 Permanent Redirect](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/308) This means that the resource is now permanently located at another URI, specified by the [Location](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Location) response header. This has the same semantics as the 301 Moved Permanently HTTP response code, with the exception that the user agent must not change the HTTP method used: if a [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/POST) was used in the first request, a POST must be used in the second request. eventually it makes sense to unify this between node/node-core, but this should happen when we merge the httpIntegration split PR. --- .../node-core/src/integrations/http/index.ts | 43 ++++++++++++------- packages/node/src/integrations/http.ts | 43 ++++++++++++------- 2 files changed, 54 insertions(+), 32 deletions(-) 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/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; + }); +} From 5a1faedfc7e0faecbb8035060d40477b2fc1efc6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 18 Sep 2025 12:41:22 +0200 Subject: [PATCH 09/28] ref(core): Add debug log when dropping a span via `ignoreSpans` (#17692) - Emit a debug log when a span matches `ignoreSpans` - Adjust the warning when returning `null` in `beforeSendSpan` to suggest `ignoreSpans` instead closes https://github.com/getsentry/sentry-javascript/issues/17687 --- packages/core/src/utils/should-ignore-span.ts | 8 ++++++++ packages/core/src/utils/spanUtils.ts | 2 +- packages/core/test/lib/client.test.ts | 2 +- packages/core/test/lib/tracing/sentrySpan.test.ts | 2 +- .../core/test/lib/utils/should-ignore-span.test.ts | 13 ++++++++++++- 5 files changed, 23 insertions(+), 4 deletions(-) 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 b903b689fb70..afca376393ee 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(); }); 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/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', () => { From d03964021465388626560980032c56d6d475c897 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 18 Sep 2025 15:58:59 +0200 Subject: [PATCH 10/28] fix(core): Ensure builtin stack frames don't affect `thirdPartyErrorFilterIntegration` (#17693) As reported in https://github.com/getsentry/sentry-javascript/issues/17674 our previous filter to exclude builtin or native stack frames from `thirdPartyErrorFilterIntegration` filtering logic would incorrectly still let some native frames slip through. This PR expands the check and lets the integration only consider frames with a filename (as previously) AND with at least a `lineno` or `colno`. If neither of these properties are present, we exclude them from being a factor in determining 1st vs. 3rd party. Also added integration tests for `thirdPartyErrorsFilterIntegration`. closes https://github.com/getsentry/sentry-javascript/issues/17674 --- .../thirdPartyErrorsFilter/init.js | 34 ++++++++ .../thirdPartyErrorsFilter/subject.js | 28 +++++++ .../thirdPartyErrorsFilter/template.html | 10 +++ .../thirdPartyErrorsFilter/test.ts | 82 +++++++++++++++++++ .../thirdPartyScript.js | 3 + .../integrations/third-party-errors-filter.ts | 5 +- .../third-party-errors-filter.test.ts | 65 +++++++++++---- 7 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/thirdPartyErrorsFilter/thirdPartyScript.js 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/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index 1a0628359f5b..7d742d5c76ea 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -108,8 +108,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/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'], From 3c76c5db53c8de76f055ae7437c9e1c93b2f6e15 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Sep 2025 09:21:19 +0200 Subject: [PATCH 11/28] fix(browser): Ensure idle span duration is adjusted when child spans are ignored (#17700) This PR fixes a problem reported in https://github.com/getsentry/sentry-javascript/issues/17451 where ignoring spans in idle root spans (pageload and navigations most prominently) caused the root span duration to be perceived much longer than reasonable. This is only a problem for idle spans in browser, so this PR applies a pragmatic fix to adjust the end time stamp: We already adjust the time stamp when we end the idle span. So we might as well at this point take any to-be-removed-because-of-`ignoreSpans` spans out of this calculation. This should work well enough without throwing a bunch of idle-span specific logic into the client or completely changing the point in the event lifecycle where `ignoreSpans` is applied. closes https://github.com/getsentry/sentry-javascript/issues/17451 --- .../suites/tracing/ignoreSpans/init.js | 49 +++++++++++++++++++ .../suites/tracing/ignoreSpans/test.ts | 29 +++++++++++ packages/core/src/client.ts | 1 + packages/core/src/tracing/idleSpan.ts | 20 ++++++-- 4 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans/test.ts 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/packages/core/src/client.ts b/packages/core/src/client.ts index 924bd1810ea3..6a7451fe96cb 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1347,6 +1347,7 @@ function processBeforeSend( if (droppedSpans) { client.recordDroppedEvent('before_send', 'span', droppedSpans); } + processedEvent.spans = processedSpans; } } diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 11045e0da1af..c8b37adec436 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, @@ -156,10 +157,21 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti 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; From 97ff2f7f050c5d2337b4dc05fbfd479d287625d0 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Fri, 19 Sep 2025 11:21:48 +0200 Subject: [PATCH 12/28] feat(core,node): Add instrumentation for `GoogleGenerativeAI` (#17625) This PR implements instrumentation for the Google GenerativeAI SDK, adding automatic tracing for both direct model calls (models.generateContent) and conversational chat sessions (chats.create + sendMessage). The implementation follows Sentry's AI Agents Manual Instrumentation conventions and includes integration tests. Usage: ``` import * as Sentry from '@sentry/node'; Sentry.init({ integrations: [ Sentry.googleGenAIIntegration({ recordInputs: true, // Record prompts/messages recordOutputs: true, // Record AI responses }), ], }); // Chat Google GenAI calls are now automatically instrumented ``` --- .../node-integration-tests/package.json | 1 + .../google-genai/instrument-with-options.mjs | 23 ++ .../google-genai/instrument-with-pii.mjs | 17 + .../tracing/google-genai/instrument.mjs | 17 + .../suites/tracing/google-genai/scenario.mjs | 109 ++++++ .../suites/tracing/google-genai/test.ts | 205 ++++++++++++ packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/core/src/index.ts | 9 + packages/core/src/utils/ai/utils.ts | 3 + packages/core/src/utils/exports.ts | 47 +++ .../core/src/utils/google-genai/constants.ts | 10 + packages/core/src/utils/google-genai/index.ts | 315 ++++++++++++++++++ packages/core/src/utils/google-genai/types.ts | 185 ++++++++++ packages/core/src/utils/google-genai/utils.ts | 16 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + .../tracing/google-genai/index.ts | 73 ++++ .../tracing/google-genai/instrumentation.ts | 102 ++++++ .../node/src/integrations/tracing/index.ts | 3 + yarn.lock | 64 +++- 22 files changed, 1198 insertions(+), 6 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts create mode 100644 packages/core/src/utils/exports.ts create mode 100644 packages/core/src/utils/google-genai/constants.ts create mode 100644 packages/core/src/utils/google-genai/index.ts create mode 100644 packages/core/src/utils/google-genai/types.ts create mode 100644 packages/core/src/utils/google-genai/utils.ts create mode 100644 packages/node/src/integrations/tracing/google-genai/index.ts create mode 100644 packages/node/src/integrations/tracing/google-genai/instrumentation.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 3deeb1ae0df4..15dd3b68d3a8 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@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/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/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/core/src/index.ts b/packages/core/src/index.ts index b971aa8b43a3..b4c37b312e80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -131,6 +131,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 +140,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 +215,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/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/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/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/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/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/yarn.lock b/yarn.lock index ebdb2b198675..56ee0f3c9f37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4266,6 +4266,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" @@ -17459,6 +17467,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" @@ -17467,6 +17486,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" @@ -17978,6 +18006,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" @@ -18053,6 +18098,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" @@ -18785,7 +18838,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== @@ -23051,7 +23104,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== @@ -28483,7 +28536,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" @@ -28593,7 +28646,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" @@ -28770,7 +28823,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -31658,7 +31710,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" From 804f7a7d40cae8379746fe96e385a15605c5d85c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Sep 2025 12:03:55 +0200 Subject: [PATCH 13/28] ref(core): Streamline `module_metadata` assignment and cleanup functions (#17696) Quick PR to streamline the stack frame `module_metadata` property assignment (`addMetadataToStackFrames`) and cleanup (`stripMetadataFromStackFrames`) logic, since we can use optional chaining now without a bundle size hit from polyfilling. Originally went with moving the cleanup logic into the client but decided against it due to the bundle size hit (see comment). --- packages/core/src/index.ts | 2 +- .../{metadata.ts => moduleMetadata.ts} | 0 .../integrations/third-party-errors-filter.ts | 1 - packages/core/src/metadata.ts | 40 +++++-------------- 4 files changed, 12 insertions(+), 31 deletions(-) rename packages/core/src/integrations/{metadata.ts => moduleMetadata.ts} (100%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4c37b312e80..56ae0bdc4335 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'; 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 7d742d5c76ea..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') { 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. - } + }); } From 062d684cac1f1caa1062c224b23e7213879d027c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 19 Sep 2025 12:09:18 +0200 Subject: [PATCH 14/28] ref(core): Avoid looking up anthropic-ai integration options (#17694) This avoids looking up the integration for anthropic-ai instrumentation, instead relying on this being passed in (which it already is). When manually instrumenting the client you need to pass in the options directly. 1. Node: Options are passed from the integration to `instrumentAnthropicAiClient` anyhow, so nothing changes 2. cloudflare/vercel-edge: There is no integration, users need to manually call `instrumentAnthropicAiClient()` and pass in the options anyhow (no integration to look anything up from exists there) This required updating the tests to actually use auto instrumentation properly instead of manual client wrapping, which is overall a good change anyhow IMHO. --- .../node-integration-tests/package.json | 1 + .../anthropic/instrument-with-options.mjs | 9 +- .../tracing/anthropic/instrument-with-pii.mjs | 9 +- .../suites/tracing/anthropic/instrument.mjs | 10 +- .../anthropic/scenario-manual-client.mjs | 115 ++++++++++++++++++ .../suites/tracing/anthropic/scenario.mjs | 90 ++++++-------- .../suites/tracing/anthropic/test.ts | 10 ++ packages/core/src/utils/anthropic-ai/index.ts | 43 +++---- .../tracing/anthropic-ai/index.ts | 6 +- .../tracing/anthropic-ai/instrumentation.ts | 31 ++--- yarn.lock | 28 ++++- 11 files changed, 235 insertions(+), 117 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-manual-client.mjs diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 15dd3b68d3a8..5695877ad984 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -23,6 +23,7 @@ "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", 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/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index cf99b12c1062..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'; @@ -25,11 +25,9 @@ import { } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; -import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, - AnthropicAiIntegration, AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, @@ -196,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 @@ -220,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); @@ -241,7 +223,7 @@ function instrumentMethod( }, async span => { try { - if (finalOptions.recordInputs && params) { + if (options.recordInputs && params) { addPrivateRequestAttributes(span, params); } @@ -249,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' }); @@ -276,7 +258,7 @@ function instrumentMethod( attributes: requestAttributes as Record, }, span => { - if (finalOptions.recordInputs && params) { + if (options.recordInputs && params) { addPrivateRequestAttributes(span, params); } @@ -294,7 +276,7 @@ function instrumentMethod( }); }, () => {}, - result => addResponseAttributes(span, result as AnthropicAiResponse, finalOptions.recordOutputs), + result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), ); }, ); @@ -304,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]; @@ -337,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/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 a10a01b3debf..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, getClient, 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(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/yarn.lock b/yarn.lock index 56ee0f3c9f37..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" @@ -20157,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" @@ -29590,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" From 670c624e00192da4aac1f7f612c1a9b6dc5b5ed2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Sep 2025 12:41:10 +0200 Subject: [PATCH 15/28] fix(core): Fix client hook edge cases around multiple callbacks (#17706) This PR fixes two edge cases around our client hook subscriber management: 1. Registering the same callback instance twice on the same hook, resulted in the callback only being invoked once. Fixed by wrapping the passed callback in a function to "unique-ify" it. 2. Unregistering one callback synchronously within the callback, caused other callbacks to not be invoked due to in-place, sync array mutation. Fixed by converting the hooks data structure from `Array` to `Set` which is resilient to sync, in-place mutation. This also lets us remove the workaround introduced in https://github.com/getsentry/sentry-javascript/pull/17272 where we initially discovered this bug. Added regression tests for both cases that failed beforehand. closes https://github.com/getsentry/sentry-javascript/issues/17276 --- packages/browser-utils/src/metrics/utils.ts | 14 +-- packages/core/src/client.ts | 20 ++-- packages/core/test/lib/client.test.ts | 114 +++++++++++++++++++- 3 files changed, 123 insertions(+), 25 deletions(-) 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/core/src/client.ts b/packages/core/src/client.ts index 6a7451fe96cb..936e8ac3ebe0 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -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. @@ -685,21 +685,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); }; } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index afca376393ee..c7cbe7ab4a97 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2397,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); @@ -2420,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(); @@ -2435,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(); @@ -2450,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', () => { From 65fd3b60350046475ee2a15a89c1043184dfbd25 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 19 Sep 2025 12:44:13 +0200 Subject: [PATCH 16/28] ci: Fix lookup of changed E2E test apps (#17707) The problem was that when anything inside of the E2E test apps was changed, we _only_ looked at this, not at the affected projects anymore. With this fix, we collect a set from both sources, ensuring anything changed/affected is run. --- dev-packages/e2e-tests/lib/getTestMatrix.ts | 40 +++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.ts index 8d02e79310af..86a4bda3e701 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.ts @@ -162,41 +162,43 @@ function getAffectedTestApplications( // eslint-disable-next-line no-console console.error(`Nx affected projects (${affectedProjects.length}): ${JSON.stringify(affectedProjects)}`); - // If something in e2e tests themselves are changed, check if only test applications were changed + // 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) { + // Shared code was changed, run all tests // eslint-disable-next-line no-console console.error('Shared e2e code changed. Running all test applications.'); - return testApplications; - } - - // Only test applications that were changed, run selectively - if (changedTestApps.size > 0) { - const selected = testApplications.filter(testApp => changedTestApps.has(testApp)); + 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 (${selected.length}): ${JSON.stringify(Array.from(changedTestApps))}`, + `Only changed test applications will run (${changedTestApps.size}): ${JSON.stringify(Array.from(changedTestApps))}`, ); - return selected; + 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 { From 9b22c8ab5e88ab487db21389b3ae8eec98a66b98 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 19 Sep 2025 14:02:12 +0200 Subject: [PATCH 17/28] feat(nextjs): Use `afterProductionCompile` hook for webpack builds (#17655) --- .../src/config/getBuildPluginOptions.ts | 301 ++++++++++--- .../config/handleRunAfterProductionCompile.ts | 28 +- packages/nextjs/src/config/webpack.ts | 39 +- .../nextjs/src/config/webpackPluginOptions.ts | 126 ------ .../nextjs/src/config/withSentryConfig.ts | 24 +- .../test/config/getBuildPluginOptions.test.ts | 404 +++++++++++++++++- .../handleRunAfterProductionCompile.test.ts | 17 +- packages/nextjs/test/config/testUtils.ts | 19 +- .../webpack/constructWebpackConfig.test.ts | 97 ++++- .../webpack/webpackPluginOptions.test.ts | 234 ---------- .../test/config/withSentryConfig.test.ts | 7 +- 11 files changed, 811 insertions(+), 485 deletions(-) delete mode 100644 packages/nextjs/src/config/webpackPluginOptions.ts delete mode 100644 packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts 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/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..494052af26f2 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -329,16 +329,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 +373,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: userSentryOptions._experimental?.useRunAfterProductionCompileHook, + }), ...(isTurbopackSupported && isTurbopack ? { turbopack: constructTurbopackConfig({ 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..a644525ce311 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,16 @@ 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?._experimental?.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..d46bcd917fb7 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,100 @@ 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: { + _experimental: { + 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: { + _experimental: { + 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..d0b30aa7eae3 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 }; From 162143f3d46c6427d1649f86e72ed213b52ff52b Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:52:20 +0200 Subject: [PATCH 18/28] fix(nextjs): Enable fetch span when OTel setup is skipped (#17699) Next.js has their own fetch instrumentation, but when people use their own OTel setup, we don't want to disable that. fixes https://github.com/getsentry/sentry-javascript/issues/17581 --- packages/nextjs/src/server/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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. From f3aa9978f6c668669a48d0ca6d8994e25ffc1c93 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 19 Sep 2025 09:45:07 -0400 Subject: [PATCH 19/28] feat(core): Add logger to core and allow scope to be passed log methods (#17698) Supercedes https://github.com/getsentry/sentry-javascript/pull/16874 This PR makes two changes 1. It adds `logger` as an export to `@sentry/core`, and then refactors the `browser`, `cloudflare` and `vercel-edge` packages to just re-export `@sentry/core`'s logger. This change makes it easy to use logging in an isomorphic way, and reduces duplication between our various packages. We couldn't change the export from `node-core` because it has a different type signature than the standard logger. 2. It expands the logger exports to accept an optional scope argument. This allows for users to provide their own custom clients to the methods, which helps with standalone client cases. ```js import * as Sentry from "@sentry/browser"; const client = createMySentryClient(); const scope = new Sentry.Scope(); scope.setClient(client); Sentry.logger.info("Hello World!", {}, { scope }); ``` --- packages/browser/src/index.ts | 4 +- packages/browser/test/client.test.ts | 33 ++- packages/browser/test/log.test.ts | 150 ------------- packages/cloudflare/src/index.ts | 3 +- packages/cloudflare/src/logs/exports.ts | 205 ------------------ packages/core/src/index.ts | 3 +- packages/core/src/integrations/consola.ts | 2 +- packages/core/src/logs/console-integration.ts | 2 +- .../core/src/logs/{exports.ts => internal.ts} | 2 +- .../log.ts => core/src/logs/public-api.ts} | 73 +++++-- packages/core/src/server-runtime-client.ts | 2 +- .../test/lib/integrations/consola.test.ts | 12 +- .../{exports.test.ts => internal.test.ts} | 83 ++++--- .../test/lib/server-runtime-client.test.ts | 26 ++- packages/node-core/src/logs/capture.ts | 37 +++- packages/node-core/test/logs/exports.test.ts | 156 +++++++------ packages/vercel-edge/src/index.ts | 3 +- 17 files changed, 294 insertions(+), 502 deletions(-) delete mode 100644 packages/browser/test/log.test.ts delete mode 100644 packages/cloudflare/src/logs/exports.ts rename packages/core/src/logs/{exports.ts => internal.ts} (99%) rename packages/{browser/src/log.ts => core/src/logs/public-api.ts} (70%) rename packages/core/test/lib/logs/{exports.test.ts => internal.test.ts} (93%) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 35de77f611d0..ac6ea866c9d9 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'; @@ -63,6 +60,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/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/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/index.ts b/packages/core/src/index.ts index 56ae0bdc4335..631181ccacc8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; 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/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index 6c967499800f..a79da511373f 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -6,7 +6,7 @@ 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 { _INTERNAL_captureLog } from './internal'; import { formatConsoleArgs } from './utils'; interface CaptureConsoleOptions { 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/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/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/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/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/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/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'; From c123105d845b83a51360132c684662e795c713e0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Sep 2025 17:33:18 +0200 Subject: [PATCH 20/28] feat(browser): Add option to explicitly end pageload span via `reportPageLoaded()` (#17697) Add new functionality to `browserTracingIntegration`: By setting the new `enableReportPageLoaded` option to `true`, users can take full control* of the pageload span duration. It will stay active until `Sentry.reportPageLoaded()` is called*. This new functionality is opt-in and nothing changes for the default idle span mechanism. --- .../reportPageLoaded/default/init.js | 15 ++++++ .../reportPageLoaded/default/test.ts | 42 ++++++++++++++++ .../reportPageLoaded/finalTimeout/init.js | 13 +++++ .../reportPageLoaded/finalTimeout/test.ts | 42 ++++++++++++++++ .../reportPageLoaded/navigation/init.js | 19 +++++++ .../reportPageLoaded/navigation/test.ts | 40 +++++++++++++++ .../index.bundle.tracing.replay.feedback.ts | 2 + .../src/index.bundle.tracing.replay.ts | 3 ++ packages/browser/src/index.bundle.tracing.ts | 2 + packages/browser/src/index.ts | 1 + .../src/tracing/browserTracingIntegration.ts | 50 +++++++++++++++++-- .../browser/src/tracing/reportPageLoaded.ts | 14 ++++++ .../test/tracing/reportPageLoaded.test.ts | 24 +++++++++ packages/core/src/client.ts | 11 ++++ packages/core/src/tracing/idleSpan.ts | 16 +++++- 15 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/finalTimeout/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/navigation/test.ts create mode 100644 packages/browser/src/tracing/reportPageLoaded.ts create mode 100644 packages/browser/test/tracing/reportPageLoaded.test.ts 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/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 ac6ea866c9d9..f2a3e7dc179c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -39,6 +39,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { reportPageLoaded } from './tracing/reportPageLoaded'; export type { RequestInstrumentationOptions } from './tracing/request'; export { registerSpanErrorInstrumentation, 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/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/core/src/client.ts b/packages/core/src/client.ts index 936e8ac3ebe0..1de223b327c0 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -606,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. @@ -802,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. */ diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index c8b37adec436..b35e31322ecd 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -75,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; } /** @@ -108,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(); @@ -151,8 +160,11 @@ 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]); } @@ -174,7 +186,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti }, 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 From 0e0c71146f2d017e795b4e72e1f6419c9555c176 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 19 Sep 2025 12:06:34 -0400 Subject: [PATCH 21/28] feat(core): Create template attributes in `consoleLoggingIntegration` (#17703) ref https://github.com/getsentry/sentry-javascript/issues/16737 Right now if users use `console.log` like so: ```js console.log("here", "is", "my", "log", "statement"); ``` The console logging integration will emit a log with log message `"here is my log statement"`. Some users would like it if we automatically paramaterized this into a template, given there are separate arguments being sent into the logging statement. So the above log statement would generate ```json { "sentry.message.template": "here {} {} {} {}", "sentry.message.parameter.0": "is", "sentry.message.parameter.1": "my", "sentry.message.parameter.2": "log", "sentry.message.parameter.3": "statement", } ``` This paramaterization is what this PR does, which provides a much better user experience. One edge case that we need to watch out for is console substitution patterns like `%s`, `%d`, `%i`, `%f`, `%o`, `%O`, `%c`. Read more about this in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/console). When encountering a console substitution pattern in the string, we elect to not generate string templates, as parsing the string to evaluate it gets too complicated client side. --- .../public-api/logger/integration/subject.js | 8 ++ .../public-api/logger/integration/test.ts | 85 ++++++++++++++++++- packages/core/src/logs/console-integration.ts | 18 +++- packages/core/src/logs/utils.ts | 32 +++++++ 4 files changed, 138 insertions(+), 5 deletions(-) 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/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index a79da511373f..bf49c745e788 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -7,7 +7,7 @@ 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 './internal'; -import { formatConsoleArgs } from './utils'; +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/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; +} From ee4ed058f5307d6f04ee44db4b74650c6aa034c0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 22 Sep 2025 10:31:33 +0200 Subject: [PATCH 22/28] chore(repo): Add changelog entry for `reportPageLoaded` (#17724) --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 614883faa7eb..572f19a13b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(browser): Add option to explicitly end pageload span via `reportPageLoaded()` ([#17697](https://github.com/getsentry/sentry-javascript/issues/17212))** + + 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. + ## 10.12.0 ### Important Changes From 51c16a4e02e0a1028de72875d8904b92f5a56868 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 22 Sep 2025 10:45:46 +0200 Subject: [PATCH 23/28] feat(node): Add extra platforms to `os` context (#17720) - Ref https://github.com/getsentry/sentry-electron/issues/1218 --- packages/node-core/src/integrations/context.ts | 2 ++ 1 file changed, 2 insertions(+) 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. */ From 592ed90d1eb8af0e9deeca735daf773d2c28e079 Mon Sep 17 00:00:00 2001 From: Olexandr88 Date: Mon, 22 Sep 2025 11:55:13 +0300 Subject: [PATCH 24/28] chore: Add link to build and test icon in readme (#17719) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 9e24a700aa65a4b66019c63fdc30faaccc3876d7 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 22 Sep 2025 11:37:54 +0200 Subject: [PATCH 25/28] chore: Add external contributor to CHANGELOG.md (#17725) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17719 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 572f19a13b24..29a8728c270b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @Olexandr88. Thank you for your contribution! + ### Important Changes - **feat(browser): Add option to explicitly end pageload span via `reportPageLoaded()` ([#17697](https://github.com/getsentry/sentry-javascript/issues/17212))** From cf7913c1ae782ecc2ea9f3a1215e3065f79ca85f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 22 Sep 2025 12:25:39 +0200 Subject: [PATCH 26/28] feat(nextjs): Promote `useRunAfterProductionCompileHook` to non-experimental build option (#17721) --- packages/nextjs/src/config/types.ts | 21 +-- .../nextjs/src/config/withSentryConfig.ts | 4 +- packages/nextjs/test/config/testUtils.ts | 3 +- .../webpack/constructWebpackConfig.test.ts | 8 +- .../test/config/withSentryConfig.test.ts | 139 +++--------------- 5 files changed, 36 insertions(+), 139 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 55f080fb433e..23b18f39b253 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 false + */ + 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/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 494052af26f2..e27f2bf37e25 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -294,7 +294,7 @@ function getFinalConfigObject( } } - if (userSentryOptions?._experimental?.useRunAfterProductionCompileHook === true && supportsProductionCompileHook()) { + if (userSentryOptions?.useRunAfterProductionCompileHook === true && supportsProductionCompileHook()) { if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { incomingUserNextConfigObject.compiler ??= {}; incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { @@ -379,7 +379,7 @@ function getFinalConfigObject( releaseName, routeManifest, nextJsVersion, - useRunAfterProductionCompileHook: userSentryOptions._experimental?.useRunAfterProductionCompileHook, + useRunAfterProductionCompileHook: userSentryOptions?.useRunAfterProductionCompileHook, }), ...(isTurbopackSupported && isTurbopack ? { diff --git a/packages/nextjs/test/config/testUtils.ts b/packages/nextjs/test/config/testUtils.ts index a644525ce311..c3769dfdf00f 100644 --- a/packages/nextjs/test/config/testUtils.ts +++ b/packages/nextjs/test/config/testUtils.ts @@ -77,8 +77,7 @@ export async function materializeFinalWebpackConfig(options: { routeManifest: options.routeManifest, nextJsVersion: options.nextJsVersion, useRunAfterProductionCompileHook: - options.useRunAfterProductionCompileHook ?? - options.sentryBuildTimeOptions?._experimental?.useRunAfterProductionCompileHook, + options.useRunAfterProductionCompileHook ?? options.sentryBuildTimeOptions?.useRunAfterProductionCompileHook, }); // call it to get concrete values for comparison diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index d46bcd917fb7..b8cfb4015512 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -100,9 +100,7 @@ describe('constructWebpackConfigFunction()', () => { incomingWebpackConfig: serverWebpackConfig, incomingWebpackBuildContext: serverBuildContext, sentryBuildTimeOptions: { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }, }); @@ -128,9 +126,7 @@ describe('constructWebpackConfigFunction()', () => { incomingWebpackConfig: serverWebpackConfig, incomingWebpackBuildContext: serverBuildContext, sentryBuildTimeOptions: { - _experimental: { - useRunAfterProductionCompileHook: false, - }, + useRunAfterProductionCompileHook: false, }, }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index d0b30aa7eae3..5121cb51d6fd 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -769,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 }; @@ -804,9 +804,7 @@ describe('withSentryConfig', () => { vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; const cleanConfig = { ...exportedNextConfig }; @@ -829,9 +827,7 @@ describe('withSentryConfig', () => { }; const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; const finalConfig = materializeFinalNextConfig(configWithExistingHook, undefined, sentryOptions); @@ -852,9 +848,7 @@ describe('withSentryConfig', () => { }; const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; materializeFinalNextConfig(configWithInvalidHook, undefined, sentryOptions); @@ -873,9 +867,7 @@ describe('withSentryConfig', () => { delete configWithoutCompiler.compiler; const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, + useRunAfterProductionCompileHook: true, }; const finalConfig = materializeFinalNextConfig(configWithoutCompiler, undefined, sentryOptions); @@ -890,126 +882,35 @@ describe('withSentryConfig', () => { vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, - }; - - const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); - - expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); - - delete process.env.TURBOPACK; - }); - - 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, - }, - }; - - 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, - }, + useRunAfterProductionCompileHook: true, }; + // 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('respects useRunAfterProductionCompileHook: false', () => { - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); - - const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: false, - }, - }; - - const cleanConfig = { ...exportedNextConfig }; - delete cleanConfig.compiler; - - const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); - - expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); - }); - - it('does not set up hook when experimental flag is undefined', () => { - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); - - const sentryOptions = { - _experimental: { - // useRunAfterProductionCompileHook not specified - }, - }; - - const cleanConfig = { ...exportedNextConfig }; - delete cleanConfig.compiler; - - const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); - - expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + delete process.env.TURBOPACK; }); - it('does not set up hook when _experimental is undefined', () => { + it('works with webpack builds when TURBOPACK env is not set', () => { + delete process.env.TURBOPACK; vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); const sentryOptions = { - // no _experimental property + useRunAfterProductionCompileHook: true, }; + // 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).toBeUndefined(); - }); - - it('combines experimental flag with other configurations correctly', () => { - process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); - - const sentryOptions = { - _experimental: { - useRunAfterProductionCompileHook: true, - }, - sourcemaps: {}, - tunnelRoute: '/tunnel', - }; - - 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; }); }); }); From 61b3f9732fe04668cd053fa1099a2d7be01a9aad Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 22 Sep 2025 13:15:18 +0200 Subject: [PATCH 27/28] feat(nextjs): Flip default value for `useRunAfterProductionCompileHook` turbopack builds (#17722) --- packages/nextjs/src/config/types.ts | 2 +- .../nextjs/src/config/withSentryConfig.ts | 8 ++- .../test/config/withSentryConfig.test.ts | 71 ++++++++++++++++--- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 23b18f39b253..1fa245412f2c 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -507,7 +507,7 @@ export type SentryBuildOptions = { * * When false, use the traditional approach of uploading sourcemaps during each webpack build. For Turbopack no sourcemaps will be uploaded. * - * @default false + * @default true for Turbopack, false for Webpack */ useRunAfterProductionCompileHook?: boolean; diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index e27f2bf37e25..b5c2be2f25bb 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -294,7 +294,11 @@ function getFinalConfigObject( } } - if (userSentryOptions?.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 }) => { @@ -379,7 +383,7 @@ function getFinalConfigObject( releaseName, routeManifest, nextJsVersion, - useRunAfterProductionCompileHook: userSentryOptions?.useRunAfterProductionCompileHook, + useRunAfterProductionCompileHook: shouldUseRunAfterProductionCompileHook, }), ...(isTurbopackSupported && isTurbopack ? { diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index 5121cb51d6fd..0697fc56b9e4 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -876,16 +876,13 @@ 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 = { - useRunAfterProductionCompileHook: true, - }; + const sentryOptions = {}; // No useRunAfterProductionCompileHook specified - // Use a clean copy of the config to avoid test interference const cleanConfig = { ...exportedNextConfig }; delete cleanConfig.compiler; @@ -896,20 +893,78 @@ describe('withSentryConfig', () => { 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 = {}; // No useRunAfterProductionCompileHook specified + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + 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 = { - useRunAfterProductionCompileHook: true, + useRunAfterProductionCompileHook: false, }; - // 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).toBeUndefined(); + + delete process.env.TURBOPACK; + }); + + it('respects explicit true setting for webpack', () => { + delete process.env.TURBOPACK; + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + useRunAfterProductionCompileHook: true, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + + 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 = { + useRunAfterProductionCompileHook: true, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + + delete process.env.TURBOPACK; + }); + + it('works with webpack builds when TURBOPACK env is not set', () => { + delete process.env.TURBOPACK; + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + useRunAfterProductionCompileHook: true, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); }); }); From 85e867834b9a46199ac5a144b3f45e4c956a9ac5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 22 Sep 2025 13:38:30 +0200 Subject: [PATCH 28/28] meta(changelog): Update changelog for 10.13.0 --- CHANGELOG.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a8728c270b..0d17a94e4e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @Olexandr88. Thank you for your contribution! +## 10.13.0 ### Important Changes -- **feat(browser): Add option to explicitly end pageload span via `reportPageLoaded()` ([#17697](https://github.com/getsentry/sentry-javascript/issues/17212))** +- **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: @@ -27,6 +27,59 @@ Work in this release was contributed by @Olexandr88. Thank you for your contribu 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