diff --git a/.gitignore b/.gitignore index f381e7e6e24d..36f8a3f6b9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ packages/gatsby/gatsby-node.d.ts # intellij *.iml /**/.wrangler/* + +#junit reports +packages/**/*.junit.xml diff --git a/.size-limit.js b/.size-limit.js index 5b8374f81615..9cebd30285e4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -40,6 +40,13 @@ module.exports = [ gzip: true, limit: '41 KB', }, + { + name: '@sentry/browser (incl. Tracing, Profiling)', + path: 'packages/browser/build/npm/esm/index.js', + import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), + gzip: true, + limit: '48 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', @@ -75,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '84 KB', + limit: '85 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -206,7 +213,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '45 KB', + limit: '46 KB', }, // SvelteKit SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index a14433358d0c..9b9b82b9bc8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,53 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.21.0 + +### Important Changes + +- **feat(browserProfiling): Add `trace` lifecycle mode for UI profiling ([#17619](https://github.com/getsentry/sentry-javascript/pull/17619))** + + Adds a new `trace` lifecycle mode for UI profiling, allowing profiles to be captured for the duration of a trace. A `manual` mode will be added in a future release. + +- **feat(nuxt): Instrument Database ([#17899](https://github.com/getsentry/sentry-javascript/pull/17899))** + + Adds instrumentation for Nuxt database operations, enabling better performance tracking of database queries. + +- **feat(nuxt): Instrument server cache API ([#17886](https://github.com/getsentry/sentry-javascript/pull/17886))** + + Adds instrumentation for Nuxt's server cache API, providing visibility into cache operations. + +- **feat(nuxt): Instrument storage API ([#17858](https://github.com/getsentry/sentry-javascript/pull/17858))** + + Adds instrumentation for Nuxt's storage API, enabling tracking of storage operations. + +### Other Changes + +- feat(browser): Add `onRequestSpanEnd` hook to browser tracing integration ([#17884](https://github.com/getsentry/sentry-javascript/pull/17884)) +- feat(nextjs): Support Next.js proxy files ([#17926](https://github.com/getsentry/sentry-javascript/pull/17926)) +- feat(replay): Record outcome when event buffer size exceeded ([#17946](https://github.com/getsentry/sentry-javascript/pull/17946)) +- fix(cloudflare): copy execution context in durable objects and handlers ([#17786](https://github.com/getsentry/sentry-javascript/pull/17786)) +- fix(core): Fix and add missing cache attributes in Vercel AI ([#17982](https://github.com/getsentry/sentry-javascript/pull/17982)) +- fix(core): Improve uuid performance ([#17938](https://github.com/getsentry/sentry-javascript/pull/17938)) +- fix(ember): Use updated version for `clean-css` ([#17979](https://github.com/getsentry/sentry-javascript/pull/17979)) +- fix(nextjs): Don't set experimental instrumentation hook flag for next 16 ([#17978](https://github.com/getsentry/sentry-javascript/pull/17978)) +- fix(nextjs): Inconsistent transaction naming for i18n routing ([#17927](https://github.com/getsentry/sentry-javascript/pull/17927)) +- fix(nextjs): Update bundler detection ([#17976](https://github.com/getsentry/sentry-javascript/pull/17976)) + +
+ Internal Changes + +- build: Update to typescript 5.8.0 ([#17710](https://github.com/getsentry/sentry-javascript/pull/17710)) +- chore: Add external contributor to CHANGELOG.md ([#17949](https://github.com/getsentry/sentry-javascript/pull/17949)) +- chore(build): Upgrade nodemon to 3.1.10 ([#17956](https://github.com/getsentry/sentry-javascript/pull/17956)) +- chore(ci): Fix external contributor action when multiple contributions existed ([#17950](https://github.com/getsentry/sentry-javascript/pull/17950)) +- chore(solid): Remove unnecessary import from README ([#17947](https://github.com/getsentry/sentry-javascript/pull/17947)) +- test(nextjs): Fix proxy/middleware test ([#17970](https://github.com/getsentry/sentry-javascript/pull/17970)) + +
+ +Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! + ## 10.20.0 ### Important Changes @@ -42,7 +89,7 @@ - chore: Add external contributor to CHANGELOG.md ([#17940](https://github.com/getsentry/sentry-javascript/pull/17940)) -Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez . Thank you for your contributions! +Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez. Thank you for your contributions! ## 10.19.0 diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js index 230e9ee1fb9e..aad9fd2a764c 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js @@ -17,7 +17,7 @@ function fibonacci(n) { return fibonacci(n - 1) + fibonacci(n - 2); } -await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { +await Sentry.startSpanManual({ name: 'root-fibonacci', parentSpan: null, forceTransaction: true }, async span => { fibonacci(30); // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index 35f4e17bec0a..d473236cdfda 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -73,14 +73,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU expect(profile.frames.length).toBeGreaterThan(0); for (const frame of profile.frames) { expect(frame).toHaveProperty('function'); - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.function).toBe('string'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } } const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js new file mode 100644 index 000000000000..0095eb5743d9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js @@ -0,0 +1,48 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +// Create two NON-overlapping root spans so that the profiler stops and emits a chunk +// after each span (since active root span count returns to 0 between them). +await Sentry.startSpanManual({ name: 'root-fibonacci-1', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +// Small delay to ensure the first chunk is collected and sent +await new Promise(r => setTimeout(r, 25)); + +await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceTransaction: true }, async span => { + largeSum(); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts new file mode 100644 index 000000000000..702140b8823e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -0,0 +1,206 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Assert that no profile_chunk envelope is sent without policy header + const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 }); + expect(chunkCount).toBe(0); + }, +); + +sentryTest( + 'sends profile_chunk envelopes in trace mode (multiple chunks)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + // Expect at least 2 chunks because subject creates two separate root spans, + // causing the profiler to stop and emit a chunk after each root span ends. + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload1.profile).toBeDefined(); + expect(envelopeItemPayload1.version).toBe('2'); + expect(envelopeItemPayload1.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + expect(typeof envelopeItemPayload1.profiler_id).toBe('string'); + expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload1.chunk_id).toBe('string'); + expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload1.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload1.release).toBe('string'); + expect(envelopeItemPayload1.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true); + + const profile1 = envelopeItemPayload1.profile; + + expect(profile1.samples).toBeDefined(); + expect(profile1.stacks).toBeDefined(); + expect(profile1.frames).toBeDefined(); + expect(profile1.thread_metadata).toBeDefined(); + + // Samples + expect(profile1.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile1.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile1.stacks.length); + + // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) + expect(typeof (sample as any).timestamp).toBe('number'); + const ts = (sample as any).timestamp as number; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile1.stacks.length).toBeGreaterThan(0); + for (const stack of profile1.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile1.frames.length); + } + } + + // Frames + expect(profile1.frames.length).toBeGreaterThan(0); + for (const frame of profile1.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // first function is captured (other one is in other chunk) + 'fibonacci', + ]), + ); + } + + expect(profile1.thread_metadata).toHaveProperty('0'); + expect(profile1.thread_metadata['0']).toHaveProperty('name'); + expect(profile1.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeSec = (profile1.samples[0] as any).timestamp as number; + const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; + const durationSec = endTimeSec - startTimeSec; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationSec).toBeGreaterThan(0.2); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + // Basic sanity on the second chunk: has correct envelope type and structure + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + expect(envelopeItemPayload2.version).toBe('2'); + expect(envelopeItemPayload2.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload2.profiler_id).toBe('string'); + expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload2.chunk_id).toBe('string'); + expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload2.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload2.release).toBe('string'); + expect(envelopeItemPayload2.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true); + + const profile2 = envelopeItemPayload2.profile; + + const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames2.length).toBeGreaterThan(0); + expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames2).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // second function is captured (other one is in other chunk) + 'largeSum', + ]), + ); + } + }, +); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js new file mode 100644 index 000000000000..071afe1ed059 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -0,0 +1,52 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +let firstSpan; + +Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => { + largeSum(); + firstSpan = span; +}); + +await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + + Sentry.startSpan({ name: 'child-fibonacci', parentSpan: span }, childSpan => { + console.log('child span'); + }); + + // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled + await new Promise(resolve => setTimeout(resolve, 21)); + span.end(); +}); + +await new Promise(r => setTimeout(r, 21)); + +firstSpan.end(); + +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts new file mode 100644 index 000000000000..60744def96cd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -0,0 +1,187 @@ +import { expect } from '@playwright/test'; +import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properEnvelopeRequestParser, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); + + expect(transactionEvent).toBeDefined(); + + expect(profileEvent).toBeUndefined(); + }, +); + +sentryTest( + 'sends profile envelope in trace mode (single chunk for overlapping spans)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + await page.goto(url); + + const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'profile_chunk' }, + properFullEnvelopeRequestParser, + ); + + const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload.profile).toBeDefined(); + expect(envelopeItemPayload.version).toBe('2'); + expect(envelopeItemPayload.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload.profiler_id).toBe('string'); + expect(envelopeItemPayload.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload.chunk_id).toBe('string'); + expect(envelopeItemPayload.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload.release).toBe('string'); + expect(envelopeItemPayload.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload?.debug_meta?.images)).toBe(true); + + const profile = envelopeItemPayload.profile; + + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // Samples + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile.stacks.length); + + // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) + expect(typeof sample.timestamp).toBe('number'); + const ts = sample.timestamp; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // Frames + expect(profile.frames.length).toBeGreaterThan(0); + for (const frame of profile.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // both functions are captured + 'fibonacci', + 'largeSum', + ]), + ); + } + + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeSec = (profile.samples[0] as any).timestamp as number; + const endTimeSec = (profile.samples[profile.samples.length - 1] as any).timestamp as number; + const durationSec = endTimeSec - startTimeSec; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationSec).toBeGreaterThan(0.2); + }, +); + +sentryTest('attaches thread data to child spans (trace mode)', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + const req = await waitForTransactionRequestOnUrl(page, url); + const rootSpan = properEnvelopeRequestParser(req, 0) as any; + + expect(rootSpan?.type).toBe('transaction'); + expect(rootSpan.transaction).toBe('root-fibonacci-2'); + + const profilerId = rootSpan?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId).toBe('string'); + + expect(profilerId).toMatch(/^[a-f0-9]{32}$/); + + const spans = (rootSpan?.spans ?? []) as Array<{ data?: Record }>; + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.data).toBeDefined(); + expect(span.data?.['thread.id']).toBe('0'); + expect(span.data?.['thread.name']).toBe('main'); + } +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js new file mode 100644 index 000000000000..9627bfc003e7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + onRequestSpanEnd(span, { headers }) { + if (headers) { + span.setAttribute('hook.called.response-type', headers.get('x-response-type')); + } + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js new file mode 100644 index 000000000000..8a1ec65972f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js @@ -0,0 +1,11 @@ +fetch('http://sentry-test.io/fetch', { + headers: { + foo: 'fetch', + }, +}); + +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test.io/xhr'); +xhr.setRequestHeader('foo', 'xhr'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts new file mode 100644 index 000000000000..03bfc13814af --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should call onRequestSpanEnd hook', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('http://sentry-test.io/fetch', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'fetch', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + await page.route('http://sentry-test.io/xhr', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'xhr', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'xhr', + 'hook.called.response-type': 'xhr', + }), + }), + ); + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'fetch', + 'hook.called.response-type': 'fetch', + }), + }), + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js index 09af5f3e4ab4..d32f77f4cb6b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js @@ -5,6 +5,15 @@ window.Sentry = Sentry; // Force this so that the initial sampleRand is consistent Math.random = () => 0.45; +// Polyfill crypto.randomUUID +crypto.randomUUID = function randomUUID() { + return ([1e7] + 1e3 + 4e3 + 8e3 + 1e11).replace( + /[018]/g, + // eslint-disable-next-line no-bitwise + c => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), + ); +}; + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 849b011250f9..b945bee2eeea 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -86,7 +86,7 @@ export function createRunner(...paths: string[]) { } return this; }, - start: function (): StartResult { + start: function (signal?: AbortSignal): StartResult { const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); const expectedEnvelopeCount = expectedEnvelopes.length; @@ -155,7 +155,7 @@ export function createRunner(...paths: string[]) { '--var', `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, ], - { stdio }, + { stdio, signal }, ); CLEANUP_STEPS.add(() => { diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts index b785e6e37fd1..347c0d3530d8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import { eventEnvelope } from '../../expect'; import { createRunner } from '../../runner'; -it('Basic error in fetch handler', async () => { +it('Basic error in fetch handler', async ({ signal }) => { const runner = createRunner(__dirname) .expect( eventEnvelope({ @@ -26,7 +26,7 @@ it('Basic error in fetch handler', async () => { }, }), ) - .start(); + .start(signal); await runner.makeRequest('get', '/', { expectError: true }); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index 13966caaf460..c9e112b32241 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -6,7 +6,7 @@ import { createRunner } from '../../../runner'; // want to test that the instrumentation does not break in our // cloudflare SDK. -it('traces a basic message creation request', async () => { +it('traces a basic message creation request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { @@ -35,7 +35,7 @@ it('traces a basic message creation request', async () => { ]), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index a9daae21480f..e86508c0f101 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; -it('traces a durable object method', async () => { +it('traces a durable object method', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1]; @@ -21,7 +21,7 @@ it('traces a durable object method', async () => { }), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/hello'); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index c1aee24136a4..eb15fd80fc97 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -6,7 +6,7 @@ import { createRunner } from '../../../runner'; // want to test that the instrumentation does not break in our // cloudflare SDK. -it('traces a basic chat completion request', async () => { +it('traces a basic chat completion request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { @@ -37,7 +37,7 @@ it('traces a basic chat completion request', async () => { ]), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); }); diff --git a/dev-packages/e2e-tests/test-applications/ember-classic/package.json b/dev-packages/e2e-tests/test-applications/ember-classic/package.json index 260a8f8032ae..949b2b05f816 100644 --- a/dev-packages/e2e-tests/test-applications/ember-classic/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-classic/package.json @@ -75,7 +75,8 @@ "node": ">=18" }, "resolutions": { - "@babel/traverse": "~7.25.9" + "@babel/traverse": "~7.25.9", + "clean-css": "^5.3.0" }, "ember": { "edition": "octane" diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc b/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts b/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts new file mode 100644 index 000000000000..beb10260da38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts @@ -0,0 +1,5 @@ +import * as _SentryReplay from '@sentry-internal/replay'; +import * as _SentryBrowser from '@sentry/browser'; +import * as _SentryCore from '@sentry/core'; +import * as _SentryNode from '@sentry/node'; +import * as _SentryWasm from '@sentry/wasm'; diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json new file mode 100644 index 000000000000..1079d8f4c793 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json @@ -0,0 +1,26 @@ +{ + "name": "@sentry-internal/ts5.0-test", + "private": true, + "license": "MIT", + "scripts": { + "build:types": "pnpm run type-check", + "ts-version": "tsc --version", + "type-check": "tsc --project tsconfig.json", + "test:build": "pnpm install && pnpm run build:types", + "test:assert": "pnpm -v" + }, + "devDependencies": { + "typescript": "5.0.2", + "@types/node": "^18.19.1" + }, + "dependencies": { + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry-internal/replay": "latest || *", + "@sentry/wasm": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json b/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json new file mode 100644 index 000000000000..95de9c93fc38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["index.ts"], + "compilerOptions": { + "lib": ["es2018", "DOM"], + "skipLibCheck": false, + "noEmit": true, + "types": [], + "target": "es2018", + "moduleResolution": "node" + } +} diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json index 503ad2758767..bf0b59ca0adf 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json @@ -41,6 +41,7 @@ "@total-typescript/ts-reset": "^0.4.2", "@types/eslint": "^8.4.10", "@types/react": "^18.2.22", + "@types/node": "^18.19.1", "@types/react-dom": "^18.2.7", "esbuild": "0.25.0", "eslint": "^9.18.0", diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json index 0d4c4dc2e4de..6b1b95f76f6f 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json @@ -14,7 +14,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "baseUrl": ".", - "types": ["@shopify/oxygen-workers-types"], + "types": ["@shopify/oxygen-workers-types", "node"], "paths": { "~/*": ["app/*"] }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore new file mode 100644 index 000000000000..2d0dd371dc86 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore @@ -0,0 +1,51 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry +.sentryclirc + +pnpm-lock.yaml +.tmp_dev_server_logs +.tmp_build_stdout +.tmp_build_stderr +event-dumps +test-results + diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx new file mode 100644 index 000000000000..7e2e8d45db06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx @@ -0,0 +1,9 @@ +export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

I18n Test Page

+

Current locale: {locale}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx new file mode 100644 index 000000000000..23e7b3213a3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx @@ -0,0 +1,9 @@ +export default async function LocaleRootPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

Locale Root

+

Current locale: {locale}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx new file mode 100644 index 000000000000..60b3740fd7a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx @@ -0,0 +1,11 @@ +export const metadata = { + title: 'Next.js 15 i18n Test', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts new file mode 100644 index 000000000000..5ed375a9107a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts @@ -0,0 +1,14 @@ +import { getRequestConfig } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { routing } from './routing'; + +export default getRequestConfig(async ({ requestLocale }) => { + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; + + return { + locale, + messages: {}, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts new file mode 100644 index 000000000000..efa95881eabc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts @@ -0,0 +1,10 @@ +import { defineRouting } from 'next-intl/routing'; +import { createNavigation } from 'next-intl/navigation'; + +export const routing = defineRouting({ + locales: ['en', 'ar', 'fr'], + defaultLocale: 'en', + localePrefix: 'as-needed', +}); + +export const { Link, redirect, usePathname, useRouter } = createNavigation(routing); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts new file mode 100644 index 000000000000..c232101a75e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts new file mode 100644 index 000000000000..14e2b3ce738a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts @@ -0,0 +1,8 @@ +import createMiddleware from 'next-intl/middleware'; +import { routing } from './i18n/routing'; + +export default createMiddleware(routing); + +export const config = { + matcher: ['/((?!api|_next|.*\\..*).*)'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js new file mode 100644 index 000000000000..edd191e14b38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js @@ -0,0 +1,11 @@ +const { withSentryConfig } = require('@sentry/nextjs'); +const createNextIntlPlugin = require('next-intl/plugin'); + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = withSentryConfig(withNextIntl(nextConfig), { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json new file mode 100644 index 000000000000..359b939eaf50 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -0,0 +1,31 @@ +{ + "name": "nextjs-15-intl", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@types/node": "^18.19.1", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "15.5.4", + "next-intl": "^4.3.12", + "react": "latest", + "react-dom": "latest", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs new file mode 100644 index 000000000000..38548e975851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs @@ -0,0 +1,25 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts new file mode 100644 index 000000000000..e9521895498e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.SENTRY_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts new file mode 100644 index 000000000000..760b8b581a29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs new file mode 100644 index 000000000000..8f6b9b5886d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-15-intl', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/nextjs-15-intl-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts new file mode 100644 index 000000000000..0943df8c7216 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create consistent parameterized transaction for default locale without prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should create consistent parameterized transaction for non-default locale with prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/ar/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should parameterize locale root page correctly for default locale without prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should parameterize locale root page correctly for non-default locale with prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/fr`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json new file mode 100644 index 000000000000..64c21044c49f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "target": "ES2017" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx new file mode 100644 index 000000000000..10c32a944514 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx @@ -0,0 +1,10 @@ +export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

I18n Test Page

+

Current locale: {locale || 'default'}

+

This page tests i18n route parameterization

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts new file mode 100644 index 000000000000..fda0645fa1a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create consistent parameterized transaction for i18n routes - locale: en', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/en/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create consistent parameterized transaction for i18n routes - locale: ar', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/ar/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc @@ -0,0 +1,4 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 1fd09523ddb2..2da23b152807 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@sentry/nextjs": "latest || *", + "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", "next": "16.0.0-beta.0", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts index 85bd765c9c44..2199afc46eaf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts @@ -6,4 +6,5 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 8da0a18497a0..08d5d580b314 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -6,5 +6,6 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + // debug: true, integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts new file mode 100644 index 000000000000..4ed289eb6215 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for middleware', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const response = await request.get('/api/endpoint-behind-middleware'); + expect(await response.json()).toStrictEqual({ name: 'John Doe' }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + + // Assert that isolation scope works properly + expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); + +test('Faulty middlewares', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const errorEventPromise = waitForError('nextjs-16', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + await test.step('should record transactions', async () => { + const middlewareTransaction = await middlewareTransactionPromise; + expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + }); + + await test.step('should record exceptions', async () => { + const errorEvent = await errorEventPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect([ + 'middleware GET', // non-otel webpack versions + '/middleware', // middleware file + '/proxy', // proxy file + ]).toContain(errorEvent.transaction); + }); +}); + +test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent?.transaction === 'middleware GET' && + !!transactionEvent.spans?.find(span => span.op === 'http.client') + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.spans).toEqual( + expect.arrayContaining([ + { + data: { + 'http.method': 'GET', + 'http.response.status_code': 200, + type: 'fetch', + url: 'http://localhost:3030/', + 'http.url': 'http://localhost:3030/', + 'server.address': 'localhost:3030', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.wintercg_fetch', + }, + description: 'GET http://localhost:3030/', + op: 'http.client', + origin: 'auto.http.wintercg_fetch', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]), + ); + expect(middlewareTransaction.breadcrumbs).toEqual( + expect.arrayContaining([ + { + category: 'fetch', + data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + timestamp: expect.any(Number), + type: 'http', + }, + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index b9c0e7b4b602..45a89f683be4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -3,7 +3,7 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const response = await request.get('/api/endpoint-behind-middleware'); @@ -23,7 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => { test('Faulty middlewares', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-faulty-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { @@ -48,14 +48,14 @@ test('Faulty middlewares', async ({ request }) => { // Assert that isolation scope works properly expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(errorEvent.transaction).toBe('middleware GET /api/endpoint-behind-faulty-middleware'); + expect(errorEvent.transaction).toBe('middleware GET'); }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( - transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware' && + transactionEvent?.transaction === 'middleware GET' && !!transactionEvent.spans?.find(span => span.op === 'http.client') ); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 0fcccd560af9..8f920a41e76e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -11,4 +11,28 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index b38943d6e3eb..bbf0ced23c12 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -33,6 +33,7 @@ ] }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts new file mode 100644 index 000000000000..b19530e18c96 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = cachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? ''); + const dataKey = String(getQuery(event).data ?? ''); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts new file mode 100644 index 000000000000..383617421ec7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts new file mode 100644 index 000000000000..2241afdee14d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts new file mode 100644 index 000000000000..a1697136ef01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test?user=123&data=test-key'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts new file mode 100644 index 000000000000..a229b4db34cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts new file mode 100644 index 000000000000..ecb0e32133db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-3', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..a721df04b40c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts new file mode 100644 index 000000000000..2c451be51135 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index d0ae045f1e9d..c7acd2b12328 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -13,7 +13,6 @@ export default defineNuxtConfig({ }, modules: ['@pinia/nuxt', '@sentry/nuxt/module'], - runtimeConfig: { public: { sentry: { @@ -21,4 +20,28 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index b16b7ee2b236..a5d36c1f6a61 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -25,7 +25,8 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" }, "sentryTest": { "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts new file mode 100644 index 000000000000..0fb4ace46bd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = cachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts new file mode 100644 index 000000000000..53f110c1ce28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts new file mode 100644 index 000000000000..4460758ab414 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts new file mode 100644 index 000000000000..1295de002145 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts new file mode 100644 index 000000000000..9d995fa1b37c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts new file mode 100644 index 000000000000..9b9fdd892563 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-4', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..1e2fc1eb16b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts new file mode 100644 index 000000000000..c171c9b6956f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/external-contributor-gh-action/index.mjs b/dev-packages/external-contributor-gh-action/index.mjs index ffa9369ee2df..2bad8a16f0bd 100644 --- a/dev-packages/external-contributor-gh-action/index.mjs +++ b/dev-packages/external-contributor-gh-action/index.mjs @@ -7,7 +7,7 @@ const UNRELEASED_HEADING = `## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott `; -const contributorMessageRegex = /Work in this release was contributed by (.+)\. Thank you for your contribution!/; +const contributorMessageRegex = /Work in this release was contributed by (.+)\. Thank you for your contributions?!/; async function run() { const { getInput } = core; diff --git a/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs new file mode 100644 index 000000000000..d2a0408eebcd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: '1.0', + enableLogs: true, + integrations: [Sentry.pinoIntegration({ autoInstrument: false })], +}); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs new file mode 100644 index 000000000000..2e968444a74f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({ name: 'myapp' }); +Sentry.pinoIntegration.trackLogger(logger); + +const loggerIgnore = pino({ name: 'ignore' }); + +loggerIgnore.info('this should be ignored'); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index ea8dc5e223d0..beb080ac3c42 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -3,6 +3,11 @@ import pino from 'pino'; const logger = pino({ name: 'myapp' }); +const ignoredLogger = pino({ name: 'ignored' }); +Sentry.pinoIntegration.untrackLogger(ignoredLogger); + +ignoredLogger.info('this will not be tracked'); + Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'startup' }, () => { logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index cc88f650203b..1982c8d686fc 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -173,4 +173,54 @@ conditionalTest({ min: 20 })('Pino integration', () => { .start() .completed(); }); + + test('captures logs when autoInstrument is false and logger is tracked', async () => { + const instrumentPath = join(__dirname, 'instrument-auto-off.mjs'); + + await createRunner(__dirname, 'scenario-track.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 30, type: 'integer' }, + user: { value: 'user-id', type: 'string' }, + something: { + type: 'string', + value: '{"more":3,"complex":"nope"}', + }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 50, type: 'integer' }, + err: { value: '{}', type: 'string' }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); }); diff --git a/package.json b/package.json index edbd645b3c97..0c3d47a3c7b3 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "jsdom": "^21.1.2", "lerna": "7.1.1", "madge": "7.0.0", - "nodemon": "^2.0.16", + "nodemon": "^3.1.10", "npm-run-all2": "^6.2.0", "prettier": "^3.6.2", "prettier-plugin-astro": "^0.14.1", @@ -135,7 +135,7 @@ "size-limit": "~11.1.6", "sucrase": "^3.35.0", "ts-node": "10.9.1", - "typescript": "~5.0.0", + "typescript": "~5.8.0", "vitest": "^3.2.4", "yalc": "^1.0.0-pre.53", "yarn-deduplicate": "6.0.2" diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index accf3cb3a278..a4d0960b1ccb 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -28,7 +28,7 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export { getBodyString, getFetchRequestArgBody, serializeFormData } from './networkUtils'; +export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrResponseHeaders } from './networkUtils'; export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 8017bd4c89e1..4c461ec6776c 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -301,7 +301,7 @@ function instrumentPerformanceObserver(type: InstrumentHandlerTypePerformanceObs function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { handlers[type] = handlers[type] || []; - (handlers[type] as InstrumentHandlerCallback[]).push(handler); + handlers[type].push(handler); } // Get a callback which can be called to remove the instrumentation handler diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index 607434251872..b8df5886e7ee 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -54,3 +54,29 @@ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit[' return (fetchArgs[1] as RequestInit).body; } + +/** + * Parses XMLHttpRequest response headers into a Record. + * Extracted from replay internals to be reusable. + */ +export function parseXhrResponseHeaders(xhr: XMLHttpRequest): Record { + let headers: string | undefined; + try { + headers = xhr.getAllResponseHeaders(); + } catch (error) { + DEBUG_BUILD && debug.error(error, 'Failed to get xhr response headers', xhr); + return {}; + } + + if (!headers) { + return {}; + } + + return headers.split('\r\n').reduce((acc: Record, line: string) => { + const [key, value] = line.split(': ') as [string, string | undefined]; + if (value) { + acc[key.toLowerCase()] = value; + } + return acc; + }, {}); +} diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7ad77d8920e5..415282698d45 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,15 +1,21 @@ import type { EventEnvelope, IntegrationFn, Profile, Span } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, getRootSpan } from '@sentry/core'; +import { debug, defineIntegration, getActiveSpan, getRootSpan, hasSpansEnabled } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; +import { BrowserTraceLifecycleProfiler } from './lifecycleMode/traceLifecycleProfiler'; import { startProfileForSpan } from './startProfileForSpan'; import type { ProfiledEvent } from './utils'; import { addProfilesToEnvelope, + attachProfiledThreadToEvent, createProfilingEvent, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, + hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSpan, + shouldProfileSession, + shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -19,73 +25,133 @@ const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, setup(client) { + const options = client.getOptions() as BrowserOptions; + + if (!hasLegacyProfiling(options) && !options.profileLifecycle) { + // Set default lifecycle mode + options.profileLifecycle = 'manual'; + } + + if (hasLegacyProfiling(options) && !options.profilesSampleRate) { + DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); + return; + } + const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); - if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { - if (shouldProfileSpan(rootSpan)) { - startProfileForSpan(rootSpan); - } + if (hasLegacyProfiling(options) && options.profileSessionSampleRate !== undefined) { + DEBUG_BUILD && + debug.warn( + '[Profiling] Both legacy profiling (`profilesSampleRate`) and UI profiling settings are defined. `profileSessionSampleRate` has no effect when legacy profiling is enabled.', + ); } - client.on('spanStart', (span: Span) => { - if (span === getRootSpan(span) && shouldProfileSpan(span)) { - startProfileForSpan(span); + // UI PROFILING (Profiling V2) + if (!hasLegacyProfiling(options)) { + const sessionSampled = shouldProfileSession(options); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); } - }); - client.on('beforeEnvelope', (envelope): void => { - // if not profiles are in queue, there is nothing to add to the envelope. - if (!getActiveProfilesCount()) { - return; - } + const lifecycleMode = options.profileLifecycle; - const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); - if (!profiledTransactionEvents.length) { - return; - } + if (lifecycleMode === 'trace') { + if (!hasSpansEnabled(options)) { + DEBUG_BUILD && + debug.warn( + "[Profiling] `profileLifecycle` is 'trace' but tracing is disabled. Set a `tracesSampleRate` or `tracesSampler` to enable span tracing.", + ); + return; + } - const profilesToAddToEnvelope: Profile[] = []; + const traceLifecycleProfiler = new BrowserTraceLifecycleProfiler(); + traceLifecycleProfiler.initialize(client, sessionSampled); - for (const profiledTransaction of profiledTransactionEvents) { - const context = profiledTransaction?.contexts; - const profile_id = context?.profile?.['profile_id']; - const start_timestamp = context?.profile?.['start_timestamp']; + // If there is an active, sampled root span already, notify the profiler + if (rootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + } - if (typeof profile_id !== 'string') { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. + WINDOW.setTimeout(() => { + const laterActiveSpan = getActiveSpan(); + const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); + if (laterRootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + } + }, 0); + } + } else { + // LEGACY PROFILING (v1) + if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { + if (shouldProfileSpanLegacy(rootSpan)) { + startProfileForSpan(rootSpan); } + } - if (!profile_id) { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + client.on('spanStart', (span: Span) => { + if (span === getRootSpan(span) && shouldProfileSpanLegacy(span)) { + startProfileForSpan(span); } + }); - // Remove the profile from the span context before sending, relay will take care of the rest. - if (context?.profile) { - delete context.profile; + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!getActiveProfilesCount()) { + return; } - const profile = takeProfileFromGlobalCache(profile_id); - if (!profile) { - DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); - continue; + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; } - const profileEvent = createProfilingEvent( - profile_id, - start_timestamp as number | undefined, - profile, - profiledTransaction as ProfiledEvent, - ); - if (profileEvent) { - profilesToAddToEnvelope.push(profileEvent); + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction?.contexts; + const profile_id = context?.profile?.['profile_id']; + const start_timestamp = context?.profile?.['start_timestamp']; + + if (typeof profile_id !== 'string') { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + if (!profile_id) { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + // Remove the profile from the span context before sending, relay will take care of the rest. + if (context?.profile) { + delete context.profile; + } + + const profile = takeProfileFromGlobalCache(profile_id); + if (!profile) { + DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); + continue; + } + + const profileEvent = createProfilingEvent( + profile_id, + start_timestamp as number | undefined, + profile, + profiledTransaction as ProfiledEvent, + ); + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } } - } - addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); - }); + addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); + }); + } + }, + processEvent(event) { + return attachProfiledThreadToEvent(event); }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts new file mode 100644 index 000000000000..3ce773fe01ff --- /dev/null +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -0,0 +1,355 @@ +import type { Client, ProfileChunk, Span } from '@sentry/core'; +import { + type ProfileChunkEnvelope, + createEnvelope, + debug, + dsnToString, + getGlobalScope, + getRootSpan, + getSdkMetadataForEnvelopeHeader, + uuid4, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { JSSelfProfiler } from '../jsSelfProfiling'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; + +const CHUNK_INTERVAL_MS = 60_000; // 1 minute +// Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) +const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes + +/** + * Browser trace-lifecycle profiler (UI Profiling / Profiling V2): + * - Starts when the first sampled root span starts + * - Stops when the last sampled root span ends + * - While running, periodically stops and restarts the JS self-profiling API to collect chunks + * + * Profiles are emitted as standalone `profile_chunk` envelopes either when: + * - there are no more sampled root spans, or + * - the 60s chunk timer elapses while profiling is running. + */ +export class BrowserTraceLifecycleProfiler { + private _client: Client | undefined; + private _profiler: JSSelfProfiler | undefined; + private _chunkTimer: ReturnType | undefined; + // For keeping track of active root spans + private _activeRootSpanIds: Set; + private _rootSpanTimeouts: Map>; + // ID for Profiler session + private _profilerId: string | undefined; + private _isRunning: boolean; + private _sessionSampled: boolean; + + public constructor() { + this._client = undefined; + this._profiler = undefined; + this._chunkTimer = undefined; + this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map>(); + this._profilerId = undefined; + this._isRunning = false; + this._sessionSampled = false; + } + + /** + * Initialize the profiler with client and session sampling decision computed by the integration. + */ + public initialize(client: Client, sessionSampled: boolean): void { + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); + + DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + + this._client = client; + this._sessionSampled = sessionSampled; + + client.on('spanStart', span => { + if (!this._sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); + return; + } + if (span !== getRootSpan(span)) { + return; + } + // Only count sampled root spans + if (!span.isRecording()) { + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); + return; + } + + // Matching root spans with profiles + getGlobalScope().setContext('profile', { + profiler_id: this._profilerId, + }); + + const spanId = span.spanContext().spanId; + if (!spanId) { + return; + } + if (this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.add(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + const timeout = setTimeout(() => { + this._onRootSpanTimeout(spanId); + }, MAX_ROOT_SPAN_PROFILE_MS); + this._rootSpanTimeouts.set(spanId, timeout); + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} started. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + + this.start(); + } + }); + + client.on('spanEnd', span => { + if (!this._sessionSampled) { + return; + } + + const spanId = span.spanContext().spanId; + if (!spanId || !this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.delete(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + if (rootSpanCount === 0) { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `spanEnd`:', e); + }); + + this.stop(); + } + }); + } + + /** + * Handle an already-active root span at integration setup time. + */ + public notifyRootSpanActive(rootSpan: Span): void { + if (!this._sessionSampled) { + return; + } + + const spanId = rootSpan.spanContext().spanId; + if (!spanId || this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.add(spanId); + + const rootSpanCount = this._activeRootSpanIds.size; + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log('[Profiling] Detected already active root span during setup. Active root spans now:', rootSpanCount); + + this.start(); + } + } + + /** + * Start profiling if not already running. + */ + public start(): void { + if (this._isRunning) { + return; + } + this._isRunning = true; + + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + + this._startProfilerInstance(); + + if (!this._profiler) { + DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + + /** + * Stop profiling; final chunk will be collected and sent. + */ + public stop(): void { + if (!this._isRunning) { + return; + } + + this._isRunning = false; + if (this._chunkTimer) { + clearTimeout(this._chunkTimer); + this._chunkTimer = undefined; + } + + this._clearAllRootSpanTimeouts(); + + // Collect whatever was currently recording + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); + }); + } + + /** + * Resets profiling information from scope and resets running state + */ + private _resetProfilerInfo(): void { + this._isRunning = false; + getGlobalScope().setContext('profile', {}); + } + + /** + * Clear and reset all per-root-span timeouts. + */ + private _clearAllRootSpanTimeouts(): void { + this._rootSpanTimeouts.forEach(timeout => clearTimeout(timeout)); + this._rootSpanTimeouts.clear(); + } + + /** + * Start a profiler instance if needed. + */ + private _startProfilerInstance(): void { + if (this._profiler?.stopped === false) { + return; + } + const profiler = startJSSelfProfile(); + if (!profiler) { + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in trace lifecycle.'); + return; + } + this._profiler = profiler; + } + + /** + * Schedule the next 60s chunk while running. + * Each tick collects a chunk and restarts the profiler. + * A chunk should be closed when there are no active root spans anymore OR when the maximum chunk interval is reached. + */ + private _startPeriodicChunking(): void { + if (!this._isRunning) { + return; + } + + this._chunkTimer = setTimeout(() => { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e); + }); + + if (this._isRunning) { + this._startProfilerInstance(); + + if (!this._profiler) { + // If restart failed, stop scheduling further chunks and reset context. + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + }, CHUNK_INTERVAL_MS); + } + + /** + * Handle timeout for a specific root span ID to avoid indefinitely running profiler if `spanEnd` never fires. + * If this was the last active root span, collect the current chunk and stop profiling. + */ + private _onRootSpanTimeout(rootSpanId: string): void { + // If span already ended, ignore + if (!this._rootSpanTimeouts.has(rootSpanId)) { + return; + } + this._rootSpanTimeouts.delete(rootSpanId); + + if (!this._activeRootSpanIds.has(rootSpanId)) { + return; + } + + DEBUG_BUILD && + debug.log( + `[Profiling] Reached 5-minute timeout for root span ${rootSpanId}. You likely started a manual root span that never called \`.end()\`.`, + ); + + this._activeRootSpanIds.delete(rootSpanId); + + const rootSpanCount = this._activeRootSpanIds.size; + if (rootSpanCount === 0) { + this.stop(); + } + } + + /** + * Stop the current profiler, convert and send a profile chunk. + */ + private async _collectCurrentChunk(): Promise { + const prevProfiler = this._profiler; + this._profiler = undefined; + + if (!prevProfiler) { + return; + } + + try { + const profile = await prevProfiler.stop(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); + + // Validate chunk before sending + const validationReturn = validateProfileChunk(chunk); + if ('reason' in validationReturn) { + DEBUG_BUILD && + debug.log( + '[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):', + validationReturn.reason, + ); + return; + } + + this._sendProfileChunk(chunk); + + DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); + } catch (e) { + DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS Profiler for chunk:', e); + } + } + + /** + * Send a profile chunk as a standalone envelope. + */ + private _sendProfileChunk(chunk: ProfileChunk): void { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const client = this._client!; + + const sdkInfo = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata?.()); + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + + const envelope = createEnvelope( + { + event_id: uuid4(), + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }, + [[{ type: 'profile_chunk' }, chunk]], + ); + + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); + }); + } +} diff --git a/packages/browser/src/profiling/startProfileForSpan.ts b/packages/browser/src/profiling/startProfileForSpan.ts index b60a207abbce..6eaaa016d822 100644 --- a/packages/browser/src/profiling/startProfileForSpan.ts +++ b/packages/browser/src/profiling/startProfileForSpan.ts @@ -41,7 +41,7 @@ export function startProfileForSpan(span: Span): void { // event of an error or user mistake (calling span.finish multiple times), it is important that the behavior of onProfileHandler // is idempotent as we do not want any timings or profiles to be overridden by the last call to onProfileHandler. // After the original finish method is called, the event will be reported through the integration and delegated to transport. - const processedProfile: JSSelfProfile | null = null; + let processedProfile: JSSelfProfile | null = null; getCurrentScope().setContext('profile', { profile_id: profileId, @@ -90,6 +90,7 @@ export function startProfileForSpan(span: Span): void { return; } + processedProfile = profile; addProfileToGlobalCache(profileId, profile); }) .catch(error => { diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 66b202c8517f..ed794a40a98b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,5 +1,16 @@ /* eslint-disable max-lines */ -import type { DebugImage, Envelope, Event, EventEnvelope, Profile, Span, ThreadCpuProfile } from '@sentry/core'; +import type { + Client, + ContinuousThreadCpuProfile, + DebugImage, + Envelope, + Event, + EventEnvelope, + Profile, + ProfileChunk, + Span, + ThreadCpuProfile, +} from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, @@ -7,19 +18,24 @@ import { forEachEnvelopeItem, getClient, getDebugImagesForResources, + GLOBAL_OBJ, spanToJSON, timestampInSeconds, uuid4, } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor, JSSelfProfileStack } from './jsSelfProfiling'; const MS_TO_NS = 1e6; -// Use 0 as main thread id which is identical to threadId in node:worker_threads -// where main logs 0 and workers seem to log in increments of 1 -const THREAD_ID_STRING = String(0); -const THREAD_NAME = 'main'; + +// Checking if we are in Main or Worker thread: `self` (not `window`) is the `globalThis` in Web Workers and `importScripts` are only available in Web Workers +const isMainThread = 'window' in GLOBAL_OBJ && GLOBAL_OBJ.window === GLOBAL_OBJ && typeof importScripts === 'undefined'; + +// Setting ID to 0 as we cannot get an ID from Web Workers +export const PROFILER_THREAD_ID_STRING = String(0); +export const PROFILER_THREAD_NAME = isMainThread ? 'main' : 'worker'; // We force make this optional to be on the safe side... const navigator = WINDOW.navigator as typeof WINDOW.navigator | undefined; @@ -185,7 +201,7 @@ export function createProfilePayload( name: event.transaction || '', id: event.event_id || uuid4(), trace_id: traceId, - active_thread_id: THREAD_ID_STRING, + active_thread_id: PROFILER_THREAD_ID_STRING, relative_start_ns: '0', relative_end_ns: ((transactionEndMs - transactionStartMs) * 1e6).toFixed(0), }, @@ -195,6 +211,161 @@ export function createProfilePayload( return profile; } +/** + * Create a profile chunk envelope item + */ +export function createProfileChunkPayload( + jsSelfProfile: JSSelfProfile, + client: Client, + profilerId?: string, +): ProfileChunk { + // only == to catch null and undefined + if (jsSelfProfile == null) { + throw new TypeError( + `Cannot construct profiling event envelope without a valid profile. Got ${jsSelfProfile} instead.`, + ); + } + + const continuousProfile = convertToContinuousProfile(jsSelfProfile); + + const options = client.getOptions(); + const sdk = client.getSdkMetadata?.()?.sdk; + + return { + chunk_id: uuid4(), + client_sdk: { + name: sdk?.name ?? 'sentry.javascript.browser', + version: sdk?.version ?? '0.0.0', + }, + profiler_id: profilerId || uuid4(), + platform: 'javascript', + version: '2', + release: options.release ?? '', + environment: options.environment ?? 'production', + debug_meta: { + // function name obfuscation + images: applyDebugMetadata(jsSelfProfile.resources), + }, + profile: continuousProfile, + }; +} + +/** + * Validate a profile chunk against the Sample Format V2 requirements. + * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + * - Presence of samples, stacks, frames + * - Required metadata fields + */ +export function validateProfileChunk(chunk: ProfileChunk): { valid: true } | { reason: string } { + try { + // Required metadata + if (!chunk || typeof chunk !== 'object') { + return { reason: 'chunk is not an object' }; + } + + // profiler_id and chunk_id must be 32 lowercase hex chars + const isHex32 = (val: unknown): boolean => typeof val === 'string' && /^[a-f0-9]{32}$/.test(val); + if (!isHex32(chunk.profiler_id)) { + return { reason: 'missing or invalid profiler_id' }; + } + if (!isHex32(chunk.chunk_id)) { + return { reason: 'missing or invalid chunk_id' }; + } + + if (!chunk.client_sdk) { + return { reason: 'missing client_sdk metadata' }; + } + + // Profile data must have frames, stacks, samples + const profile = chunk.profile as { frames?: unknown[]; stacks?: unknown[]; samples?: unknown[] } | undefined; + if (!profile) { + return { reason: 'missing profile data' }; + } + + if (!Array.isArray(profile.frames) || !profile.frames.length) { + return { reason: 'profile has no frames' }; + } + if (!Array.isArray(profile.stacks) || !profile.stacks.length) { + return { reason: 'profile has no stacks' }; + } + if (!Array.isArray(profile.samples) || !profile.samples.length) { + return { reason: 'profile has no samples' }; + } + + return { valid: true }; + } catch (e) { + return { reason: `unknown validation error: ${e}` }; + } +} + +/** + * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. + */ +function convertToContinuousProfile(input: { + frames: { name: string; resourceId?: number; line?: number; column?: number }[]; + stacks: { frameId: number; parentId?: number }[]; + samples: { timestamp: number; stackId?: number }[]; + resources: string[]; +}): ContinuousThreadCpuProfile { + // Frames map 1:1 by index; fill only when present to avoid sparse writes + const frames: ContinuousThreadCpuProfile['frames'] = []; + for (let i = 0; i < input.frames.length; i++) { + const frame = input.frames[i]; + if (!frame) { + continue; + } + frames[i] = { + function: frame.name, + abs_path: typeof frame.resourceId === 'number' ? input.resources[frame.resourceId] : undefined, + lineno: frame.line, + colno: frame.column, + }; + } + + // Build stacks by following parent links, top->down order (root last) + const stacks: ContinuousThreadCpuProfile['stacks'] = []; + for (let i = 0; i < input.stacks.length; i++) { + const stackHead = input.stacks[i]; + if (!stackHead) { + continue; + } + const list: number[] = []; + let current: { frameId: number; parentId?: number } | undefined = stackHead; + while (current) { + list.push(current.frameId); + current = current.parentId === undefined ? undefined : input.stacks[current.parentId]; + } + stacks[i] = list; + } + + // Align timestamps to SDK time origin to match span/event timelines + const perfOrigin = browserPerformanceTimeOrigin(); + const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0; + const adjustForOriginChange = origin - (perfOrigin || origin); + + const samples: ContinuousThreadCpuProfile['samples'] = []; + for (let i = 0; i < input.samples.length; i++) { + const sample = input.samples[i]; + if (!sample) { + continue; + } + // Convert ms to seconds epoch-based timestamp + const timestampSeconds = (origin + (sample.timestamp - adjustForOriginChange)) / 1000; + samples[i] = { + stack_id: sample.stackId ?? 0, + thread_id: PROFILER_THREAD_ID_STRING, + timestamp: timestampSeconds, + }; + } + + return { + frames, + stacks, + samples, + thread_metadata: { [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME } }, + }; +} + /** * */ @@ -226,7 +397,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi stacks: [], frames: [], thread_metadata: { - [THREAD_ID_STRING]: { name: THREAD_NAME }, + [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME }, }, }; @@ -258,7 +429,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: EMPTY_STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; return; } @@ -291,7 +462,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; profile['stacks'][STACK_ID] = stack; @@ -459,7 +630,7 @@ export function startJSSelfProfile(): JSSelfProfiler | undefined { /** * Determine if a profile should be profiled. */ -export function shouldProfileSpan(span: Span): boolean { +export function shouldProfileSpanLegacy(span: Span): boolean { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { @@ -469,9 +640,7 @@ export function shouldProfileSpan(span: Span): boolean { } if (!span.isRecording()) { - if (DEBUG_BUILD) { - debug.log('[Profiling] Discarding profile because transaction was not sampled.'); - } + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); return false; } @@ -518,6 +687,46 @@ export function shouldProfileSpan(span: Span): boolean { return true; } +/** + * Determine if a profile should be created for the current session (lifecycle profiling mode). + */ +export function shouldProfileSession(options: BrowserOptions): boolean { + // If constructor failed once, it will always fail, so we can early return. + if (PROFILING_CONSTRUCTOR_FAILED) { + if (DEBUG_BUILD) { + debug.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); + } + return false; + } + + if (options.profileLifecycle !== 'trace') { + return false; + } + + // Session sampling: profileSessionSampleRate gates whether profiling is enabled for this session + const profileSessionSampleRate = options.profileSessionSampleRate; + + if (!isValidSampleRate(profileSessionSampleRate)) { + DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid profileSessionSampleRate.'); + return false; + } + + if (!profileSessionSampleRate) { + DEBUG_BUILD && + debug.log('[Profiling] Discarding profile because profileSessionSampleRate is not defined or set to 0'); + return false; + } + + return Math.random() <= profileSessionSampleRate; +} + +/** + * Checks if legacy profiling is configured. + */ +export function hasLegacyProfiling(options: BrowserOptions): boolean { + return typeof options.profilesSampleRate !== 'undefined'; +} + /** * Creates a profiling envelope item, if the profile does not pass validation, returns null. * @param event @@ -564,7 +773,44 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { - const last: string = PROFILE_MAP.keys().next().value; - PROFILE_MAP.delete(last); + const last = PROFILE_MAP.keys().next().value; + if (last !== undefined) { + PROFILE_MAP.delete(last); + } } } + +/** + * Attaches the profiled thread information to the event's trace context. + */ +export function attachProfiledThreadToEvent(event: Event): Event { + if (!event?.contexts?.profile) { + return event; + } + + if (!event.contexts) { + return event; + } + + // @ts-expect-error the trace fallback value is wrong, though it should never happen + // and in case it does, we dont want to override whatever was passed initially. + event.contexts.trace = { + ...(event.contexts?.trace ?? {}), + data: { + ...(event.contexts?.trace?.data ?? {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }, + }; + + // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. + event.spans?.forEach(span => { + span.data = { + ...(span.data || {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }; + }); + + return event; +} diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a79f629855d7..2e3eebe86845 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -1,5 +1,13 @@ /* eslint-disable max-lines */ -import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core'; +import type { + Client, + IntegrationFn, + RequestHookInfo, + ResponseHookInfo, + Span, + StartSpanOptions, + TransactionSource, +} from '@sentry/core'; import { addNonEnumerableProperty, browserPerformanceTimeOrigin, @@ -297,7 +305,12 @@ export interface BrowserTracingOptions { * You can use it to annotate the span with additional data or attributes, for example by setting * attributes based on the passed request headers. */ - onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void; + onRequestSpanStart?(span: Span, requestInformation: RequestHookInfo): void; + + /** + * Is called when spans end for outgoing requests, providing access to response headers. + */ + onRequestSpanEnd?(span: Span, responseInformation: ResponseHookInfo): void; } const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { @@ -365,6 +378,7 @@ export const browserTracingIntegration = ((options: Partial(); @@ -125,6 +138,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { propagateTraceparent, + onRequestSpanEnd, }); if (handlerData.response && handlerData.fetchData.__span) { @@ -205,6 +220,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial boolean, spans: Record, propagateTraceparent?: boolean, + onRequestSpanEnd?: RequestInstrumentationOptions['onRequestSpanEnd'], ): Span | undefined { const xhr = handlerData.xhr; const sentryXhrData = xhr?.[SENTRY_XHR_DATA_KEY]; @@ -337,6 +341,11 @@ function xhrCallback( setHttpStatus(span, sentryXhrData.status_code); span.end(); + onRequestSpanEnd?.(span, { + headers: createHeadersSafely(parseXhrResponseHeaders(xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest)), + error: handlerData.error, + }); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -438,18 +447,3 @@ function setHeaderOnXhr( // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } - -function baggageHeaderHasSentryValues(baggageHeader: string): boolean { - return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); -} - -function getFullURL(url: string): string | undefined { - try { - // By adding a base URL to new URL(), this will also work for relative urls - // If `url` is a full URL, the base URL is ignored anyhow - const parsed = new URL(url, WINDOW.location.origin); - return parsed.href; - } catch { - return undefined; - } -} diff --git a/packages/browser/src/tracing/utils.ts b/packages/browser/src/tracing/utils.ts new file mode 100644 index 000000000000..c422e3438fd9 --- /dev/null +++ b/packages/browser/src/tracing/utils.ts @@ -0,0 +1,46 @@ +import { WINDOW } from '../helpers'; + +/** + * Checks if the baggage header has Sentry values. + */ +export function baggageHeaderHasSentryValues(baggageHeader: string): boolean { + return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); +} + +/** + * Gets the full URL from a given URL string. + */ +export function getFullURL(url: string): string | undefined { + try { + // By adding a base URL to new URL(), this will also work for relative urls + // If `url` is a full URL, the base URL is ignored anyhow + const parsed = new URL(url, WINDOW.location.origin); + return parsed.href; + } catch { + return undefined; + } +} + +/** + * Checks if the entry is a PerformanceResourceTiming. + */ +export function isPerformanceResourceTiming(entry: PerformanceEntry): entry is PerformanceResourceTiming { + return ( + entry.entryType === 'resource' && + 'initiatorType' in entry && + typeof (entry as PerformanceResourceTiming).nextHopProtocol === 'string' && + (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') + ); +} + +/** + * Creates a Headers object from a record of string key-value pairs, and returns undefined if it fails. + */ +export function createHeadersSafely(headers: Record | undefined): Headers | undefined { + try { + return new Headers(headers); + } catch { + // noop + return undefined; + } +} diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index 2af3cb662689..f9d97230701c 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -3,7 +3,9 @@ */ import * as Sentry from '@sentry/browser'; +import { debug } from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; +import type { BrowserClient } from '../../src/index'; import type { JSSelfProfile } from '../../src/profiling/jsSelfProfiling'; describe('BrowserProfilingIntegration', () => { @@ -65,4 +67,46 @@ describe('BrowserProfilingIntegration', () => { expect(profile_timestamp_ms).toBeGreaterThan(transaction_timestamp_ms); expect(profile.profile.frames[0]).toMatchObject({ function: 'pageload_fn', lineno: 1, colno: 1 }); }); + + it("warns when profileLifecycle is 'trace' but tracing is disabled", async () => { + debug.enable(); + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + + // @ts-expect-error mock constructor + window.Profiler = class { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return Promise.resolve({ frames: [], stacks: [], samples: [], resources: [] }); + } + }; + + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + // no tracesSampleRate and no tracesSampler → tracing disabled + profileLifecycle: 'trace', + profileSessionSampleRate: 1, + integrations: [Sentry.browserProfilingIntegration()], + }); + + expect( + warnSpy.mock.calls.some(call => + String(call?.[1] ?? call?.[0]).includes("`profileLifecycle` is 'trace' but tracing is disabled"), + ), + ).toBe(true); + + warnSpy.mockRestore(); + }); + + it("auto-sets profileLifecycle to 'manual' when not specified", async () => { + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + integrations: [Sentry.browserProfilingIntegration()], + }); + + const client = Sentry.getClient(); + const lifecycle = client?.getOptions()?.profileLifecycle; + expect(lifecycle).toBe('manual'); + }); }); diff --git a/packages/browser/test/profiling/traceLifecycleProfiler.test.ts b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts new file mode 100644 index 000000000000..f28880960256 --- /dev/null +++ b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts @@ -0,0 +1,631 @@ +/** + * @vitest-environment jsdom + */ + +import * as Sentry from '@sentry/browser'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('Browser Profiling v2 trace lifecycle', () => { + afterEach(async () => { + const client = Sentry.getClient(); + await client?.close(); + // reset profiler constructor + (window as any).Profiler = undefined; + vi.restoreAllMocks(); + }); + + function mockProfiler() { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + return new MockProfilerImpl(opts); + }); + + (window as any).Profiler = mockConstructor; + return { stop, mockConstructor }; + } + + it('does not start profiler when tracing is disabled (logs a warning)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + Sentry.init({ + // tracing disabled + dsn: 'https://public@o.ingest.sentry.io/1', + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + // no tracesSampleRate/tracesSampler + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + // warning is logged by our debug logger only when DEBUG_BUILD, so just assert no throw and no profiler + const client = Sentry.getClient(); + expect(client).toBeDefined(); + expect(stop).toHaveBeenCalledTimes(0); + expect(mockConstructor).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + describe('profiling lifecycle behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts on first sampled root span and sends a chunk on stop', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-1', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + // Ending the only root span should flush one chunk immediately + spanRef.end(); + + // Resolve any pending microtasks + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(2); // one for transaction, one for profile_chunk + const transactionEnvelopeHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const profileChunkEnvelopeHeader = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + expect(profileChunkEnvelopeHeader?.type).toBe('profile_chunk'); + expect(transactionEnvelopeHeader?.type).toBe('transaction'); + }); + + it('continues while any sampled root span is active; stops after last ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanA: any; + Sentry.startSpanManual({ name: 'root-A', parentSpan: null, forceTransaction: true }, span => { + spanA = span; + }); + + let spanB: any; + Sentry.startSpanManual({ name: 'root-B', parentSpan: null, forceTransaction: true }, span => { + spanB = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // End first root span -> still one active sampled root span; no send yet + spanA.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(0); + expect(send).toHaveBeenCalledTimes(1); // only transaction so far + const envelopeHeadersTxn = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeadersTxn?.type).toBe('transaction'); + + // End last root span -> should flush one chunk + spanB.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(3); + const envelopeHeadersTxn1 = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersTxn2 = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersProfile = send.mock.calls?.[2]?.[0]?.[1]?.[0]?.[0]; + + expect(envelopeHeadersTxn1?.type).toBe('transaction'); + expect(envelopeHeadersTxn2?.type).toBe('transaction'); + expect(envelopeHeadersProfile?.type).toBe('profile_chunk'); + }); + + it('sends a periodic chunk every 60s while running and restarts profiler', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger scheduled chunk collection + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted (second constructor call) + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(mockConstructor).toHaveBeenCalledTimes(2); + const envelopeHeaders = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeaders?.type).toBe('profile_chunk'); + + // Clean up + spanRef.end(); + await Promise.resolve(); + }); + + it('emits periodic chunks every 60s while span is stuck (no spanEnd)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger first periodic chunk while still running + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted for the next period + expect(stop.mock.calls.length).toBe(1); + expect(send.mock.calls.length).toBe(1); + expect(mockConstructor.mock.calls.length).toBe(2); + const firstChunkHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(firstChunkHeader?.type).toBe('profile_chunk'); + + // Second chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(2); + expect(send.mock.calls.length).toBe(2); + expect(mockConstructor.mock.calls.length).toBe(3); + + // Third chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(3); + expect(send.mock.calls.length).toBe(3); + expect(mockConstructor.mock.calls.length).toBe(4); + + spanRef.end(); + vi.advanceTimersByTime(100_000); + await Promise.resolve(); + + // All chunks should have been sent (4 total) + expect(stop.mock.calls.length).toBe(4); + expect(mockConstructor.mock.calls.length).toBe(4); // still 4 + expect(send.mock.calls.length).toBe(5); // 4 chunks + 1 transaction (tested below) + + const countProfileChunks = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'profile_chunk').length; + const countTransactions = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'transaction').length; + expect(countProfileChunks).toBe(4); + expect(countTransactions).toBe(1); + }); + + it('emits periodic chunks and stops after timeout if manual root span never ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Creates 2 profile chunks + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // At least two chunks emitted and profiler restarted in between + const stopsBeforeKill = stop.mock.calls.length; + const sendsBeforeKill = send.mock.calls.length; + const constructorCallsBeforeKill = mockConstructor.mock.calls.length; + expect(stopsBeforeKill).toBe(2); + expect(sendsBeforeKill).toBe(2); + expect(constructorCallsBeforeKill).toBe(3); + + // Advance to session kill switch (~5 minutes total since start) + vi.advanceTimersByTime(180_000); // now 300s total + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(constructorCallsBeforeKill + 2); // constructor was already called 3 times + expect(stopsAtKill).toBe(stopsBeforeKill + 3); + expect(sendsAtKill).toBe(sendsBeforeKill + 3); + + // No calls should happen after kill + vi.advanceTimersByTime(120_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(stopsAtKill); + expect(send.mock.calls.length).toBe(sendsAtKill); + expect(mockConstructor.mock.calls.length).toBe(constructorCallsAtKill); + }); + + it('continues profiling for another rootSpan after one rootSpan profile timed-out', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + vi.advanceTimersByTime(300_000); // 5 minutes (kill switch) + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(5); + expect(stopsAtKill).toBe(5); + expect(sendsAtKill).toBe(5); + + let spanRef: Span | undefined; + Sentry.startSpanManual({ name: 'root-manual-will-end', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + vi.advanceTimersByTime(119_000); // create 2 chunks + await Promise.resolve(); + + spanRef?.end(); + + expect(mockConstructor.mock.calls.length).toBe(sendsAtKill + 2); + expect(stop.mock.calls.length).toBe(constructorCallsAtKill + 2); + expect(send.mock.calls.length).toBe(stopsAtKill + 2); + }); + }); + + describe('profile context', () => { + it('sets global profile context on transaction', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + // Allow async tasks to resolve and flush queued envelopes + const client = Sentry.getClient(); + await client?.flush(1000); + + // Find the transaction envelope among sent envelopes + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + expect(txnCall).toBeDefined(); + + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: expect.objectContaining({ + ['thread.id']: expect.any(String), + ['thread.name']: expect.any(String), + }), + }, + profile: { + profiler_id: expect.any(String), + }, + }, + }); + }); + + it('reuses the same profiler_id across multiple root transactions within one session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toEqual(2); + + const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + const secondProfilerId = transactionEvents[1]?.contexts?.profile?.profiler_id; + + expect(typeof firstProfilerId).toBe('string'); + expect(typeof secondProfilerId).toBe('string'); + expect(firstProfilerId).toBe(secondProfilerId); + }); + + it('emits profile_chunk items with the same profiler_id as the transactions within a session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-chunk-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-chunk-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toBe(2); + const expectedProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + expect(typeof expectedProfilerId).toBe('string'); + + const profileChunks = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(profileChunks.length).toBe(2); + + for (const chunk of profileChunks) { + expect(chunk?.profiler_id).toBe(expectedProfilerId); + } + }); + + it('changes profiler_id when a new user session starts (new SDK init)', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + // Session 1 + const send1 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send1 }), + }); + + Sentry.startSpan({ name: 'session-1-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + let client = Sentry.getClient(); + await client?.flush(1000); + + // Extract first session profiler_id from transaction and a chunk + const calls1 = send1.mock.calls; + const txnEvt1 = calls1.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks1 = calls1 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId1 = txnEvt1?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId1).toBe('string'); + expect(chunks1.length).toBe(1); + for (const chunk of chunks1) { + expect(chunk?.profiler_id).toBe(profilerId1); + } + + // End Session 1 + await client?.close(); + + // Session 2 (new init simulates new user session) + const send2 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send2 }), + }); + + Sentry.startSpan({ name: 'session-2-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + client = Sentry.getClient(); + await client?.flush(1000); + + const calls2 = send2.mock.calls; + const txnEvt2 = calls2.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks2 = calls2 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId2 = txnEvt2?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId2).toBe('string'); + expect(profilerId2).not.toBe(profilerId1); + expect(chunks2.length).toBe(1); + for (const chunk of chunks2) { + expect(chunk?.profiler_id).toBe(profilerId2); + } + }); + }); +}); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 0f139a80ccd0..64467aad9d8f 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -18,6 +18,7 @@ import { isInstrumented, markAsInstrumented } from './instrument'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; type MethodWrapperOptions = { spanName?: string; @@ -192,8 +193,9 @@ export function instrumentDurableObjectWithSentry< C extends new (state: DurableObjectState, env: E) => T, >(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { - construct(target, [context, env]) { + construct(target, [ctx, env]) { setAsyncLocalStorageAsyncContextStrategy(); + const context = copyExecutionContext(ctx); const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 969cb6be72ee..e3e108b913d7 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -15,6 +15,7 @@ import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; /** * Wrapper for Cloudflare handlers. @@ -38,7 +39,9 @@ export function withSentry>) { - const [request, env, context] = args; + const [request, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; const options = getFinalOptions(optionsCallback(env), env); @@ -72,7 +75,10 @@ export function withSentry>) { - const [event, env, context] = args; + const [event, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; + return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); const waitUntil = context.waitUntil.bind(context); @@ -115,7 +121,10 @@ export function withSentry>) { - const [emailMessage, env, context] = args; + const [emailMessage, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; + return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); const waitUntil = context.waitUntil.bind(context); @@ -156,7 +165,9 @@ export function withSentry>) { - const [batch, env, context] = args; + const [batch, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); @@ -206,7 +217,9 @@ export function withSentry>) { - const [, env, context] = args; + const [, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; return withIsolationScope(async isolationScope => { const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/utils/copyExecutionContext.ts b/packages/cloudflare/src/utils/copyExecutionContext.ts new file mode 100644 index 000000000000..85a007f16e18 --- /dev/null +++ b/packages/cloudflare/src/utils/copyExecutionContext.ts @@ -0,0 +1,69 @@ +import { type DurableObjectState, type ExecutionContext } from '@cloudflare/workers-types'; + +type ContextType = ExecutionContext | DurableObjectState; +type OverridesStore = Map unknown>; + +/** + * Creates a new copy of the given execution context, optionally overriding methods. + * + * @param {ContextType|void} ctx - The execution context to be copied. Can be of type `ContextType` or `void`. + * @return {ContextType|void} A new execution context with the same properties and overridden methods if applicable. + */ +export function copyExecutionContext(ctx: T): T { + if (!ctx) return ctx; + + const overrides: OverridesStore = new Map(); + const contextPrototype = Object.getPrototypeOf(ctx); + const prototypeMethodNames = Object.getOwnPropertyNames(contextPrototype) as unknown as (keyof T)[]; + const ownPropertyNames = Object.getOwnPropertyNames(ctx) as unknown as (keyof T)[]; + const instrumented = new Set(['constructor']); + const descriptors = [...ownPropertyNames, ...prototypeMethodNames].reduce((prevDescriptors, methodName) => { + if (instrumented.has(methodName)) return prevDescriptors; + if (typeof ctx[methodName] !== 'function') return prevDescriptors; + instrumented.add(methodName); + const overridableDescriptor = makeOverridableDescriptor(overrides, ctx, methodName); + return { + ...prevDescriptors, + [methodName]: overridableDescriptor, + }; + }, {}); + + return Object.create(ctx, descriptors); +} + +/** + * Creates a property descriptor that allows overriding of a method on the given context object. + * + * This descriptor supports property overriding with functions only. It delegates method calls to + * the provided store if an override exists or to the original method on the context otherwise. + * + * @param {OverridesStore} store - The storage for overridden methods specific to the context type. + * @param {ContextType} ctx - The context object that contains the method to be overridden. + * @param {keyof ContextType} method - The method on the context object to create the overridable descriptor for. + * @return {PropertyDescriptor} A property descriptor enabling the overriding of the specified method. + */ +function makeOverridableDescriptor( + store: OverridesStore, + ctx: T, + method: keyof T, +): PropertyDescriptor { + return { + configurable: true, + enumerable: true, + set: newValue => { + if (typeof newValue == 'function') { + store.set(method, newValue); + return; + } + Reflect.set(ctx, method, newValue); + }, + + get: () => { + if (store.has(method)) return store.get(method); + const methodFunction = Reflect.get(ctx, method); + if (typeof methodFunction !== 'function') return methodFunction; + // We should do bind() to make sure that the method is bound to the context object - otherwise it will not work + return methodFunction.bind(ctx); + }, + }; +} diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 16327ea71ccf..17ec17e9cd85 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -22,6 +22,7 @@ import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; @@ -157,6 +158,9 @@ export function instrumentWorkflowWithSentry< return new Proxy(WorkFlowClass, { construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) { const [ctx, env] = args; + const context = copyExecutionContext(ctx); + args[0] = context; + const options = optionsCallback(env); const instance = Reflect.construct(target, args, newTarget) as T; return new Proxy(instance, { @@ -179,10 +183,10 @@ export function instrumentWorkflowWithSentry< return await obj.run.call( obj, event, - new WrappedWorkflowStep(event.instanceId, ctx, options, step), + new WrappedWorkflowStep(event.instanceId, context, options, step), ); } finally { - ctx.waitUntil(flush(2000)); + context.waitUntil(flush(2000)); } }); }); diff --git a/packages/cloudflare/test/copy-execution-context.test.ts b/packages/cloudflare/test/copy-execution-context.test.ts new file mode 100644 index 000000000000..3ee71a10b695 --- /dev/null +++ b/packages/cloudflare/test/copy-execution-context.test.ts @@ -0,0 +1,56 @@ +import { type Mocked, describe, expect, it, vi } from 'vitest'; +import { copyExecutionContext } from '../src/utils/copyExecutionContext'; + +describe('Copy of the execution context', () => { + describe.for([ + 'waitUntil', + 'passThroughOnException', + 'acceptWebSocket', + 'blockConcurrencyWhile', + 'getWebSockets', + 'arbitraryMethod', + 'anythingElse', + ])('%s', method => { + it('Override without changing original', async () => { + const context = { + [method]: vi.fn(), + } as any; + const copy = copyExecutionContext(context); + copy[method] = vi.fn(); + expect(context[method]).not.toBe(copy[method]); + }); + + it('Overridden method was called', async () => { + const context = { + [method]: vi.fn(), + } as any; + const copy = copyExecutionContext(context); + const overridden = vi.fn(); + copy[method] = overridden; + copy[method](); + expect(overridden).toBeCalled(); + expect(context[method]).not.toBeCalled(); + }); + }); + + it('No side effects', async () => { + const context = makeExecutionContextMock(); + expect(() => copyExecutionContext(Object.freeze(context))).not.toThrow( + /Cannot define property \w+, object is not extensible/, + ); + }); + it('Respects symbols', async () => { + const s = Symbol('test'); + const context = makeExecutionContextMock(); + context[s] = {}; + const copy = copyExecutionContext(context); + expect(copy[s]).toBe(context[s]); + }); +}); + +function makeExecutionContextMock() { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + } as unknown as Mocked; +} diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 9ab62ec732da..b16672430a5d 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -4,6 +4,7 @@ import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint } from './types-hoist/breadcrumb'; import type { HandlerDataFetch } from './types-hoist/instrument'; +import type { ResponseHookInfo } from './types-hoist/request'; import type { Span, SpanAttributes, SpanOrigin } from './types-hoist/span'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils/baggage'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; @@ -24,6 +25,7 @@ type PolymorphicRequestHeaders = interface InstrumentFetchRequestOptions { spanOrigin?: SpanOrigin; propagateTraceparent?: boolean; + onRequestSpanEnd?: (span: Span, responseInformation: ResponseHookInfo) => void; } /** @@ -82,6 +84,8 @@ export function instrumentFetchRequest( if (span) { endSpan(span, handlerData); + _callOnRequestSpanEnd(span, handlerData, spanOriginOrOptions); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -141,6 +145,25 @@ export function instrumentFetchRequest( return span; } +/** + * Calls the onRequestSpanEnd callback if it is defined. + */ +export function _callOnRequestSpanEnd( + span: Span, + handlerData: HandlerDataFetch, + spanOriginOrOptions?: SpanOrigin | InstrumentFetchRequestOptions, +): void { + const onRequestSpanEnd = + typeof spanOriginOrOptions === 'object' && spanOriginOrOptions !== null + ? spanOriginOrOptions.onRequestSpanEnd + : undefined; + + onRequestSpanEnd?.(span, { + headers: handlerData.response?.headers, + error: handlerData.error, + }); +} + /** * Adds sentry-trace and baggage headers to the various forms of fetch headers. * exported only for testing purposes diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2377e2ce86b0..7a6c5c2e17d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -399,7 +399,13 @@ export type { SendFeedbackParams, UserFeedback, } from './types-hoist/feedback'; -export type { QueryParams, RequestEventData, SanitizedRequestData } from './types-hoist/request'; +export type { + QueryParams, + RequestEventData, + RequestHookInfo, + ResponseHookInfo, + SanitizedRequestData, +} from './types-hoist/request'; export type { Runtime } from './types-hoist/runtime'; export type { SdkInfo } from './types-hoist/sdkinfo'; export type { SdkMetadata } from './types-hoist/sdkmetadata'; diff --git a/packages/core/src/instrument/handlers.ts b/packages/core/src/instrument/handlers.ts index 86c5a90b7c52..74dbc9902348 100644 --- a/packages/core/src/instrument/handlers.ts +++ b/packages/core/src/instrument/handlers.ts @@ -21,7 +21,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; /** Add a handler function. */ export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { handlers[type] = handlers[type] || []; - (handlers[type] as InstrumentHandlerCallback[]).push(handler); + handlers[type].push(handler); } /** diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 1df40c6fd614..18bbd46af09c 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -18,9 +18,31 @@ export type BrowserClientReplayOptions = { }; export type BrowserClientProfilingOptions = { + // todo: add deprecation warning for profilesSampleRate: @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. /** * The sample rate for profiling * 1.0 will profile all transactions and 0 will profile none. */ profilesSampleRate?: number; + + /** + * Sets profiling session sample rate for the entire profiling session. + * + * A profiling session corresponds to a user session, meaning it is set once at integration initialization and + * persisted until the next page reload. This rate determines what percentage of user sessions will have profiling enabled. + * @default 0 + */ + profileSessionSampleRate?: number; + + /** + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). + * + * @default 'manual' + */ + profileLifecycle?: 'manual' | 'trace'; }; diff --git a/packages/core/src/types-hoist/request.ts b/packages/core/src/types-hoist/request.ts index 834249cdd24e..028acbe9f77e 100644 --- a/packages/core/src/types-hoist/request.ts +++ b/packages/core/src/types-hoist/request.ts @@ -1,3 +1,5 @@ +import type { WebFetchHeaders } from './webfetchapi'; + /** * Request data included in an event as sent to Sentry. */ @@ -24,3 +26,19 @@ export type SanitizedRequestData = { 'http.fragment'?: string; 'http.query'?: string; }; + +export interface RequestHookInfo { + headers?: WebFetchHeaders; +} + +export interface ResponseHookInfo { + /** + * Headers from the response. + */ + headers?: WebFetchHeaders; + + /** + * Error that may have occurred during the request. + */ + error?: unknown; +} diff --git a/packages/core/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts index 9124602644e4..d55851927cb6 100644 --- a/packages/core/src/utils/ai/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -129,6 +129,16 @@ export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; */ export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; +/** + * The number of cache write input tokens used + */ +export const GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE = 'gen_ai.usage.input_tokens.cache_write'; + +/** + * The number of cached input tokens that were used + */ +export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_tokens.cached'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= diff --git a/packages/core/src/utils/debug-ids.ts b/packages/core/src/utils/debug-ids.ts index 97f30bbe816a..fd31009ae32d 100644 --- a/packages/core/src/utils/debug-ids.ts +++ b/packages/core/src/utils/debug-ids.ts @@ -105,7 +105,7 @@ export function getDebugImagesForResources( images.push({ type: 'sourcemap', code_file: path, - debug_id: filenameDebugIdMap[path] as string, + debug_id: filenameDebugIdMap[path], }); } } diff --git a/packages/core/src/utils/lru.ts b/packages/core/src/utils/lru.ts index 2a3b7bfc8ac0..3158dff7d413 100644 --- a/packages/core/src/utils/lru.ts +++ b/packages/core/src/utils/lru.ts @@ -27,7 +27,9 @@ export class LRUMap { public set(key: K, value: V): void { if (this._cache.size >= this._maxSize) { // keys() returns an iterator in insertion order so keys().next() gives us the oldest key - this._cache.delete(this._cache.keys().next().value); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nextKey = this._cache.keys().next().value!; + this._cache.delete(nextKey); } this._cache.set(key, value); } diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 607eff129fe5..69cd217345b8 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -7,7 +7,6 @@ import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; interface CryptoInternal { - getRandomValues(array: Uint8Array): Uint8Array; randomUUID?(): string; } @@ -22,37 +21,34 @@ function getCrypto(): CryptoInternal | undefined { return gbl.crypto || gbl.msCrypto; } +let emptyUuid: string | undefined; + +function getRandomByte(): number { + return Math.random() * 16; +} + /** * UUID4 generator * @param crypto Object that provides the crypto API. * @returns string Generated UUID4. */ export function uuid4(crypto = getCrypto()): string { - let getRandomByte = (): number => Math.random() * 16; try { if (crypto?.randomUUID) { return crypto.randomUUID().replace(/-/g, ''); } - if (crypto?.getRandomValues) { - getRandomByte = () => { - // crypto.getRandomValues might return undefined instead of the typed array - // in old Chromium versions (e.g. 23.0.1235.0 (151422)) - // However, `typedArray` is still filled in-place. - // @see https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#typedarray - const typedArray = new Uint8Array(1); - crypto.getRandomValues(typedArray); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return typedArray[0]!; - }; - } } catch { // some runtimes can crash invoking crypto // https://github.com/getsentry/sentry-javascript/issues/8935 } - // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 - // Concatenating the following numbers as strings results in '10000000100040008000100000000000' - return (([1e7] as unknown as string) + 1e3 + 4e3 + 8e3 + 1e11).replace(/[018]/g, c => + if (!emptyUuid) { + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 + // Concatenating the following numbers as strings results in '10000000100040008000100000000000' + emptyUuid = ([1e7] as unknown as string) + 1e3 + 4e3 + 8e3 + 1e11; + } + + return emptyUuid.replace(/[018]/g, c => // eslint-disable-next-line no-bitwise ((c as unknown as number) ^ ((getRandomByte() & 15) >> ((c as unknown as number) / 4))).toString(16), ); diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 9b1cc2bc8aae..8f353e88d394 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -2,6 +2,10 @@ import type { Client } from '../../client'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { Event } from '../../types-hoist/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, SpanOrigin } from '../../types-hoist/span'; +import { + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -23,6 +27,7 @@ import { AI_TOOL_CALL_ID_ATTRIBUTE, AI_TOOL_CALL_NAME_ATTRIBUTE, AI_TOOL_CALL_RESULT_ATTRIBUTE, + AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, @@ -107,6 +112,7 @@ function processEndedVercelAiSpan(span: SpanJSON): void { renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); + renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE); if ( typeof attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] === 'number' && @@ -287,7 +293,7 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadataObject.openai) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.openai.cachedPromptTokens, ); setAttributeIfDefined( @@ -309,27 +315,26 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { } if (providerMetadataObject.anthropic) { - setAttributeIfDefined( - attributes, - 'gen_ai.usage.input_tokens.cached', - providerMetadataObject.anthropic.cacheReadInputTokens, - ); - setAttributeIfDefined( - attributes, - 'gen_ai.usage.input_tokens.cache_write', - providerMetadataObject.anthropic.cacheCreationInputTokens, - ); + const cachedInputTokens = + providerMetadataObject.anthropic.usage?.cache_read_input_tokens ?? + providerMetadataObject.anthropic.cacheReadInputTokens; + setAttributeIfDefined(attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, cachedInputTokens); + + const cacheWriteInputTokens = + providerMetadataObject.anthropic.usage?.cache_creation_input_tokens ?? + providerMetadataObject.anthropic.cacheCreationInputTokens; + setAttributeIfDefined(attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, cacheWriteInputTokens); } if (providerMetadataObject.bedrock?.usage) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.bedrock.usage.cacheReadInputTokens, ); setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cache_write', + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, providerMetadataObject.bedrock.usage.cacheWriteInputTokens, ); } @@ -337,7 +342,7 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadataObject.deepseek) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.deepseek.promptCacheHitTokens, ); setAttributeIfDefined( diff --git a/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts b/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts index ac6774b08a02..95052fc1265a 100644 --- a/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts +++ b/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts @@ -288,6 +288,14 @@ export const AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE = 'ai.response.providerMeta */ export const AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'ai.settings.maxRetries'; +/** + * Basic LLM span information + * Multiple spans + * + * The number of cached input tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE = 'ai.usage.cachedInputTokens'; /** * Basic LLM span information * Multiple spans @@ -863,6 +871,21 @@ interface AnthropicProviderMetadata { * @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control */ cacheReadInputTokens?: number; + + /** + * Usage metrics for the Anthropic model. + */ + usage?: { + input_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + cache_creation?: { + ephemeral_5m_input_tokens?: number; + ephemeral_1h_input_tokens?: number; + }; + output_tokens?: number; + service_tier?: string; + }; } /** diff --git a/packages/core/test/lib/utils/misc.test.ts b/packages/core/test/lib/utils/misc.test.ts index 83e7f4c05b66..885e2dc64b8d 100644 --- a/packages/core/test/lib/utils/misc.test.ts +++ b/packages/core/test/lib/utils/misc.test.ts @@ -292,28 +292,21 @@ describe('checkOrSetAlreadyCaught()', () => { describe('uuid4 generation', () => { const uuid4Regex = /^[0-9A-F]{12}[4][0-9A-F]{3}[89AB][0-9A-F]{15}$/i; - it('returns valid uuid v4 ids via Math.random', () => { + it('returns valid and unique uuid v4 ids via Math.random', () => { + const uuids = new Set(); for (let index = 0; index < 1_000; index++) { - expect(uuid4()).toMatch(uuid4Regex); - } - }); - - it('returns valid uuid v4 ids via crypto.getRandomValues', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const cryptoMod = require('crypto'); - - const crypto = { getRandomValues: cryptoMod.getRandomValues }; - - for (let index = 0; index < 1_000; index++) { - expect(uuid4(crypto)).toMatch(uuid4Regex); + const id = uuid4(); + expect(id).toMatch(uuid4Regex); + uuids.add(id); } + expect(uuids.size).toBe(1_000); }); it('returns valid uuid v4 ids via crypto.randomUUID', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const cryptoMod = require('crypto'); - const crypto = { getRandomValues: cryptoMod.getRandomValues, randomUUID: cryptoMod.randomUUID }; + const crypto = { randomUUID: cryptoMod.randomUUID }; for (let index = 0; index < 1_000; index++) { expect(uuid4(crypto)).toMatch(uuid4Regex); @@ -321,7 +314,7 @@ describe('uuid4 generation', () => { }); it("return valid uuid v4 even if crypto doesn't exists", () => { - const crypto = { getRandomValues: undefined, randomUUID: undefined }; + const crypto = { randomUUID: undefined }; for (let index = 0; index < 1_000; index++) { expect(uuid4(crypto)).toMatch(uuid4Regex); @@ -330,9 +323,6 @@ describe('uuid4 generation', () => { it('return valid uuid v4 even if crypto invoked causes an error', () => { const crypto = { - getRandomValues: () => { - throw new Error('yo'); - }, randomUUID: () => { throw new Error('yo'); }, @@ -342,25 +332,4 @@ describe('uuid4 generation', () => { expect(uuid4(crypto)).toMatch(uuid4Regex); } }); - - // Corner case related to crypto.getRandomValues being only - // semi-implemented (e.g. Chromium 23.0.1235.0 (151422)) - it('returns valid uuid v4 even if crypto.getRandomValues does not return a typed array', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const cryptoMod = require('crypto'); - - const getRandomValues = (typedArray: Uint8Array) => { - if (cryptoMod.getRandomValues) { - cryptoMod.getRandomValues(typedArray); - } - }; - - const crypto = { getRandomValues }; - - for (let index = 0; index < 1_000; index++) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - we are testing a corner case - expect(uuid4(crypto)).toMatch(uuid4Regex); - } - }); }); diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index c20d71614234..d13097435f41 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -37,6 +37,16 @@ function getRouteSpecificity(routePath: string): number { // Static segments add 0 to score as they are most specific } + if (segments.length > 0) { + // Add a small penalty based on inverse of segment count + // This ensures that routes with more segments are preferred + // e.g., '/:locale/foo' is more specific than '/:locale' + // We use a small value (1 / segments.length) so it doesn't override the main scoring + // but breaks ties between routes with the same number of dynamic segments + const segmentCountPenalty = 1 / segments.length; + score += segmentCountPenalty; + } + return score; } @@ -134,6 +144,24 @@ function findMatchingRoutes( } } + // Try matching with optional prefix segments (for i18n routing patterns) + // This handles cases like '/foo' matching '/:locale/foo' when using next-intl with localePrefix: "as-needed" + // We do this regardless of whether we found direct matches, as we want the most specific match + if (!route.startsWith('/:')) { + for (const dynamicRoute of dynamicRoutes) { + if (dynamicRoute.hasOptionalPrefix && dynamicRoute.regex) { + // Prepend a placeholder segment to simulate the optional prefix + // e.g., '/foo' becomes '/PLACEHOLDER/foo' to match '/:locale/foo' + // Special case: '/' becomes '/PLACEHOLDER' (not '/PLACEHOLDER/') to match '/:locale' pattern + const routeWithPrefix = route === '/' ? '/SENTRY_OPTIONAL_PREFIX' : `/SENTRY_OPTIONAL_PREFIX${route}`; + const regex = getCompiledRegex(dynamicRoute.regex); + if (regex?.test(routeWithPrefix)) { + matches.push(dynamicRoute.path); + } + } + } + } + return matches; } diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 07694d659e57..ba4f7a852d45 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -64,7 +64,7 @@ export function wrapMiddlewareWithSentry( isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); - spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; + spanName = `middleware ${req.method}`; spanSource = 'url'; } else { spanName = 'middleware'; diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 32e7db61b57b..5e2a99f66285 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -47,7 +47,11 @@ function getDynamicRouteSegment(name: string): string { return `:${name.slice(1, -1)}`; } -function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } { +function buildRegexForDynamicRoute(routePath: string): { + regex: string; + paramNames: string[]; + hasOptionalPrefix: boolean; +} { const segments = routePath.split('/').filter(Boolean); const regexSegments: string[] = []; const paramNames: string[] = []; @@ -95,7 +99,20 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam pattern = `^/${regexSegments.join('/')}$`; } - return { regex: pattern, paramNames }; + return { regex: pattern, paramNames, hasOptionalPrefix: hasOptionalPrefix(paramNames) }; +} + +/** + * Detect if the first parameter is a common i18n prefix segment + * Common patterns: locale, lang, language + */ +function hasOptionalPrefix(paramNames: string[]): boolean { + const firstParam = paramNames[0]; + if (firstParam === undefined) { + return false; + } + + return firstParam === 'locale' || firstParam === 'lang' || firstParam === 'language'; } function scanAppDirectory( @@ -116,11 +133,12 @@ function scanAppDirectory( const isDynamic = routePath.includes(':'); if (isDynamic) { - const { regex, paramNames } = buildRegexForDynamicRoute(routePath); + const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath); dynamicRoutes.push({ path: routePath, regex, paramNames, + hasOptionalPrefix, }); } else { staticRoutes.push({ diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index e3a26adfce2f..0a0946be70f7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -14,6 +14,11 @@ export type RouteInfo = { * (Optional) The names of dynamic parameters in the route */ paramNames?: string[]; + /** + * (Optional) Indicates if the first segment is an optional prefix (e.g., for i18n routing) + * When true, routes like '/foo' should match '/:locale/foo' patterns + */ + hasOptionalPrefix?: boolean; }; /** diff --git a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts index 6d44af1275b5..236f4eff3999 100644 --- a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts @@ -15,6 +15,7 @@ type NextApiModule = // ESM export default?: EdgeRouteHandler; middleware?: EdgeRouteHandler; + proxy?: EdgeRouteHandler; } // CJS export | EdgeRouteHandler; @@ -29,6 +30,9 @@ let userProvidedDefaultHandler: EdgeRouteHandler | undefined = undefined; if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') { // Handle when user defines via named ESM export: `export { middleware };` userProvidedNamedHandler = userApiModule.middleware; +} else if ('proxy' in userApiModule && typeof userApiModule.proxy === 'function') { + // Handle when user defines via named ESM export (Next.js 16): `export { proxy };` + userProvidedNamedHandler = userApiModule.proxy; } else if ('default' in userApiModule && typeof userApiModule.default === 'function') { // Handle when user defines via ESM export: `export default myFunction;` userProvidedDefaultHandler = userApiModule.default; @@ -40,6 +44,7 @@ if ('middleware' in userApiModule && typeof userApiModule.middleware === 'functi export const middleware = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; +export const proxy = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; export default userProvidedDefaultHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedDefaultHandler) : undefined; // Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 8d2d7781230b..0d4a55687d2f 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -109,66 +109,75 @@ export function supportsNativeDebugIds(version: string): boolean { } /** - * Checks if the current Next.js version uses Turbopack as the default bundler. - * Starting from Next.js 15.6.0-canary.38, turbopack became the default for `next build`. + * Checks if the given Next.js version requires the `experimental.instrumentationHook` option. + * Next.js 15.0.0 and higher (including certain RC and canary versions) no longer require this option + * and will print a warning if it is set. * - * @param version - Next.js version string to check. - * @returns true if the version uses Turbopack by default + * @param version - version string to check. + * @returns true if the version requires the instrumentationHook option to be set */ -export function isTurbopackDefaultForVersion(version: string): boolean { +export function requiresInstrumentationHook(version: string): boolean { if (!version) { - return false; + return true; // Default to requiring it if version cannot be determined } - const { major, minor, prerelease } = parseSemver(version); + const { major, minor, patch, prerelease } = parseSemver(version); - if (major === undefined || minor === undefined) { - return false; + if (major === undefined || minor === undefined || patch === undefined) { + return true; // Default to requiring it if parsing fails } - // Next.js 16+ uses turbopack by default + // Next.js 16+ never requires the hook if (major >= 16) { + return false; + } + + // Next.js 14 and below always require the hook + if (major < 15) { return true; } - // For Next.js 15, only canary versions 15.6.0-canary.40+ use turbopack by default - // Stable 15.x releases still use webpack by default - if (major === 15 && minor >= 6 && prerelease && prerelease.startsWith('canary.')) { - if (minor >= 7) { - return true; - } + // At this point, we know it's Next.js 15.x.y + // Stable releases (15.0.0+) don't require the hook + if (!prerelease) { + return false; + } + + // Next.js 15.x.y with x > 0 or y > 0 don't require the hook + if (minor > 0 || patch > 0) { + return false; + } + + // Check specific prerelease versions that don't require the hook + if (prerelease.startsWith('rc.')) { + const rcNumber = parseInt(prerelease.split('.')[1] || '0', 10); + return rcNumber === 0; // Only rc.0 requires the hook + } + + if (prerelease.startsWith('canary.')) { const canaryNumber = parseInt(prerelease.split('.')[1] || '0', 10); - if (canaryNumber >= 40) { - return true; - } + return canaryNumber < 124; // canary.124+ doesn't require the hook } - return false; + // All other 15.0.0 prerelease versions (alpha, beta, etc.) require the hook + return true; } /** * Determines which bundler is actually being used based on environment variables, - * CLI flags, and Next.js version. + * and CLI flags. * - * @param nextJsVersion - The Next.js version string - * @returns 'turbopack', 'webpack', or undefined if it cannot be determined + * @returns 'turbopack' or 'webpack' */ -export function detectActiveBundler(nextJsVersion: string | undefined): 'turbopack' | 'webpack' | undefined { - if (process.env.TURBOPACK || process.argv.includes('--turbo')) { - return 'turbopack'; - } +export function detectActiveBundler(): 'turbopack' | 'webpack' { + const turbopackEnv = process.env.TURBOPACK; - // Explicit opt-in to webpack via --webpack flag - if (process.argv.includes('--webpack')) { - return 'webpack'; - } + // Check if TURBOPACK env var is set to a truthy value (excluding falsy strings like 'false', '0', '') + const isTurbopackEnabled = turbopackEnv && turbopackEnv !== 'false' && turbopackEnv !== '0'; - // Fallback to version-based default behavior - if (nextJsVersion) { - const turbopackIsDefault = isTurbopackDefaultForVersion(nextJsVersion); - return turbopackIsDefault ? 'turbopack' : 'webpack'; + if (isTurbopackEnabled || process.argv.includes('--turbo')) { + return 'turbopack'; + } else { + return 'webpack'; } - - // Unlikely but at this point, we just assume webpack for older behavior - return 'webpack'; } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 6ba07cd09f8f..14f064ae2b0a 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -183,8 +183,11 @@ export function constructWebpackConfigFunction({ ); }; - const possibleMiddlewareLocations = pageExtensions.map(middlewareFileEnding => { - return path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`); + const possibleMiddlewareLocations = pageExtensions.flatMap(middlewareFileEnding => { + return [ + path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`), + path.join(middlewareLocationFolder, `proxy.${middlewareFileEnding}`), + ]; }); const isMiddlewareResource = (resourcePath: string): boolean => { const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); @@ -331,7 +334,7 @@ export function constructWebpackConfigFunction({ .map(extension => `global-error.${extension}`) .some( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - globalErrorFile => fs.existsSync(path.join(appDirPath!, globalErrorFile)), + globalErrorFile => fs.existsSync(path.join(appDirPath, globalErrorFile)), ); if ( diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 31ea63f17a9c..7ac61d73aa73 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -16,7 +16,12 @@ import type { SentryBuildOptions, TurbopackOptions, } from './types'; -import { detectActiveBundler, getNextjsVersion, supportsProductionCompileHook } from './util'; +import { + detectActiveBundler, + getNextjsVersion, + requiresInstrumentationHook, + supportsProductionCompileHook, +} from './util'; import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; @@ -178,47 +183,18 @@ function getFinalConfigObject( // From Next.js version (15.0.0-canary.124) onwards, Next.js does no longer require the `experimental.instrumentationHook` option and will // print a warning when it is set, so we need to conditionally provide it for lower versions. - if (nextJsVersion) { - const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); - const isFullySupportedRelease = - major !== undefined && - minor !== undefined && - patch !== undefined && - major >= 15 && - ((minor === 0 && patch === 0 && prerelease === undefined) || minor > 0 || patch > 0); - const isSupportedV15Rc = - major !== undefined && - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('rc.') && - parseInt(prerelease.split('.')[1] || '', 10) > 0; - const isSupportedCanary = - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('canary.') && - parseInt(prerelease.split('.')[1] || '', 10) >= 124; - - if (!isFullySupportedRelease && !isSupportedV15Rc && !isSupportedCanary) { - if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', - ); - } - incomingUserNextConfigObject.experimental = { - instrumentationHook: true, - ...incomingUserNextConfigObject.experimental, - }; + if (nextJsVersion && requiresInstrumentationHook(nextJsVersion)) { + if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', + ); } - } else { + incomingUserNextConfigObject.experimental = { + instrumentationHook: true, + ...incomingUserNextConfigObject.experimental, + }; + } else if (!nextJsVersion) { // If we cannot detect a Next.js version for whatever reason, the sensible default is to set the `experimental.instrumentationHook`, even though it may create a warning. if ( incomingUserNextConfigObject.experimental && @@ -261,7 +237,7 @@ function getFinalConfigObject( nextMajor = major; } - const activeBundler = detectActiveBundler(nextJsVersion); + const activeBundler = detectActiveBundler(); const isTurbopack = activeBundler === 'turbopack'; const isWebpack = activeBundler === 'webpack'; const isTurbopackSupported = supportsProductionCompileHook(nextJsVersion ?? ''); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6469e3c6a2c8..6ee523fe72dc 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,8 @@ +import { context } from '@opentelemetry/api'; import { applySdkMetadata, + getCapturedScopesOnSpan, + getCurrentScope, getGlobalScope, getIsolationScope, getRootSpan, @@ -8,10 +11,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setCapturedScopesOnSpan, spanToJSON, stripUrlQueryAndFragment, vercelWaitUntil, } from '@sentry/core'; +import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; @@ -73,6 +78,19 @@ export function init(options: VercelEdgeOptions = {}): void { if (spanAttributes?.['next.span_type'] === 'Middleware.execute') { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + + if (isRootSpan) { + // Fork isolation scope for middleware requests + const scopes = getCapturedScopesOnSpan(span); + const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); + const scope = scopes.scope || getCurrentScope(); + const currentScopesPointer = getScopesFromContext(context.active()); + if (currentScopesPointer) { + currentScopesPointer.isolationScope = isolationScope; + } + + setCapturedScopesOnSpan(span, scope, isolationScope); + } } if (isRootSpan) { @@ -93,7 +111,19 @@ export function init(options: VercelEdgeOptions = {}): void { event.contexts?.trace?.data?.['next.span_name'] ) { if (event.transaction) { - event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + // Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names. + // We want to remove the url from the name here. + const spanName = event.contexts.trace.data['next.span_name']; + + if (typeof spanName === 'string') { + const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + if (match) { + const normalizedName = `middleware ${match[1]}`; + event.transaction = normalizedName; + } else { + event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + } + } } } }); diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index e9f484e71827..e593596aa8c1 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -644,4 +644,293 @@ describe('maybeParameterizeRoute', () => { expect(maybeParameterizeRoute('/some/random/path')).toBe('/:catchall*'); }); }); + + describe('i18n routing with optional prefix', () => { + it('should match routes with optional locale prefix for default locale paths', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/bar', + regex: '^/([^/]+)/bar$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products', + regex: '^/([^/]+)/products$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Default locale paths (without prefix) should match parameterized routes + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/bar')).toBe('/:locale/bar'); + expect(maybeParameterizeRoute('/products')).toBe('/:locale/products'); + + // Non-default locale paths (with prefix) should also match + expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/ar/bar')).toBe('/:locale/bar'); + expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products'); + expect(maybeParameterizeRoute('/en/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products'); + }); + + it('should handle nested routes with optional locale prefix', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale/foo/:id', + regex: '^/([^/]+)/foo/([^/]+)$', + paramNames: ['locale', 'id'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products/:productId', + regex: '^/([^/]+)/products/([^/]+)$', + paramNames: ['locale', 'productId'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Default locale (no prefix) + expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/products/abc')).toBe('/:locale/products/:productId'); + + // Non-default locale (with prefix) + expect(maybeParameterizeRoute('/ar/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/ar/products/abc')).toBe('/:locale/products/:productId'); + expect(maybeParameterizeRoute('/en/foo/456')).toBe('/:locale/foo/:id'); + }); + + it('should prioritize direct matches over optional prefix matches', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/foo/:id', + regex: '^/foo/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Direct match should win + expect(maybeParameterizeRoute('/foo/123')).toBe('/foo/:id'); + + // Optional prefix match when direct match isn't available + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo'); + }); + + it('should handle lang and language parameters as optional prefixes', () => { + const manifestWithLang: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:lang/page', + regex: '^/([^/]+)/page$', + paramNames: ['lang'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLang); + expect(maybeParameterizeRoute('/page')).toBe('/:lang/page'); + expect(maybeParameterizeRoute('/en/page')).toBe('/:lang/page'); + + const manifestWithLanguage: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:language/page', + regex: '^/([^/]+)/page$', + paramNames: ['language'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLanguage); + expect(maybeParameterizeRoute('/page')).toBe('/:language/page'); + expect(maybeParameterizeRoute('/en/page')).toBe('/:language/page'); + }); + + it('should not apply optional prefix logic to non-i18n dynamic segments', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:userId/profile', + regex: '^/([^/]+)/profile$', + paramNames: ['userId'], + hasOptionalPrefix: false, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Should not match without the userId segment + expect(maybeParameterizeRoute('/profile')).toBeUndefined(); + + // Should match with the userId segment + expect(maybeParameterizeRoute('/123/profile')).toBe('/:userId/profile'); + }); + + it('should handle real-world next-intl scenario', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/hola', + regex: '^/([^/]+)/hola$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products', + regex: '^/([^/]+)/products$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Root should not be parameterized (it's a static route) + expect(maybeParameterizeRoute('/')).toBeUndefined(); + + // Default locale (English, no prefix) - this was the bug + expect(maybeParameterizeRoute('/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/products')).toBe('/:locale/products'); + + // Non-default locale (Arabic, with prefix) + expect(maybeParameterizeRoute('/ar')).toBe('/:locale'); + expect(maybeParameterizeRoute('/ar/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products'); + + // Other locales + expect(maybeParameterizeRoute('/en/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products'); + }); + + it('should prefer more specific routes over optional prefix matches', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo/:id', + regex: '^/([^/]+)/foo/([^/]+)$', + paramNames: ['locale', 'id'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // More specific route should win (specificity score) + expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/about')).toBe('/:locale'); + }); + + it('should handle deeply nested i18n routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale/users/:userId/posts/:postId/comments/:commentId', + regex: '^/([^/]+)/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$', + paramNames: ['locale', 'userId', 'postId', 'commentId'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Without locale prefix (default locale) + expect(maybeParameterizeRoute('/users/123/posts/456/comments/789')).toBe( + '/:locale/users/:userId/posts/:postId/comments/:commentId', + ); + + // With locale prefix + expect(maybeParameterizeRoute('/ar/users/123/posts/456/comments/789')).toBe( + '/:locale/users/:userId/posts/:postId/comments/:commentId', + ); + }); + + it('should handle root path with optional locale prefix', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/about', + regex: '^/([^/]+)/about$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Root path without locale prefix (default locale) + expect(maybeParameterizeRoute('/')).toBe('/:locale'); + + // Root path with locale prefix + expect(maybeParameterizeRoute('/en')).toBe('/:locale'); + expect(maybeParameterizeRoute('/ar')).toBe('/:locale'); + + // Nested routes still work + expect(maybeParameterizeRoute('/about')).toBe('/:locale/about'); + expect(maybeParameterizeRoute('/fr/about')).toBe('/:locale/about'); + }); + }); }); diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts index 1b290796acb3..a2c1551ae4d1 100644 --- a/packages/nextjs/test/config/loaders.test.ts +++ b/packages/nextjs/test/config/loaders.test.ts @@ -129,6 +129,27 @@ describe('webpack loaders', () => { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/middleware.tsx', expectedWrappingTargetKind: undefined, }, + // Next.js 16+ renamed middleware to proxy + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.js', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: './src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.tsx', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/proxy.tsx', + expectedWrappingTargetKind: undefined, + }, { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/testApiRoute.ts', expectedWrappingTargetKind: 'api-route', diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts index a1014b05c32c..097e3f603693 100644 --- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -16,6 +16,7 @@ describe('basePath', () => { path: '/my-app/users/:id', regex: '^/my-app/users/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index b7108b6f6f23..8d78f24a0986 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -13,6 +13,7 @@ describe('catchall', () => { path: '/:path*?', regex: '^/(.*)$', paramNames: ['path'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index b1c417970ba4..d259a1a38223 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -13,6 +13,7 @@ describe('catchall', () => { path: '/catchall/:path*?', regex: '^/catchall(?:/(.*))?$', paramNames: ['path'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index fdcae299d7cf..2ea4b4aca5d8 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -13,21 +13,25 @@ describe('dynamic', () => { path: '/dynamic/:id', regex: '^/dynamic/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, { path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, { path: '/users/:id/posts/:postId', regex: '^/users/([^/]+)/posts/([^/]+)$', paramNames: ['id', 'postId'], + hasOptionalPrefix: false, }, { path: '/users/:id/settings', regex: '^/users/([^/]+)/settings$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 36ac9077df7e..8e1fe463190e 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -23,6 +23,7 @@ describe('route-groups', () => { path: '/dashboard/:id', regex: '^/dashboard/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); @@ -55,6 +56,7 @@ describe('route-groups', () => { path: '/(dashboard)/dashboard/:id', regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 55fd13cf5dc4..7335139b5037 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -213,113 +213,84 @@ describe('util', () => { }); }); - describe('isTurbopackDefaultForVersion', () => { - describe('returns true for versions where turbopack is default', () => { + describe('requiresInstrumentationHook', () => { + describe('versions that do NOT require the hook (returns false)', () => { it.each([ - // Next.js 16+ stable versions - ['16.0.0', 'Next.js 16.0.0 stable'], - ['16.0.1', 'Next.js 16.0.1 stable'], - ['16.1.0', 'Next.js 16.1.0 stable'], - ['16.2.5', 'Next.js 16.2.5 stable'], - - // Next.js 16+ pre-release versions - ['16.0.0-rc.1', 'Next.js 16.0.0-rc.1'], - ['16.0.0-canary.1', 'Next.js 16.0.0-canary.1'], - ['16.1.0-beta.2', 'Next.js 16.1.0-beta.2'], - - // Next.js 17+ + // Fully supported releases (15.0.0 or higher) + ['15.0.0', 'Next.js 15.0.0'], + ['15.0.1', 'Next.js 15.0.1'], + ['15.1.0', 'Next.js 15.1.0'], + ['15.2.0', 'Next.js 15.2.0'], + ['16.0.0', 'Next.js 16.0.0'], ['17.0.0', 'Next.js 17.0.0'], - ['18.0.0', 'Next.js 18.0.0'], ['20.0.0', 'Next.js 20.0.0'], - // Next.js 15.6.0-canary.40+ (boundary case) - ['15.6.0-canary.40', 'Next.js 15.6.0-canary.40 (exact threshold)'], - ['15.6.0-canary.41', 'Next.js 15.6.0-canary.41'], - ['15.6.0-canary.42', 'Next.js 15.6.0-canary.42'], - ['15.6.0-canary.100', 'Next.js 15.6.0-canary.100'], - - // Next.js 15.7+ canary versions - ['15.7.0-canary.1', 'Next.js 15.7.0-canary.1'], - ['15.7.0-canary.50', 'Next.js 15.7.0-canary.50'], - ['15.8.0-canary.1', 'Next.js 15.8.0-canary.1'], - ['15.10.0-canary.1', 'Next.js 15.10.0-canary.1'], - ])('returns true for %s (%s)', version => { - expect(util.isTurbopackDefaultForVersion(version)).toBe(true); + // Supported v15.0.0-rc.1 or higher + ['15.0.0-rc.1', 'Next.js 15.0.0-rc.1'], + ['15.0.0-rc.2', 'Next.js 15.0.0-rc.2'], + ['15.0.0-rc.5', 'Next.js 15.0.0-rc.5'], + ['15.0.0-rc.100', 'Next.js 15.0.0-rc.100'], + + // Supported v15.0.0-canary.124 or higher + ['15.0.0-canary.124', 'Next.js 15.0.0-canary.124 (exact threshold)'], + ['15.0.0-canary.125', 'Next.js 15.0.0-canary.125'], + ['15.0.0-canary.130', 'Next.js 15.0.0-canary.130'], + ['15.0.0-canary.200', 'Next.js 15.0.0-canary.200'], + + // Next.js 16+ prerelease versions (all supported) + ['16.0.0-beta.0', 'Next.js 16.0.0-beta.0'], + ['16.0.0-beta.1', 'Next.js 16.0.0-beta.1'], + ['16.0.0-rc.0', 'Next.js 16.0.0-rc.0'], + ['16.0.0-rc.1', 'Next.js 16.0.0-rc.1'], + ['16.0.0-canary.1', 'Next.js 16.0.0-canary.1'], + ['16.0.0-alpha.1', 'Next.js 16.0.0-alpha.1'], + ['17.0.0-canary.1', 'Next.js 17.0.0-canary.1'], + ])('returns false for %s (%s)', version => { + expect(util.requiresInstrumentationHook(version)).toBe(false); }); }); - describe('returns false for versions where webpack is still default', () => { + describe('versions that DO require the hook (returns true)', () => { it.each([ - // Next.js 15.6.0-canary.39 and below - ['15.6.0-canary.39', 'Next.js 15.6.0-canary.39 (just below threshold)'], - ['15.6.0-canary.36', 'Next.js 15.6.0-canary.36'], - ['15.6.0-canary.38', 'Next.js 15.6.0-canary.38'], - ['15.6.0-canary.0', 'Next.js 15.6.0-canary.0'], - - // Next.js 15.6.x stable releases (NOT canary) - ['15.6.0', 'Next.js 15.6.0 stable'], - ['15.6.1', 'Next.js 15.6.1 stable'], - ['15.6.2', 'Next.js 15.6.2 stable'], - ['15.6.10', 'Next.js 15.6.10 stable'], - - // Next.js 15.6.x rc releases (NOT canary) - ['15.6.0-rc.1', 'Next.js 15.6.0-rc.1'], - ['15.6.0-rc.2', 'Next.js 15.6.0-rc.2'], - - // Next.js 15.7+ stable releases (NOT canary) - ['15.7.0', 'Next.js 15.7.0 stable'], - ['15.8.0', 'Next.js 15.8.0 stable'], - ['15.10.0', 'Next.js 15.10.0 stable'], - - // Next.js 15.5 and below (all versions) - ['15.5.0', 'Next.js 15.5.0'], - ['15.5.0-canary.100', 'Next.js 15.5.0-canary.100'], - ['15.4.1', 'Next.js 15.4.1'], - ['15.0.0', 'Next.js 15.0.0'], - ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], - - // Next.js 14.x and below + // Next.js 14 and below ['14.2.0', 'Next.js 14.2.0'], ['14.0.0', 'Next.js 14.0.0'], - ['14.0.0-canary.50', 'Next.js 14.0.0-canary.50'], ['13.5.0', 'Next.js 13.5.0'], - ['13.0.0', 'Next.js 13.0.0'], ['12.0.0', 'Next.js 12.0.0'], - ])('returns false for %s (%s)', version => { - expect(util.isTurbopackDefaultForVersion(version)).toBe(false); + + // Unsupported v15.0.0-rc.0 + ['15.0.0-rc.0', 'Next.js 15.0.0-rc.0'], + + // Unsupported v15.0.0-canary versions below 124 + ['15.0.0-canary.123', 'Next.js 15.0.0-canary.123'], + ['15.0.0-canary.100', 'Next.js 15.0.0-canary.100'], + ['15.0.0-canary.50', 'Next.js 15.0.0-canary.50'], + ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], + ['15.0.0-canary.0', 'Next.js 15.0.0-canary.0'], + + // Other prerelease versions + ['15.0.0-alpha.1', 'Next.js 15.0.0-alpha.1'], + ['15.0.0-beta.1', 'Next.js 15.0.0-beta.1'], + ])('returns true for %s (%s)', version => { + expect(util.requiresInstrumentationHook(version)).toBe(true); }); }); describe('edge cases', () => { - it.each([ - ['', 'empty string'], - ['invalid', 'invalid version string'], - ['15', 'missing minor and patch'], - ['15.6', 'missing patch'], - ['not.a.version', 'completely invalid'], - ['15.6.0-alpha.1', 'alpha prerelease (not canary)'], - ['15.6.0-beta.1', 'beta prerelease (not canary)'], - ])('returns false for %s (%s)', version => { - expect(util.isTurbopackDefaultForVersion(version)).toBe(false); + it('returns true for empty string', () => { + expect(util.requiresInstrumentationHook('')).toBe(true); }); - }); - describe('canary number parsing edge cases', () => { - it.each([ - ['15.6.0-canary.', 'canary with no number'], - ['15.6.0-canary.abc', 'canary with non-numeric value'], - ['15.6.0-canary.38.extra', 'canary with extra segments'], - ])('handles malformed canary versions: %s (%s)', version => { - // Should not throw, just return appropriate boolean - expect(() => util.isTurbopackDefaultForVersion(version)).not.toThrow(); + it('returns true for invalid version strings', () => { + expect(util.requiresInstrumentationHook('invalid.version')).toBe(true); }); - it('handles canary.40 exactly (boundary)', () => { - expect(util.isTurbopackDefaultForVersion('15.6.0-canary.40')).toBe(true); + it('returns true for versions missing patch number', () => { + expect(util.requiresInstrumentationHook('15.4')).toBe(true); }); - it('handles canary.39 exactly (boundary)', () => { - expect(util.isTurbopackDefaultForVersion('15.6.0-canary.39')).toBe(false); + it('returns true for versions missing minor number', () => { + expect(util.requiresInstrumentationHook('15')).toBe(true); }); }); }); @@ -341,52 +312,26 @@ describe('util', () => { it('returns turbopack when TURBOPACK env var is set', () => { process.env.TURBOPACK = '1'; - expect(util.detectActiveBundler('15.5.0')).toBe('turbopack'); - }); - - it('returns webpack when --webpack flag is present', () => { - process.argv.push('--webpack'); - expect(util.detectActiveBundler('16.0.0')).toBe('webpack'); + expect(util.detectActiveBundler()).toBe('turbopack'); }); - it('returns turbopack for Next.js 16+ by default', () => { - expect(util.detectActiveBundler('16.0.0')).toBe('turbopack'); - expect(util.detectActiveBundler('17.0.0')).toBe('turbopack'); + it('returns turbopack when TURBOPACK env var is set to auto', () => { + process.env.TURBOPACK = 'auto'; + expect(util.detectActiveBundler()).toBe('turbopack'); }); - it('returns turbopack for Next.js 15.6.0-canary.40+', () => { - expect(util.detectActiveBundler('15.6.0-canary.40')).toBe('turbopack'); - expect(util.detectActiveBundler('15.6.0-canary.50')).toBe('turbopack'); + it('returns webpack when TURBOPACK env var is undefined', () => { + process.env.TURBOPACK = undefined; + expect(util.detectActiveBundler()).toBe('webpack'); }); - it('returns webpack for Next.js 15.6.0 stable', () => { - expect(util.detectActiveBundler('15.6.0')).toBe('webpack'); + it('returns webpack when TURBOPACK env var is false', () => { + process.env.TURBOPACK = 'false'; + expect(util.detectActiveBundler()).toBe('webpack'); }); - it('returns webpack for Next.js 15.5.x and below', () => { - expect(util.detectActiveBundler('15.5.0')).toBe('webpack'); - expect(util.detectActiveBundler('15.0.0')).toBe('webpack'); - expect(util.detectActiveBundler('14.2.0')).toBe('webpack'); - }); - - it('returns webpack when version is undefined', () => { - expect(util.detectActiveBundler(undefined)).toBe('webpack'); - }); - - it('prioritizes TURBOPACK env var over version detection', () => { - process.env.TURBOPACK = '1'; - expect(util.detectActiveBundler('14.0.0')).toBe('turbopack'); - }); - - it('prioritizes --webpack flag over version detection', () => { - process.argv.push('--webpack'); - expect(util.detectActiveBundler('16.0.0')).toBe('webpack'); - }); - - it('prioritizes TURBOPACK env var over --webpack flag', () => { - process.env.TURBOPACK = '1'; - process.argv.push('--webpack'); - expect(util.detectActiveBundler('15.5.0')).toBe('turbopack'); + it('returns webpack when TURBOPACK env var is not set', () => { + expect(util.detectActiveBundler()).toBe('webpack'); }); }); }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index f1f46c6fc6f2..b67a05845a7e 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -269,7 +269,7 @@ describe('withSentryConfig', () => { }); }); - describe('bundler detection with version-based defaults', () => { + describe('bundler detection', () => { const originalTurbopack = process.env.TURBOPACK; const originalArgv = process.argv; @@ -284,192 +284,107 @@ describe('withSentryConfig', () => { process.argv = originalArgv; }); - describe('Next.js 16+ defaults to turbopack', () => { - it('uses turbopack config by default for Next.js 16.0.0', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses turbopack config by default for Next.js 17.0.0', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('17.0.0'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses webpack when --webpack flag is present on Next.js 16.0.0', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - process.argv.push('--webpack'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - - it('prioritizes TURBOPACK env var over --webpack flag', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - process.env.TURBOPACK = '1'; - process.argv.push('--webpack'); + it('uses webpack config by default when TURBOPACK env var is not set', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('Next.js 15.6.0-canary.40+ defaults to turbopack', () => { - it('uses turbopack config by default for 15.6.0-canary.40', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.40'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses turbopack config by default for 15.6.0-canary.50', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.50'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses turbopack config by default for 15.7.0-canary.1', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0-canary.1'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses webpack when --webpack flag is present on 15.6.0-canary.40', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.40'); - process.argv.push('--webpack'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - - it('uses webpack when --webpack flag is present on 15.7.0-canary.1', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0-canary.1'); - process.argv.push('--webpack'); + it('uses turbopack config when TURBOPACK env var is set (supported version)', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); }); - describe('Next.js 15.6.0-canary.37 and below defaults to webpack', () => { - it('uses webpack config by default for 15.6.0-canary.37', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.37'); + it('uses turbopack config when TURBOPACK env var is set (16.0.0)', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); - it('uses webpack config by default for 15.6.0-canary.1', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.1'); + it('skips webpack config when TURBOPACK env var is set, even with unsupported version', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + // turbopack config won't be added when version is unsupported, + // but webpack config should still be skipped + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + expect(finalConfig.turbopack).toBeUndefined(); + }); - it('uses turbopack when TURBOPACK env var is set on 15.6.0-canary.37', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.37'); - process.env.TURBOPACK = '1'; + it('defaults to webpack when Next.js version cannot be determined and no TURBOPACK env var', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('Next.js 15.6.x stable releases default to webpack', () => { - it('uses webpack config by default for 15.6.0 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + it('uses turbopack when TURBOPACK env var is set even when version is undefined', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); + process.env.TURBOPACK = '1'; - it('uses webpack config by default for 15.6.1 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.1'); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeUndefined(); + }); - it('uses webpack config by default for 15.7.0 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0'); + it('uses turbopack when TURBOPACK env var is truthy string', () => { + process.env.TURBOPACK = 'true'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); - it('uses turbopack when explicitly requested via env var on 15.6.0 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0'); - process.env.TURBOPACK = '1'; + it('uses webpack when TURBOPACK env var is empty string', () => { + process.env.TURBOPACK = ''; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('older Next.js versions default to webpack', () => { - it.each([['15.5.0'], ['15.0.0'], ['14.2.0'], ['13.5.0']])( - 'uses webpack config by default for Next.js %s', - version => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(version); + it('uses webpack when TURBOPACK env var is false string', () => { + process.env.TURBOPACK = 'false'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }, - ); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); - it.each([['15.5.0-canary.100'], ['15.0.0-canary.1'], ['14.2.0-canary.50']])( - 'uses webpack config by default for Next.js %s canary', - version => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(version); + it('handles malformed version strings gracefully', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('not.a.version'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }, - ); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('warnings are shown for unsupported turbopack usage', () => { + describe('warnings for unsupported turbopack usage', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { @@ -508,39 +423,6 @@ describe('withSentryConfig', () => { expect(consoleWarnSpy).not.toHaveBeenCalled(); }); }); - - describe('edge cases', () => { - it('defaults to webpack when Next.js version cannot be determined', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - - it('uses turbopack when TURBOPACK env var is set even when version is undefined', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); - process.env.TURBOPACK = '1'; - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - // Note: turbopack config won't be added when version is undefined because - // isTurbopackSupported will be false, but webpack config should still be skipped - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - // Turbopack config is only added when both isTurbopack AND isTurbopackSupported are true - expect(finalConfig.turbopack).toBeUndefined(); - }); - - it('handles malformed version strings gracefully', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('not.a.version'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - }); }); describe('turbopack sourcemap configuration', () => { @@ -1411,24 +1293,6 @@ describe('withSentryConfig', () => { consoleWarnSpy.mockRestore(); }); - - it('warns when TURBOPACK=0 (truthy string) with unsupported version', () => { - process.env.TURBOPACK = '0'; - // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing - process.env.NODE_ENV = 'development'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - materializeFinalNextConfig(exportedNextConfig); - - // Note: '0' is truthy in JavaScript, so this will trigger the warning - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), - ); - - consoleWarnSpy.mockRestore(); - }); }); describe('useRunAfterProductionCompileHook warning logic', () => { diff --git a/packages/nextjs/tsconfig.test.json b/packages/nextjs/tsconfig.test.json index 633c4212a0e9..be787654b1a0 100644 --- a/packages/nextjs/tsconfig.test.json +++ b/packages/nextjs/tsconfig.test.json @@ -9,6 +9,7 @@ // require for top-level await "module": "Node16", + "moduleResolution": "Node16", "target": "es2020", // other package-specific, test-specific options diff --git a/packages/node-core/src/integrations/anr/worker.ts b/packages/node-core/src/integrations/anr/worker.ts index 7c2ac91f30af..3ae9e009625c 100644 --- a/packages/node-core/src/integrations/anr/worker.ts +++ b/packages/node-core/src/integrations/anr/worker.ts @@ -110,7 +110,7 @@ function applyDebugMeta(event: Event): void { for (const frame of exception.stacktrace?.frames || []) { const filename = frame.abs_path || frame.filename; if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 6b78bcdb4386..dfc51d5022ff 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -1,5 +1,5 @@ import { tracingChannel } from 'node:diagnostics_channel'; -import type { IntegrationFn, LogSeverityLevel } from '@sentry/core'; +import type { Integration, IntegrationFn, LogSeverityLevel } from '@sentry/core'; import { _INTERNAL_captureLog, addExceptionMechanism, @@ -11,6 +11,8 @@ import { } from '@sentry/core'; import { addInstrumentationConfig } from '../sdk/injectLoader'; +const SENTRY_TRACK_SYMBOL = Symbol('sentry-track-pino-logger'); + type LevelMapping = { // Fortunately pino uses the same levels as Sentry labels: { [level: number]: LogSeverityLevel }; @@ -18,6 +20,7 @@ type LevelMapping = { type Pino = { levels: LevelMapping; + [SENTRY_TRACK_SYMBOL]?: 'track' | 'ignore'; }; type MergeObject = { @@ -28,6 +31,17 @@ type MergeObject = { type PinoHookArgs = [MergeObject, string, number]; type PinoOptions = { + /** + * Automatically instrument all Pino loggers. + * + * When set to `false`, only loggers marked with `pinoIntegration.trackLogger(logger)` will be captured. + * + * @default true + */ + autoInstrument: boolean; + /** + * Options to enable capturing of error events. + */ error: { /** * Levels that trigger capturing of events. @@ -43,6 +57,9 @@ type PinoOptions = { */ handled: boolean; }; + /** + * Options to enable capturing of logs. + */ log: { /** * Levels that trigger capturing of logs. Logs are only captured if @@ -55,6 +72,7 @@ type PinoOptions = { }; const DEFAULT_OPTIONS: PinoOptions = { + autoInstrument: true, error: { levels: [], handled: true }, log: { levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] }, }; @@ -63,18 +81,18 @@ type DeepPartial = { [P in keyof T]?: T[P] extends object ? Partial : T[P]; }; -/** - * Integration for Pino logging library. - * Captures Pino logs as Sentry logs and optionally captures some log levels as events. - * - * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 - */ -export const pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { +const _pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { const options: PinoOptions = { + autoInstrument: userOptions.autoInstrument === false ? userOptions.autoInstrument : DEFAULT_OPTIONS.autoInstrument, error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, }; + function shouldTrackLogger(logger: Pino): boolean { + const override = logger[SENTRY_TRACK_SYMBOL]; + return override === 'track' || (override !== 'ignore' && options.autoInstrument); + } + return { name: 'Pino', setup: client => { @@ -95,6 +113,10 @@ export const pinoIntegration = defineIntegration((userOptions: DeepPartial): Integration; + /** + * Marks a Pino logger to be tracked by the Pino integration. + * + * @param logger A Pino logger instance. + */ + trackLogger(logger: unknown): void; + /** + * Marks a Pino logger to be ignored by the Pino integration. + * + * @param logger A Pino logger instance. + */ + untrackLogger(logger: unknown): void; +} + +/** + * Integration for Pino logging library. + * Captures Pino logs as Sentry logs and optionally captures some log levels as events. + * + * By default, all Pino loggers will be captured. To ignore a specific logger, use `pinoIntegration.untrackLogger(logger)`. + * + * If you disable automatic instrumentation with `autoInstrument: false`, you can mark specific loggers to be tracked with `pinoIntegration.trackLogger(logger)`. + * + * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 + */ +export const pinoIntegration = Object.assign(_pinoIntegration, { + trackLogger(logger: unknown): void { + if (logger && typeof logger === 'object' && 'levels' in logger) { + (logger as Pino)[SENTRY_TRACK_SYMBOL] = 'track'; + } + }, + untrackLogger(logger: unknown): void { + if (logger && typeof logger === 'object' && 'levels' in logger) { + (logger as Pino)[SENTRY_TRACK_SYMBOL] = 'ignore'; + } + }, +}) as PinoIntegrationFunction; diff --git a/packages/node-core/test/helpers/mockSdkInit.ts b/packages/node-core/test/helpers/mockSdkInit.ts index 0ea8a93cb064..8d4cb28bfd66 100644 --- a/packages/node-core/test/helpers/mockSdkInit.ts +++ b/packages/node-core/test/helpers/mockSdkInit.ts @@ -149,7 +149,7 @@ export function getSpanProcessor(): SentrySpanProcessor | undefined { const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + ); return spanProcessor; } diff --git a/packages/node-core/tsconfig.json b/packages/node-core/tsconfig.json index 07c7602c1fdd..28abec410557 100644 --- a/packages/node-core/tsconfig.json +++ b/packages/node-core/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["ES2020", "ES2021.WeakRef"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index 26b9bb683930..492070a2d1dc 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -149,7 +149,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void for (const frame of exception.stacktrace?.frames || []) { const filename = stripFileProtocol(frame.abs_path || frame.filename); if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } @@ -158,7 +158,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void for (const frame of thread.stacktrace?.frames || []) { const filename = stripFileProtocol(frame.abs_path || frame.filename); if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3d3463d0b5cf..1f84b69a9f28 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -46,16 +46,19 @@ export interface BaseNodeOptions { profilesSampler?: (samplingContext: SamplingContext) => number | boolean; /** - * Sets profiling session sample rate - only evaluated once per SDK initialization. + * Sets profiling session sample rate for the entire profiling session (evaluated once per SDK initialization). + * * @default 0 */ profileSessionSampleRate?: number; /** - * Set the lifecycle of the profiler. - * - * - `manual`: The profiler will be manually started and stopped. - * - `trace`: The profiler will be automatically started when when a span is sampled and stopped when there are no more sampled spans. + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). * * @default 'manual' */ diff --git a/packages/node/test/helpers/mockSdkInit.ts b/packages/node/test/helpers/mockSdkInit.ts index dc4c3586d978..8f8be9e8af68 100644 --- a/packages/node/test/helpers/mockSdkInit.ts +++ b/packages/node/test/helpers/mockSdkInit.ts @@ -61,7 +61,7 @@ export function getSpanProcessor(): SentrySpanProcessor | undefined { const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + ); return spanProcessor; } diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index 64d6f3a1b9e0..d5f034ad1048 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 1e806e4dc2eb..3656eac56e63 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,8 +10,10 @@ import { consoleSandbox } from '@sentry/core'; import * as path from 'path'; import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; +import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; +import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -75,14 +77,16 @@ export default defineNuxtModule({ // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { - if (!options.tsConfig.include) { - options.tsConfig.include = []; + const tsConfig = options.tsConfig as { include?: string[] }; + + if (!tsConfig.include) { + tsConfig.include = []; } // Add type references for useRuntimeConfig in root files for nuxt v4 // Should be relative to `root/.nuxt` const relativePath = path.relative(nuxt.options.buildDir, clientConfigFile); - options.tsConfig.include.push(relativePath); + tsConfig.include.push(relativePath); }); } @@ -126,6 +130,8 @@ export default defineNuxtModule({ // Preps the the middleware instrumentation module. if (serverConfigFile) { addMiddlewareImports(); + addStorageInstrumentation(nuxt); + addDatabaseInstrumentation(nuxt.options.nitro); } nuxt.hooks.hook('nitro:init', nitro => { diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts new file mode 100644 index 000000000000..9cdff58d336e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -0,0 +1,232 @@ +import { + type Span, + type StartSpanOptions, + addBreadcrumb, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpan, +} from '@sentry/core'; +import type { Database, PreparedStatement } from 'db0'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +// @ts-expect-error - This is a virtual module +import { databaseConfig } from '#sentry/database-config.mjs'; +import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; + +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + +/** + * The Sentry origin for the database plugin. + */ +const SENTRY_ORIGIN = 'auto.db.nuxt'; + +/** + * Creates a Nitro plugin that instruments the database calls. + */ +export default defineNitroPlugin(() => { + try { + const _databaseConfig = databaseConfig as Record; + const databaseInstances = Object.keys(databaseConfig); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); + + for (const instance of databaseInstances) { + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db, _databaseConfig[instance]); + } + + debug.log('[Nitro Database Plugin]: Databases instrumented.'); + } catch (error) { + // During build time, we can't use the useDatabase function, so we just log an error. + if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); + return; + } + + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); + } +}); + +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); + }, + }); + + // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly + // So we have to patch it manually, and would mean we would have less info in the spans. + // https://github.com/unjs/db0/blob/main/src/database.ts#L64 + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, metadata); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], metadata), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); + + db.__sentry_instrumented__ = true; +} + +/** + * Instruments a DB prepared statement with Sentry. + * + * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` + * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. + */ +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.bind = new Proxy(statement.bind, { + apply(target, thisArg, args: Parameters) { + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, data); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + if (patchedStatement.has(statement)) { + return statement; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +/** + * Creates a span start callback handler + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: SENTRY_ORIGIN, + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }; +} + +function createBreadcrumb(query: string): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query.text': query, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + return { + name: query, + attributes: { + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + ...data, + }, + }; +} diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 5438ac829d8a..d45d45d0d4ed 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -1,4 +1,3 @@ -import type { IncomingRequestCfProperties } from '@cloudflare/workers-types'; import type { CloudflareOptions } from '@sentry/cloudflare'; import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare'; import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from '@sentry/core'; @@ -64,8 +63,9 @@ export const sentryCloudflareNitroPlugin = const request = new Request(url, { method: event.method, headers: event.headers, + // @ts-expect-error - 'cf' is a valid property in the RequestInit type for Cloudflare cf: getCfProperties(event), - }) as Request>; + }); const requestHandlerOptions = { options: cloudflareOptions, diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts new file mode 100644 index 000000000000..710424d6995e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -0,0 +1,314 @@ +import { + type SpanAttributes, + type StartSpanOptions, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, +} from '@sentry/core'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useStorage } from 'nitropack/runtime'; +import type { CacheEntry, ResponseCacheEntry } from 'nitropack/types'; +import type { Driver, Storage } from 'unstorage'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; + +type MaybeInstrumented = T & { + __sentry_instrumented__?: boolean; +}; + +type MaybeInstrumentedDriver = MaybeInstrumented; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have a attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates a Nitro plugin that instruments the storage driver. + */ +export default defineNitroPlugin(async _nitroApp => { + // This runs at runtime when the Nitro server starts + const storage = useStorage(); + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Adds cache mount to handle Nitro's cache calls + // Nitro uses the mount to cache functions and event handlers + // https://nitro.build/guide/cache + userMounts.add('cache:'); + // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. + // Either way, we need to instrument the root mount as well. + userMounts.add(''); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + instrumentDriver(mount.driver, mount.base); + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +}); + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const options = createSpanStartOptions(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + return startSpan(options, async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original: MaybeInstrumented = storage.mount; + if (original.__sentry_instrumented__) { + return original; + } + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + mountWithInstrumentation.__sentry_instrumented__ = true; + + return mountWithInstrumentation; +} +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +/** + * Creates the span start options for the storage method. + */ +function createSpanStartOptions( + methodName: keyof Driver, + driver: Driver, + mountBase: string, + args: unknown[], +): StartSpanOptions { + const keys = getCacheKeys(args?.[0], mountBase); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + 'db.operation.name': methodName, + 'db.collection.name': mountBase.replace(/:$/, ''), + 'db.system.name': driver.name ?? 'unknown', + }; + + return { + name: keys.join(', '), + attributes, + }; +} + +/** + * Gets a normalized array of cache keys. + */ +function getCacheKeys(key: unknown, prefix: string): string[] { + // Handles an array of keys + if (Array.isArray(key)) { + return key.map(k => normalizeKey(k, prefix)); + } + + return [normalizeKey(key, prefix)]; +} + +/** + * Normalizes the key to a string for `cache.key` attribute. + */ +function normalizeKey(key: unknown, prefix: string): string { + if (typeof key === 'string') { + return `${prefix}${key}`; + } + + // Handles an object with a key property + if (typeof key === 'object' && key !== null && 'key' in key) { + return `${prefix}${key.key}`; + } + + return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; +} + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. + * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. + * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. + * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. + */ +function isCacheHit(key: string, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + // Empty value means no cache hit either way + // Or if key doesn't match the cached function or handler patterns, we can return the empty value check + if (isEmpty || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); + } catch (error) { + // this is a best effort, so we return false if we can't validate the cache entry + return false; + } +} + +/** + * Validates the cache entry. + */ +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + // Date.now is used by Nitro internally, so safe to use here. + // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 + if (Date.now() > (entry.expires || 0)) { + return false; + } + + /** + * Pulled from Nitro's cache entry validation + * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 + */ + if (isResponseCacheEntry(key, entry)) { + if (entry.value.status >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers.etag === 'undefined' || entry.value.headers['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +/** + * Checks if the cache entry is a response cache entry. + */ +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts new file mode 100644 index 000000000000..e5d9c8dc7cec --- /dev/null +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -0,0 +1,46 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; + +export interface DatabaseSpanData { + [key: string]: string | number | undefined; +} + +/** + * Extracts span attributes from the database configuration. + */ +export function getDatabaseSpanData(config?: DatabaseConfig): Partial { + try { + if (!config?.connector) { + // Default to SQLite if no connector is configured + return { + 'db.namespace': 'db.sqlite', + }; + } + + if (config.connector === 'postgresql' || config.connector === 'mysql2') { + return { + 'server.address': config.options?.host, + 'server.port': config.options?.port, + }; + } + + if (config.connector === 'pglite') { + return { + 'db.namespace': config.options?.dataDir, + }; + } + + if ((['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[]).includes(config.connector)) { + return { + // DB is the default file name in nitro for sqlite-like connectors + 'db.namespace': `${config.options?.name ?? 'db'}.sqlite`, + }; + } + + return {}; + } catch { + // This is a best effort to get some attributes, so it is not an absolute must + // Since the user can configure invalid options, we should not fail the whole instrumentation. + return {}; + } +} diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index dcd2f46caec9..edbd26b3d707 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,5 +1,5 @@ import * as path from 'node:path'; -import type { Client, EventProcessor, Integration } from '@sentry/core'; +import type { Client, Event, EventProcessor, Integration } from '@sentry/core'; import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; import { type NodeOptions, @@ -40,7 +40,7 @@ export function init(options: SentryNuxtServerOptions): Client | undefined { export function lowQualityTransactionsFilter(options: SentryNuxtServerOptions): EventProcessor { return Object.assign( (event => { - if (event.type !== 'transaction' || !event.transaction) { + if (event.type !== 'transaction' || !event.transaction || isCacheEvent(event)) { return event; } @@ -111,3 +111,10 @@ async function flushSafelyWithTimeout(): Promise { DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } + +/** + * Checks if the event is a cache event. + */ +function isCacheEvent(e: Event): boolean { + return e.contexts?.trace?.origin === 'auto.cache.nuxt'; +} diff --git a/packages/nuxt/src/vendor/server-template.ts b/packages/nuxt/src/vendor/server-template.ts new file mode 100644 index 000000000000..afdb46345d5c --- /dev/null +++ b/packages/nuxt/src/vendor/server-template.ts @@ -0,0 +1,17 @@ +import { useNuxt } from '@nuxt/kit'; +import type { NuxtTemplate } from 'nuxt/schema'; + +/** + * Adds a virtual file that can be used within the Nuxt Nitro server build. + * Available in NuxtKit v4, so we are porting it here. + * https://github.com/nuxt/nuxt/blob/d6df732eec1a3bd442bdb325b0335beb7e10cd64/packages/kit/src/template.ts#L55-L62 + */ +export function addServerTemplate(template: NuxtTemplate): NuxtTemplate { + const nuxt = useNuxt(); + if (template.filename) { + nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}; + nuxt.options.nitro.virtual[template.filename] = template.getContents; + } + + return template; +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts new file mode 100644 index 000000000000..dfe27fd9821d --- /dev/null +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -0,0 +1,38 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import { consoleSandbox } from '@sentry/core'; +import type { NitroConfig } from 'nitropack/types'; +import { addServerTemplate } from '../vendor/server-template'; + +/** + * Sets up the database instrumentation. + */ +export function addDatabaseInstrumentation(nitro: NitroConfig): void { + if (!nitro.experimental?.database) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log( + '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', + ); + }); + + return; + } + + /** + * This is a different option than the one in `experimental.database`, this configures multiple database instances. + * keys represent database names to be passed to `useDatabase(name?)`. + * We also use the config to populate database span attributes. + * https://nitro.build/guide/database#configuration + */ + const databaseConfig = nitro.database || { default: {} }; + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/database-config.mjs', + getContents: () => { + return `export const databaseConfig = ${JSON.stringify(databaseConfig)};`; + }, + }); + + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); +} diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts new file mode 100644 index 000000000000..c0838ad154b8 --- /dev/null +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -0,0 +1,21 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import type { Nuxt } from 'nuxt/schema'; +import { addServerTemplate } from '../vendor/server-template'; + +/** + * Prepares the storage config export to be used in the runtime storage instrumentation. + */ +export function addStorageInstrumentation(nuxt: Nuxt): void { + const moduleDirResolver = createResolver(import.meta.url); + const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/storage-config.mjs', + getContents: () => { + return `export const userStorageMounts = ${JSON.stringify(userStorageMounts)};`; + }, + }); + + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); +} diff --git a/packages/nuxt/test/runtime/utils/database-span-data.test.ts b/packages/nuxt/test/runtime/utils/database-span-data.test.ts new file mode 100644 index 000000000000..fc4f4b376af8 --- /dev/null +++ b/packages/nuxt/test/runtime/utils/database-span-data.test.ts @@ -0,0 +1,199 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import { describe, expect, it } from 'vitest'; +import { getDatabaseSpanData } from '../../../src/runtime/utils/database-span-data'; + +describe('getDatabaseSpanData', () => { + describe('no config', () => { + it('should return default SQLite namespace when no config provided', () => { + const result = getDatabaseSpanData(); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + + it('should return default SQLite namespace when config has no connector', () => { + const result = getDatabaseSpanData({} as DatabaseConfig); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + }); + + describe('PostgreSQL connector', () => { + it('should extract host and port for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'localhost', + port: 5432, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'localhost', + 'server.port': 5432, + }); + }); + + it('should handle missing options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + + it('should handle partial options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'pg-host', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'pg-host', + 'server.port': undefined, + }); + }); + }); + + describe('MySQL connector', () => { + it('should extract host and port for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + options: { + host: 'mysql-host', + port: 3306, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'mysql-host', + 'server.port': 3306, + }); + }); + + it('should handle missing options for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + }); + + describe('PGLite connector', () => { + it('should extract dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: { + dataDir: '/path/to/data', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': '/path/to/data', + }); + }); + + it('should handle missing dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': undefined, + }); + }); + }); + + describe('SQLite-like connectors', () => { + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should extract database name for %s', + connector => { + const config: DatabaseConfig = { + connector, + options: { + name: 'custom-db', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'custom-db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should use default name for %s when name is not provided', + connector => { + const config: DatabaseConfig = { + connector, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should handle missing options for %s', + connector => { + const config: DatabaseConfig = { + connector, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + }); + + describe('unsupported connector', () => { + it('should return empty object for unsupported connector', () => { + const config: DatabaseConfig = { + connector: 'unknown-connector' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({}); + }); + }); + + describe('error handling', () => { + it('should return empty object when accessing invalid config throws', () => { + // Simulate a config that might throw during access + const invalidConfig = { + connector: 'postgresql' as ConnectorName, + get options(): never { + throw new Error('Invalid access'); + }, + }; + + const result = getDatabaseSpanData(invalidConfig as unknown as DatabaseConfig); + expect(result).toEqual({}); + }); + }); +}); diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index 5485cff5e0a3..9cc02341ac0a 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -29,7 +29,7 @@ export async function storeFormDataKeys( if (formDataKeys?.[key]) { if (typeof formDataKeys[key] === 'string') { - attrKey = formDataKeys[key] as string; + attrKey = formDataKeys[key]; } span.setAttribute( diff --git a/packages/remix/src/vendor/instrumentation.ts b/packages/remix/src/vendor/instrumentation.ts index 317a17da663d..6ccc56c7a88f 100644 --- a/packages/remix/src/vendor/instrumentation.ts +++ b/packages/remix/src/vendor/instrumentation.ts @@ -310,11 +310,7 @@ export class RemixInstrumentation extends InstrumentationBase { const { actionFormDataAttributes: actionFormAttributes } = plugin.getConfig(); formData.forEach((value: unknown, key: string) => { - if ( - actionFormAttributes?.[key] && - actionFormAttributes[key] !== false && - typeof value === 'string' - ) { + if (actionFormAttributes?.[key] && typeof value === 'string') { const keyName = actionFormAttributes[key] === true ? key : actionFormAttributes[key]; span.setAttribute(`formData.${keyName}`, value.toString()); } diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 04e20e5f3a56..d138a0eb8eaa 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -21,7 +21,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "nock": "^13.5.5", - "typescript": "~5.0.0" + "typescript": "~5.8.0" }, "resolutions": { "@sentry/browser": "file:../../../browser", diff --git a/packages/remix/tsconfig.test.json b/packages/remix/tsconfig.test.json index f62d7ff34d09..dace64b4fd9a 100644 --- a/packages/remix/tsconfig.test.json +++ b/packages/remix/tsconfig.test.json @@ -8,6 +8,7 @@ "types": ["node"], // Required for top-level await in tests "module": "Node16", + "moduleResolution": "Node16", "target": "es2020", "esModuleInterop": true diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index bb7c631eddef..be3c205d60d9 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,6 +1,6 @@ import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import type { NetworkMetaWarning, XhrHint } from '@sentry-internal/browser-utils'; -import { getBodyString, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { getBodyString, parseXhrResponseHeaders, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../../debug-build'; import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData } from '../../types'; import { debug } from '../../util/logger'; @@ -104,7 +104,7 @@ function _prepareXhrData( const networkRequestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) : {}; - const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); + const networkResponseHeaders = getAllowedHeaders(parseXhrResponseHeaders(xhr), options.networkResponseHeaders); const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, debug) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; @@ -123,22 +123,6 @@ function _prepareXhrData( }; } -function getResponseHeaders(xhr: XMLHttpRequest): Record { - const headers = xhr.getAllResponseHeaders(); - - if (!headers) { - return {}; - } - - return headers.split('\r\n').reduce((acc: Record, line: string) => { - const [key, value] = line.split(': ') as [string, string | undefined]; - if (value) { - acc[key.toLowerCase()] = value; - } - return acc; - }, {}); -} - function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkMetaWarning?] { // We collect errors that happen, but only log them if we can't get any response body const errors: unknown[] = []; diff --git a/packages/replay-internal/src/util/addEvent.ts b/packages/replay-internal/src/util/addEvent.ts index a133d9de6303..0cd76227379c 100644 --- a/packages/replay-internal/src/util/addEvent.ts +++ b/packages/replay-internal/src/util/addEvent.ts @@ -83,6 +83,14 @@ async function _addEvent( } catch (error) { const isExceeded = error && error instanceof EventBufferSizeExceededError; const reason = isExceeded ? 'addEventSizeExceeded' : 'addEvent'; + const client = getClient(); + + if (client) { + // We are limited in the drop reasons: + // https://github.com/getsentry/snuba/blob/6c73be60716c2fb1c30ca627883207887c733cbd/rust_snuba/src/processors/outcomes.rs#L39 + const dropReason = isExceeded ? 'buffer_overflow' : 'internal_sdk_error'; + client.recordDroppedEvent(dropReason, 'replay'); + } if (isExceeded && isBufferMode) { // Clear buffer and wait for next checkout @@ -95,12 +103,6 @@ async function _addEvent( replay.handleException(error); await replay.stop({ reason }); - - const client = getClient(); - - if (client) { - client.recordDroppedEvent('internal_sdk_error', 'replay'); - } } } diff --git a/packages/solid/README.md b/packages/solid/README.md index 58fa5c75c345..29336b5ba250 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -67,7 +67,6 @@ Pass your router instance from `createRouter` to the integration. ```js import * as Sentry from '@sentry/solid'; import { tanstackRouterBrowserTracingIntegration } from '@sentry/solid/tanstackrouter'; -import { Route, Router } from '@solidjs/router'; const router = createRouter({ // your router config diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 612b174f6c69..9cacad6f4cb8 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -39,7 +39,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { return wrapRequestHandler( { options: opts, - request: event.request as Request>, + request: event.request, // @ts-expect-error This will exist in Cloudflare context: event.platform.context, // We don't want to capture errors here, as we want to capture them in the `sentryHandle` handler diff --git a/packages/tanstackstart-react/tsconfig.json b/packages/tanstackstart-react/tsconfig.json index ff4cadba841a..220ba3fa2b86 100644 --- a/packages/tanstackstart-react/tsconfig.json +++ b/packages/tanstackstart-react/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*"], "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/tanstackstart/tsconfig.json b/packages/tanstackstart/tsconfig.json index ff4cadba841a..220ba3fa2b86 100644 --- a/packages/tanstackstart/tsconfig.json +++ b/packages/tanstackstart/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*"], "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/typescript/package.json b/packages/typescript/package.json index dc465ec207dd..430842c16d09 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -13,7 +13,7 @@ "tsconfig.json" ], "peerDependencies": { - "typescript": "~5.0.0" + "typescript": "~5.8.0" }, "scripts": { "clean": "yarn rimraf sentry-internal-typescript-*.tgz", diff --git a/scripts/verify-packages-versions.js b/scripts/verify-packages-versions.js index e6f0837cb38c..81eac62e9c90 100644 --- a/scripts/verify-packages-versions.js +++ b/scripts/verify-packages-versions.js @@ -1,6 +1,6 @@ const pkg = require('../package.json'); -const TYPESCRIPT_VERSION = '~5.0.0'; +const TYPESCRIPT_VERSION = '~5.8.0'; if (pkg.devDependencies.typescript !== TYPESCRIPT_VERSION) { console.error(` diff --git a/yarn.lock b/yarn.lock index 70c9e3d80b73..c0bc7ba27923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7110,11 +7110,6 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@sindresorhus/is@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" - integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== - "@sindresorhus/is@^7.0.2": version "7.0.2" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.0.2.tgz#a0df078a8d29f9741503c5a9c302de474ec8564a" @@ -7921,13 +7916,6 @@ dependencies: tslib "^2.4.0" -"@szmarczak/http-timer@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" - integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== - dependencies: - defer-to-connect "^1.0.1" - "@tanstack/history@1.132.21": version "1.132.21" resolved "https://registry.yarnpkg.com/@tanstack/history/-/history-1.132.21.tgz#09ae649b0c0c2d1093f0b1e34b9ab0cd3b2b1d2f" @@ -10379,7 +10367,7 @@ amqplib@^0.10.7: buffer-more-ints "~1.0.0" url-parse "~1.5.10" -ansi-align@^3.0.0, ansi-align@^3.0.1: +ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== @@ -11689,20 +11677,6 @@ bowser@^2.11.0: resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== -boxen@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" - integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.2" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - boxen@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/boxen/-/boxen-7.1.1.tgz#f9ba525413c2fec9cdb88987d835c4f7cad9c8f4" @@ -12460,19 +12434,6 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" -cacheable-request@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" - integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^3.0.0" - lowercase-keys "^2.0.0" - normalize-url "^4.1.0" - responselike "^1.0.2" - calculate-cache-key-for-tree@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/calculate-cache-key-for-tree/-/calculate-cache-key-for-tree-2.0.0.tgz#7ac57f149a4188eacb0a45b210689215d3fef8d6" @@ -12544,7 +12505,7 @@ camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0, camelcase@^6.3.0: +camelcase@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -12744,11 +12705,6 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - ci-info@^3.2.0, ci-info@^3.4.0, ci-info@^3.6.1, ci-info@^3.7.0, ci-info@^3.8.0: version "3.9.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" @@ -12825,11 +12781,6 @@ clear@^0.1.0: resolved "https://registry.yarnpkg.com/clear/-/clear-0.1.0.tgz#b81b1e03437a716984fd7ac97c87d73bdfe7048a" integrity sha512-qMjRnoL+JDPJHeLePZJuao6+8orzHMGP04A8CdwCNsKhRbOnKRjefxONR7bwILT3MHecxKBjHkKL/tkZ8r4Uzw== -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - cli-boxes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145" @@ -12933,13 +12884,6 @@ clone-deep@4.0.1, clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" -clone-response@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" - integrity sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q== - dependencies: - mimic-response "^1.0.0" - clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -13961,10 +13905,10 @@ debug@3.1.0: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== +debug@4, debug@^4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" @@ -14026,13 +13970,6 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= - dependencies: - mimic-response "^1.0.0" - decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -14152,11 +14089,6 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" -defer-to-connect@^1.0.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" - integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== - define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -14710,11 +14642,6 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -16151,11 +16078,6 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -17791,7 +17713,7 @@ get-stream@6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== -get-stream@^4.0.0, get-stream@^4.1.0: +get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== @@ -18055,13 +17977,6 @@ global-directory@^4.0.1: dependencies: ini "4.1.1" -global-dirs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" - integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== - dependencies: - ini "2.0.0" - global-modules@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" @@ -18211,23 +18126,6 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -got@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" - integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== - dependencies: - "@sindresorhus/is" "^0.14.0" - "@szmarczak/http-timer" "^1.1.2" - cacheable-request "^6.0.0" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^4.1.0" - lowercase-keys "^1.0.1" - mimic-response "^1.0.1" - p-cancelable "^1.0.0" - to-readable-stream "^1.0.0" - url-parse-lax "^3.0.0" - graceful-fs@4.2.11, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.5, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -18443,11 +18341,6 @@ has-values@^1.0.0: is-number "^3.0.0" kind-of "^4.0.0" -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -18914,7 +18807,7 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" -http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: +http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== @@ -19178,11 +19071,6 @@ import-in-the-middle@^1.14.2, import-in-the-middle@^1.8.1: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== - import-local@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" @@ -19255,11 +19143,6 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@2.0.0, ini@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - ini@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1" @@ -19275,6 +19158,11 @@ ini@^1.3.2, ini@^1.3.4, ini@^1.3.5, ini@^1.3.8, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ini@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + init-package-json@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-5.0.0.tgz#030cf0ea9c84cfc1b0dc2e898b45d171393e4b40" @@ -19543,13 +19431,6 @@ is-ci@3.0.1: dependencies: ci-info "^3.2.0" -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - is-core-module@^2.13.0, is-core-module@^2.16.0, is-core-module@^2.3.0, is-core-module@^2.5.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.16.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" @@ -19657,14 +19538,6 @@ is-inside-container@^1.0.0: dependencies: is-docker "^3.0.0" -is-installed-globally@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - is-installed-globally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-1.0.0.tgz#08952c43758c33d815692392f7f8437b9e436d5a" @@ -19710,11 +19583,6 @@ is-negative-zero@^2.0.2: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== -is-npm@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" - integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== - is-number-object@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" @@ -19742,11 +19610,6 @@ is-obj@^2.0.0: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-path-inside@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-4.0.0.tgz#805aeb62c47c1b12fc3fd13bfb3ed1e7430071db" @@ -19995,11 +19858,6 @@ is-wsl@^3.0.0, is-wsl@^3.1.0: dependencies: is-inside-container "^1.0.0" -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - is64bit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is64bit/-/is64bit-2.0.0.tgz#198c627cbcb198bbec402251f88e5e1a51236c07" @@ -20323,11 +20181,6 @@ json-bigint@^1.0.0: dependencies: bignumber.js "^9.0.0" -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= - json-parse-better-errors@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -20547,13 +20400,6 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" -keyv@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" - integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== - dependencies: - json-buffer "3.0.0" - kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -20649,13 +20495,6 @@ language-tags@^1.0.5: dependencies: language-subtag-registry "~0.3.2" -latest-version@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" - launch-editor@^2.9.1: version "2.9.1" resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.9.1.tgz#253f173bd441e342d4344b4dae58291abb425047" @@ -21359,16 +21198,6 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" -lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== - lru-cache@6.0.0, lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -22356,11 +22185,6 @@ mimic-fn@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== -mimic-response@^1.0.0, mimic-response@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" @@ -23387,21 +23211,21 @@ node-watch@0.7.3: resolved "https://registry.yarnpkg.com/node-watch/-/node-watch-0.7.3.tgz#6d4db88e39c8d09d3ea61d6568d80e5975abc7ab" integrity sha512-3l4E8uMPY1HdMMryPRUAl+oIHtXtyiTlIiESNSVSNxcPfzAFzeTbXFQkZfAwBbo0B1qMSG8nUABx+Gd+YrbKrQ== -nodemon@^2.0.16: - version "2.0.16" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.16.tgz#d71b31bfdb226c25de34afea53486c8ef225fdef" - integrity sha512-zsrcaOfTWRuUzBn3P44RDliLlp263Z/76FPoHFr3cFFkOz0lTPAcIw8dCzfdVIx/t3AtDYCZRCDkoCojJqaG3w== +nodemon@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.10.tgz#5015c5eb4fffcb24d98cf9454df14f4fecec9bc1" + integrity sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw== dependencies: chokidar "^3.5.2" - debug "^3.2.7" + debug "^4" ignore-by-default "^1.0.1" - minimatch "^3.0.4" + minimatch "^3.1.2" pstree.remy "^1.1.8" - semver "^5.7.1" + semver "^7.5.3" + simple-update-notifier "^2.0.0" supports-color "^5.5.0" touch "^3.1.0" undefsafe "^2.0.5" - update-notifier "^5.1.0" nopt@^3.0.6: version "3.0.6" @@ -23497,11 +23321,6 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= -normalize-url@^4.1.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" - integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== - npm-bundled@^1.1.1, npm-bundled@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" @@ -24230,11 +24049,6 @@ osenv@^0.1.3: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -p-cancelable@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" - integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== - p-defer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" @@ -24441,16 +24255,6 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - package-manager-detector@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea" @@ -25800,11 +25604,6 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= - prettier-plugin-astro@^0.14.1: version "0.14.1" resolved "https://registry.yarnpkg.com/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz#50bff8a659f2a6a4ff3b1d7ea73f2de93c95b213" @@ -26086,13 +25885,6 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -pupa@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - pure-rand@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" @@ -26765,20 +26557,6 @@ regextras@^0.7.1: resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.1.tgz#be95719d5f43f9ef0b9fa07ad89b7c606995a3b2" integrity sha512-9YXf6xtW+qzQ+hcMQXx95MOvfqXFgsKDZodX3qZB0x2n5Z94ioetIITsBtvJbiOyxa/6s9AtyweBLCdPmPko/w== -registry-auth-token@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" - integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw== - dependencies: - rc "^1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - regjsparser@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" @@ -27101,13 +26879,6 @@ resolve@^2.0.0-next.1, resolve@^2.0.0-next.3: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -responselike@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" - integrity sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ== - dependencies: - lowercase-keys "^1.0.0" - restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -27647,13 +27418,6 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" @@ -27666,7 +27430,7 @@ semver@7.5.3: dependencies: lru-cache "^6.0.0" -semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0, semver@^6.3.1: +semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -28073,6 +27837,13 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + sinon@19.0.2: version "19.0.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-19.0.2.tgz#944cf771d22236aa84fc1ab70ce5bffc3a215dad" @@ -28737,7 +28508,7 @@ string-template@~0.2.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.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: +string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -29645,11 +29416,6 @@ to-object-path@^0.3.0: dependencies: kind-of "^3.0.2" -to-readable-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" - integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== - to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" @@ -30045,7 +29811,7 @@ typescript@4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== -"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3: +"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3, typescript@~5.8.0: version "5.8.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== @@ -30060,11 +29826,6 @@ typescript@next: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== -typescript@~5.0.0: - version "5.0.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" - integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== - typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" @@ -30691,26 +30452,6 @@ update-browserslist-db@^1.1.1: escalade "^3.2.0" picocolors "^1.1.1" -update-notifier@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" - integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== - dependencies: - boxen "^5.0.0" - chalk "^4.1.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.4.0" - is-npm "^5.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.1.0" - pupa "^2.1.1" - semver "^7.3.4" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" - uqr@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/uqr/-/uqr-0.1.2.tgz#5c6cd5dcff9581f9bb35b982cb89e2c483a41d7d" @@ -30728,13 +30469,6 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= - dependencies: - prepend-http "^2.0.0" - url-parse@^1.5.3, url-parse@~1.5.10: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" @@ -31784,13 +31518,6 @@ wide-align@^1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - widest-line@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-4.0.1.tgz#a0fc673aaba1ea6f0a0d35b3c2795c9a9cc2ebf2"