diff --git a/MIGRATION.md b/MIGRATION.md index 994557cbadc4..4f17941b1f87 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -481,6 +481,24 @@ Sentry.init({ `ignoreSpans` itself is unchanged in shape, but it now takes effect when a span **starts** rather than when the transaction is sent. Matched spans are never recorded at all, which means a matched non-segment span's children are re-parented to its parent instead of being dropped. +#### `ignoreStatusCodes` is deprecated + +The `ignoreStatusCodes` option is deprecated on `httpIntegration` and `httpServerSpansIntegration` (Node and the SDKs built on it) as well as on `denoHttpIntegration` and `denoServeIntegration`. It will be removed in v12, without a direct replacement. + +The filter runs on the finished transaction event, which is no longer supported span streaming. Child spans are sent as they end, before the response status code is known, so a request's spans can no longer be dropped once the status turns out to be uninteresting. The option therefore only has an effect with `traceLifecycle: 'static'`. + +To keep specific requests out of Sentry, decide before they are instrumented: Use `tracesSampler`, or ignore the request via `ignoreIncomingRequests`, which matches on the incoming request instead of on the response: + +```js +Sentry.init({ + integrations: [ + Sentry.httpIntegration({ + ignoreIncomingRequests: urlPath => urlPath.startsWith('/admin'), + }), + ], +}); +``` + #### Opting out of span streaming To keep the previous transaction-based model, set `traceLifecycle: 'static'`: @@ -656,7 +674,8 @@ Sentry.init({ This filter runs on transaction events (`processEvent`), so it only takes effect when `traceLifecycle` is `'static'`. The default `'stream'` lifecycle does not produce transaction events, and typical Deno apps are unaffected. Node's -`httpIntegration` has the same limitation. +`httpIntegration` has the same limitation. For that reason, [`ignoreStatusCodes` is deprecated](#ignorestatuscodes-is-deprecated) +and will be removed in v12. Transactions that are kept now also carry the HTTP status in the top-level `response` context, as in the other server SDKs. @@ -980,6 +999,8 @@ Messaging span names now read ` ` in every integrat Cache keys are unbounded, so they are no longer part of a cache span name. They remain available on the `cache.key` attribute, and every cache span now also carries a `cache.operation` attribute (`get`, `put`, `remove`) — the value the name is built from. That attribute is set in both trace lifecycles. This affects the redis/ioredis cache spans (`cachePrefixes`), the Nuxt and Nitro storage spans, and the dataloader spans. +A Redis command whose key matches `cachePrefixes` now starts as a `cache.*` span instead of being converted from a `db.query` span at response time. `ignoreSpans` is evaluated at span start, so filters can match these spans by their cache op and name. A failed cache command reports as a cache span too, where it previously stayed a `db.query` span. + A dataloader span no longer carries the loader's `name` either (`dataloader.load usersLoader` becomes `cache.get`), because the cache conventions have no slot for it in the name. It is reported on the `db.collection.name` attribute instead — a loader batches one entity type, so it is the closest thing dataloader has to a collection — and that attribute is set in both trace lifecycles. Unnamed loaders do not set it. Redis has no SQL statement to summarize and no collection to pair a command with, so redis and ioredis `db.query` spans are named after the operation and the connection instead of the command that was sent. The command and its arguments remain available on `db.query.text`, redacted as before. `MULTI`/`PIPELINE` batch spans are unchanged — they were already named after their operation, which they now also report on `db.operation.name`. `db.namespace` is deliberately not used in the name: for redis it is the numeric database index, which says nothing about what the command did. @@ -1239,10 +1260,36 @@ Affected SDKs: `@sentry/react-router`. + import { sentryOnBuildEnd } from '@sentry/react-router/vite'; ``` +### Remix: Vite plugin moved to `@sentry/remix/vite` + +Affected SDKs: `@sentry/remix`. + +`sentryRemixVitePlugin` is no longer available from the main `@sentry/remix` entry point. Import it from the dedicated subpath instead: + +```diff +// vite.config.ts +- import { sentryRemixVitePlugin } from '@sentry/remix'; ++ import { sentryRemixVitePlugin } from '@sentry/remix/vite'; +``` + +The plugin now also applies the build-time instrumentation transform. If you added `sentryOrchestrionPlugin()` from `@sentry/server-utils/orchestrion/vite` to your Vite config manually, remove it. Opt out with `sentryRemixVitePlugin({ buildTimeInstrumentation: false })`. + ## 3. Removed APIs ### `@sentry/core` / All SDKs +- `@sentry/core` now exports only isomorphic code. Browser-only exports live on `@sentry/core/browser` and server-only exports on `@sentry/core/server`, and neither subpath re-exports the shared surface any more. This keeps server-only code (HTTP instrumentation, ANR, postgres and sql helpers) out of browser bundles. Most of these APIs are also re-exported by the platform SDKs (`@sentry/node`, `@sentry/browser`, ...), which is unchanged, so this only affects code importing straight from `@sentry/core`. TypeScript reports it as `has no exported member`. + +```js +// before +import { loadModule, trpcMiddleware } from '@sentry/core'; +import type { BrowserClientReplayOptions } from '@sentry/core'; + +// after +import { loadModule, trpcMiddleware } from '@sentry/core/server'; +import type { BrowserClientReplayOptions } from '@sentry/core/browser'; +``` + - The internal, deprecated `addAutoIpAddressToUser` export was removed. - `Scope.clear()` was removed. To reset scope state, re-initialize the SDK or run your code in a fresh scope via `withScope`/`withIsolationScope`. - The deprecated positional `spanOrigin` argument of `instrumentFetchRequest` was removed. Pass an options object (e.g. `{ spanOrigin }`) as the last argument instead. @@ -1424,6 +1471,8 @@ Sentry.httpIntegration({ }); ``` +Note that `ignoreStatusCodes` is itself [deprecated](#ignorestatuscodes-is-deprecated) and will be removed in v12. + ### `@sentry/cloudflare` - The `@sentry/cloudflare/nodejs_compat` subpath export was removed. Since `nodejs_compat` is now required for all users, the main `@sentry/cloudflare` entry point includes everything that was previously only available via the subpath. diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts index 6ebb8eacc6a5..44f475c6714e 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts @@ -1,6 +1,6 @@ import { installGlobals } from '@remix-run/node'; import { vitePlugin as remix } from '@remix-run/dev'; -import { sentryRemixVitePlugin } from '@sentry/remix'; +import { sentryRemixVitePlugin } from '@sentry/remix/vite'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts index 6ebb8eacc6a5..44f475c6714e 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts @@ -1,6 +1,6 @@ import { installGlobals } from '@remix-run/node'; import { vitePlugin as remix } from '@remix-run/dev'; -import { sentryRemixVitePlugin } from '@sentry/remix'; +import { sentryRemixVitePlugin } from '@sentry/remix/vite'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json index 269fe3571de7..ecdfe08fa487 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", "@remix-run/css-bundle": "2.17.4", "@remix-run/node": "2.17.4", "@remix-run/react": "2.17.4", diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts index 42372f108ba8..690acdb91168 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts @@ -1,6 +1,5 @@ import { vitePlugin as remix } from '@remix-run/dev'; -import { sentryRemixVitePlugin } from '@sentry/remix'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; +import { sentryRemixVitePlugin } from '@sentry/remix/vite'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; @@ -10,10 +9,6 @@ export default defineConfig({ ignoredRouteFiles: ['**/.*'], }), sentryRemixVitePlugin(), - // Run the orchestrion code transform over the SSR server bundle and force-bundle the - // instrumented deps (mysql, ioredis, @remix-run/server-runtime, …) so their - // diagnostics-channel calls are injected at build time. - sentryOrchestrionPlugin(), tsconfigPaths(), ], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts new file mode 100644 index 000000000000..dbd927e2053b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; +import * as fs from 'fs'; +import { createRequire } from 'module'; +import * as path from 'path'; +import { isDevMode } from './isDevMode'; + +/** + * The orchestrion bundler plugins are build-time-only, and their module-scope side effects break + * on Workers (an unawaited `WebAssembly.compile()` crashed every cold start, issue #22794). The + * worker bundle OpenNext produces must therefore never contain them: importing `@sentry/nextjs` + * on the server has to keep the plugin graph out of the deployed artifact. + */ +test('worker bundle does not contain the orchestrion bundler plugins', () => { + test.skip(isDevMode, 'requires the production worker build'); + + const openNextDir = path.resolve(__dirname, '..', '.open-next'); + expect(fs.existsSync(path.join(openNextDir, 'worker.js'))).toBe(true); + + // `assets` holds the static client files; everything else is code the worker can run. + const serverFiles = collectJsFiles(openNextDir).filter( + filePath => !filePath.startsWith(path.join(openNextDir, 'assets')), + ); + expect(serverFiles.length).toBeGreaterThan(0); + + const markers = ['code-transformer-bundler-plugins', '__codeTransformerWebpackDiagnostics']; + + // The markers must still exist in the installed plugin build. + // If upstream renames them, this fails instead of letting the leak check below pass. + const pluginGraphSources = readOrchestrionPluginGraphSources(); + for (const marker of markers) { + expect( + pluginGraphSources.some(source => source.includes(marker)), + `marker "${marker}" is gone from the @sentry/server-utils plugin build — update the markers`, + ).toBe(true); + } + + const leaks = serverFiles.filter(filePath => { + const content = fs.readFileSync(filePath, 'utf8'); + return markers.some(marker => content.includes(marker)); + }); + + expect(leaks.map(filePath => path.relative(openNextDir, filePath))).toEqual([]); +}); + +/** + * Reads the source of the installed `@sentry/server-utils` webpack plugin entry plus the files it + * requires relatively — the graph a leak would drag into the worker bundle. `createRequire` takes + * the `require` export condition, so this resolves the CJS build, whose `require('./…')` calls the + * regex below picks up. + */ +function readOrchestrionPluginGraphSources(): string[] { + const pluginEntry = createRequire(__filename).resolve('@sentry/server-utils/orchestrion/webpack'); + const entrySource = fs.readFileSync(pluginEntry, 'utf8'); + return [ + entrySource, + ...[...entrySource.matchAll(/require\('(\.\.?\/[^']+)'\)/g)].map(([, specifier]) => + fs.readFileSync(path.resolve(path.dirname(pluginEntry), specifier), 'utf8'), + ), + ]; +} + +function collectJsFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return collectJsFiles(fullPath); + } + return /\.(js|mjs|cjs)$/.test(entry.name) ? [fullPath] : []; + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.client.config.ts index e14d25bfc0c1..9a9566051452 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.client.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nuxt'; import { useRuntimeConfig } from '#imports'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: useRuntimeConfig().public.sentry.dsn, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.server.config.ts index 60138a64a4c8..729b2296c683 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.server.config.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts index 9c0b8232b823..7172e69c052a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts @@ -1,60 +1,49 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; -import type { Span } from '@sentry/nuxt'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('sends a pageload root span with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-3-dynamic-import', async transactionEvent => { - return transactionEvent.transaction === '/test-param/:param()'; + const pageloadSpanPromise = waitForStreamedSpan('nuxt-3-dynamic-import', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/test-param/1234`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.pageload.vue', - 'sentry.op': 'pageload', - 'params.param': '1234', - 'url.template': '/test-param/:param()', - 'url.path': '/test-param/1234', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), - }, - op: 'pageload', - origin: 'auto.pageload.vue', - }, - }, - transaction: '/test-param/:param()', - transaction_info: { - source: 'route', - }, + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/test-param/:param()'); + expect(pageloadSpan.status).toBe('ok'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.op': { type: 'string', value: 'pageload' }, + 'params.param': { type: 'string', value: '1234' }, + 'url.template': { type: 'string', value: '/test-param/:param()' }, + 'url.path': { type: 'string', value: '/test-param/1234' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/) }, }); }); test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-3-dynamic-import', async transactionEvent => { - return transactionEvent.transaction === '/client-error'; - }); + const spansPromise = collectStreamedSpans('nuxt-3-dynamic-import', spans => + spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'), + ); await page.goto(`/client-error`); - const rootSpan = await transactionPromise; - const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + const spans = await spansPromise; + const errorButtonSpan = spans.find(span => span.name === 'Vue '); - const expected = { - data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount' }, - description: 'Vue ', - op: 'ui.mount', + expect(errorButtonSpan).toMatchObject({ + name: 'Vue ', + is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.vue', - }; - - expect(errorButtonSpan).toMatchObject(expected); + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.mount' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + }), + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts index fc39ac094910..2320c83cfbb2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts @@ -1,45 +1,39 @@ 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'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a server action transaction on pageload', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-3-dynamic-import', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); +test('sends a server root span on pageload', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nuxt-3-dynamic-import', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); await page.goto('/test-param/1234'); - const transaction = await transactionPromise; + const serverSpan = await serverSpanPromise; - expect(transaction.contexts.trace).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.http_server', - }), - }), - ); + expect(getSpanOp(serverSpan)).toBe('http.server'); + expect(serverSpan.attributes['sentry.origin']?.value).toBe('auto.http.http_server'); }); -test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { +test('does not send spans for build asset folder "_nuxt"', async ({ page }) => { let buildAssetFolderOccurred = false; - waitForTransaction('nuxt-3-dynamic-import', transactionEvent => { - if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + waitForStreamedSpan('nuxt-3-dynamic-import', span => { + if (span.is_segment && /^GET \/_nuxt\//.test(span.name)) { buildAssetFolderOccurred = true; } return false; // expects to return a boolean (but not relevant here) }); - const transactionEventPromise = waitForTransaction('nuxt-3-dynamic-import', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); + const serverSpanPromise = waitForStreamedSpan('nuxt-3-dynamic-import', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); await page.goto('/test-param/1234'); - const transactionEvent = await transactionEventPromise; + const serverSpan = await serverSpanPromise; expect(buildAssetFolderOccurred).toBe(false); - expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); + expect(serverSpan.name).toBe('GET /test-param/:param()'); + expect(serverSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts index 94c8df1642de..12d8e9d3a3e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts @@ -1,22 +1,23 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.describe('distributed tracing', () => { const PARAM = 's0me-param'; + const API_PATH = `/api/user/${PARAM}`; test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { - return txnEvent.transaction === '/test-param/:param()'; + const clientSpanPromise = waitForStreamedSpan('nuxt-3-dynamic-import', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { - return txnEvent.transaction?.includes('GET /test-param/') || false; + const serverSpanPromise = waitForStreamedSpan('nuxt-3-dynamic-import', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); - const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + const [_, clientSpan, serverSpan] = await Promise.all([ page.goto(`/test-param/${PARAM}`), - clientTxnEventPromise, - serverTxnEventPromise, + clientSpanPromise, + serverSpanPromise, expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); @@ -24,7 +25,7 @@ test.describe('distributed tracing', () => { // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); - expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverSpan.trace_id}`); expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -33,125 +34,115 @@ test.describe('distributed tracing', () => { expect(metaSampled).toBe('1'); - expect(clientTxnEvent).toMatchObject({ - transaction: '/test-param/:param()', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.vue', - trace_id: metaTraceId, - parent_span_id: metaParentSpanId, - }, - }, + expect(clientSpan).toMatchObject({ + name: '/test-param/:param()', + is_segment: true, + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), }); - expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/:param()`, // parametrized - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.http_server', - }, - }, + expect(serverSpan).toMatchObject({ + name: 'GET /test-param/:param()', // parametrized + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), }); // connected trace - expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); - - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); - expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + expect(clientSpan.parent_span_id).toBe(serverSpan.span_id); + expect(serverSpan.trace_id).toBe(metaTraceId); }); test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { - return txnEvent.transaction === '/test-param/user/:userId()'; + // The `http.client` span ends after the pageload segment, so it can be flushed in a later + // envelope. Accumulate until both spans have arrived. + const clientSpansPromise = collectStreamedSpans('nuxt-3-dynamic-import', spans => { + return ( + spans.some(span => span.name === '/test-param/user/:userId()' && span.is_segment) && + spans.some( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), + ) + ); }); - const ssrTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { - return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + const ssrSpanPromise = waitForStreamedSpan('nuxt-3-dynamic-import', span => { + return span.is_segment && span.name.includes('GET /test-param/user'); }); - const serverReqTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { - return txnEvent.transaction?.includes('GET /api/user/') ?? false; + const serverReqSpanPromise = waitForStreamedSpan('nuxt-3-dynamic-import', span => { + return span.is_segment && span.name.includes('GET /api/user/'); }); // Navigate to the page which will trigger an API call from the client-side await page.goto(`/test-param/user/${PARAM}`); - const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ - clientTxnEventPromise, - ssrTxnEventPromise, - serverReqTxnEventPromise, + const [clientSpans, ssrSpan, serverReqSpan] = await Promise.all([ + clientSpansPromise, + ssrSpanPromise, + serverReqSpanPromise, ]); - const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); - - expect(clientTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: '/test-param/user/:userId()', // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'pageload', - origin: 'auto.pageload.vue', - }), - }), - }), + const pageloadSpan = clientSpans.find(span => span.name === '/test-param/user/:userId()' && span.is_segment); + const httpClientSpan = clientSpans.find( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), ); + expect(pageloadSpan).toMatchObject({ + name: '/test-param/user/:userId()', + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), + }); + expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan).toEqual( - expect.objectContaining({ - description: `GET /api/user/${PARAM}`, // fixme: parametrize - parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent - data: expect.objectContaining({ - 'url.full': expect.stringContaining(`/api/user/${PARAM}`), - type: 'fetch', - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.browser', - 'http.request.method': 'GET', - }), + expect(httpClientSpan).toMatchObject({ + // A relative fetch has no domain of its own, so it resolves against the page origin. + name: 'GET localhost', + parent_span_id: pageloadSpan?.span_id, // pageload span is parent + attributes: expect.objectContaining({ + type: { type: 'string', value: 'fetch' }, + 'sentry.op': { type: 'string', value: 'http.client' }, + 'sentry.origin': { type: 'string', value: 'auto.http.browser' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: expect.stringContaining(API_PATH) }, + 'url.domain': { type: 'string', value: 'localhost' }, }), - ); + }); - expect(ssrTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: `GET /test-param/user/:userId()`, // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.http_server', - }), - }), + expect(ssrSpan).toMatchObject({ + name: 'GET /test-param/user/:userId()', // parametrized route + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, }), - ); + }); - expect(serverReqTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: `GET /api/user/:userId`, // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.http_server', - parent_span_id: httpClientSpan?.span_id, // http.client span is parent - }), - }), + expect(serverReqSpan).toMatchObject({ + name: 'GET /api/user/:userId', // parametrized route + is_segment: true, + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, }), - ); + }); - // All 3 transactions and the http.client span should share the same trace_id - expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + // All 3 root spans and the http.client span should share the same trace_id + expect(pageloadSpan?.trace_id).toBeDefined(); + expect(pageloadSpan?.trace_id).toBe(httpClientSpan?.trace_id); + expect(pageloadSpan?.trace_id).toBe(ssrSpan.trace_id); + expect(pageloadSpan?.trace_id).toBe(serverReqSpan.trace_id); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts index f1725d6bf9e4..faa56e1db7ef 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts @@ -3,7 +3,6 @@ import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'; export default defineNitroPlugin( sentryCloudflareNitroPlugin({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, tunnel: 'http://localhost:3031/', // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts index d2f846fd29cf..af9f1e7259d4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts @@ -1,43 +1,57 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +// Streamed child spans arrive across several envelopes, so collect until the request's segment +// span and the expected db spans have all been flushed. The streamed segment name is method-only +// (`GET`), so the segment is selected via `url.path`. +function collectDbSpans(minDbSpans: number) { + return collectStreamedSpans( + 'nuxt-4-cloudflare', + spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mysql') && + spans.filter(span => getSpanOp(span) === 'db').length >= minDbSpans, + ); +} test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-4-cloudflare', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.some(span => span.op === 'db') ?? false) - ); - }); + const spansPromise = collectDbSpans(1); const res = await request.get('/api/db-mysql'); expect(res.status()).toBe(200); - const transactionEvent = await transactionPromise; - const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db'); + const spans = await spansPromise; + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mysql'); + expect(rootSpan).toBeDefined(); + expect(getSpanOp(rootSpan!)).toBe('http.server'); + + const dbSpans = spans.filter(span => getSpanOp(span) === 'db' && span.trace_id === rootSpan!.trace_id); - const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution'); expect(firstQuery).toBeDefined(); - expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql'); - expect(firstQuery!.data?.['db.system.name']).toBe('mysql'); - expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution'); - expect(firstQuery!.data?.['server.address']).toBe('127.0.0.1'); - expect(firstQuery!.data?.['server.port']).toBe(3306); - expect(firstQuery!.data?.['db.user']).toBe('root'); + expect(firstQuery!.name).toBe('SELECT'); + expect(firstQuery!.attributes).toMatchObject({ + 'sentry.origin': { type: 'string', value: 'auto.db.mysql' }, + 'db.system.name': { type: 'string', value: 'mysql' }, + 'db.query.text': { type: 'string', value: 'SELECT 1 + 1 AS solution' }, + 'server.address': { type: 'string', value: '127.0.0.1' }, + 'server.port': { type: 'integer', value: 3306 }, + 'db.user': { type: 'string', value: 'root' }, + }); }); -test('a nested query lands on the same transaction (async context restored)', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-4-cloudflare', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 - ); - }); +test('a nested query lands on the same trace (async context restored)', async ({ request }) => { + const spansPromise = collectDbSpans(2); const res = await request.get('/api/db-mysql'); expect(res.status()).toBe(200); - const transactionEvent = await transactionPromise; - const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description); - expect(descriptions).toContain('SELECT 1 + 1 AS solution'); - expect(descriptions).toContain('SELECT NOW()'); + const spans = await spansPromise; + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mysql'); + expect(rootSpan).toBeDefined(); + + const queryTexts = spans + .filter(span => getSpanOp(span) === 'db' && span.trace_id === rootSpan!.trace_id) + .map(span => span.attributes['db.query.text']?.value); + expect(queryTexts).toContain('SELECT 1 + 1 AS solution'); + expect(queryTexts).toContain('SELECT NOW()'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-4-static/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/components/ErrorButton.vue new file mode 100644 index 000000000000..b686436bee17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/components/ErrorButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/composables/use-sentry-test-tag.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/composables/use-sentry-test-tag.ts new file mode 100644 index 000000000000..0d6642ca3d8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/composables/use-sentry-test-tag.ts @@ -0,0 +1,8 @@ +// fixme: this needs to be imported from @sentry/core, not @sentry/nuxt in dev mode (because of import-in-the-middle error) +// This could also be a problem with the specific setup of the pnpm E2E test setup, because this could not be reproduced outside of the E2E test. +// Related to this: https://github.com/getsentry/sentry-javascript/issues/15204#issuecomment-2948908130 +import { setTag } from '@sentry/nuxt'; + +export default function useSentryTestTag(): void { + setTag('test-tag', null); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/client-error.vue new file mode 100644 index 000000000000..97f8abf00fb9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/client-error.vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/fetch-server-routes.vue new file mode 100644 index 000000000000..089d77a2eee9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/fetch-server-routes.vue @@ -0,0 +1,18 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/index.vue new file mode 100644 index 000000000000..57a583eb43b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/pinia-cart.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/pinia-cart.vue new file mode 100644 index 000000000000..07464aa28ca3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/pinia-cart.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/client-side-only-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/client-side-only-page.vue new file mode 100644 index 000000000000..fb41b62b3308 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/client-side-only-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-1h-cached-page.vue new file mode 100644 index 000000000000..e702eca86715 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-cached-page.vue new file mode 100644 index 000000000000..780adc07de53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/pre-rendered-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/pre-rendered-page.vue new file mode 100644 index 000000000000..25b423a4c442 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/pre-rendered-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-1h-cached-page.vue new file mode 100644 index 000000000000..24918924f4a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-cached-page.vue new file mode 100644 index 000000000000..d0d8e7241968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/[param].vue new file mode 100644 index 000000000000..019404aaf460 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/[param].vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/user/[userId].vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/user/[userId].vue new file mode 100644 index 000000000000..41daf0460b05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/user/[userId].vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4-static/docker-compose.yml new file mode 100644 index 000000000000..a50a0097e5ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/docker-compose.yml @@ -0,0 +1,31 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-nuxt-4-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + + redis: + image: redis:7 + restart: always + container_name: e2e-tests-nuxt-4-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-setup.mjs new file mode 100644 index 000000000000..cb48d539c466 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MySQL + Redis via Docker Compose. `--wait` blocks until the + // healthchecks in docker-compose.yml pass, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/modules/another-module.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/modules/another-module.ts new file mode 100644 index 000000000000..9c1a3ca80487 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/modules/another-module.ts @@ -0,0 +1,9 @@ +import { defineNuxtModule } from 'nuxt/kit'; + +// Just a fake module to check if the SDK works alongside other local Nuxt modules without breaking the build +export default defineNuxtModule({ + meta: { name: 'another-module' }, + setup() { + console.log('another-module setup called'); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt-start-dev-server.bash new file mode 100644 index 000000000000..4affbb553b0e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt-start-dev-server.bash @@ -0,0 +1,55 @@ +#!/bin/bash +# To enable Sentry in Nuxt dev, it needs the sentry.server.config.mjs file from the .nuxt folder. +# First, we need to start 'nuxt dev' to generate the file, and then start 'nuxt dev' again with the NODE_OPTIONS to have Sentry enabled. + +# Using a different port to avoid playwright already starting with the tests for port 3030 +TEMP_PORT=3035 + +# 1. Start dev in background - this generates .nuxt folder +pnpm dev -p $TEMP_PORT & +DEV_PID=$! + +# 2. Wait for the sentry.server.config.mjs file to appear +echo "Waiting for .nuxt/dev/sentry.server.config.mjs file..." +COUNTER=0 +while [ ! -f ".nuxt/dev/sentry.server.config.mjs" ] && [ $COUNTER -lt 30 ]; do + sleep 1 + ((COUNTER++)) +done + +if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then + echo "ERROR: .nuxt/dev/sentry.server.config.mjs file never appeared!" + echo "This usually means the Nuxt dev server failed to start or generate the file. Try to rerun the test." + pkill -P $DEV_PID || kill $DEV_PID + exit 1 +fi + +# 3. Cleanup +# `pkill -P` only kills direct children, so the grandchild dev server holding the +# port survives; newer Nuxt's directory-scoped dev lock then blocks the real start. +echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." +pkill -P $DEV_PID 2>/dev/null +kill $DEV_PID 2>/dev/null + +# Wait for port to be released +echo "Waiting for port $TEMP_PORT to be released..." +COUNTER=0 +# Check if port is still in use +while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do + sleep 1 + ((COUNTER++)) +done + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "Port $TEMP_PORT still in use, killing remaining processes bound to it..." + lsof -t -i :$TEMP_PORT | xargs -r kill -9 2>/dev/null + sleep 1 +fi + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use, proceeding anyway..." +else + echo "Port $TEMP_PORT released successfully" +fi + +echo "Nuxt dev server can now be started with '--import ./.nuxt/dev/sentry.server.config.mjs'" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt.config.ts new file mode 100644 index 000000000000..c7acd2b12328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt.config.ts @@ -0,0 +1,47 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-06-06', + imports: { autoImport: false }, + + routeRules: { + '/rendering-modes/client-side-only-page': { ssr: false }, + '/rendering-modes/isr-cached-page': { isr: true }, + '/rendering-modes/isr-1h-cached-page': { isr: 3600 }, + '/rendering-modes/swr-cached-page': { swr: true }, + '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, + '/rendering-modes/pre-rendered-page': { prerender: true }, + }, + + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + 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-static/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json new file mode 100644 index 000000000000..68d873b7f521 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json @@ -0,0 +1,50 @@ +{ + "name": "nuxt-4-static", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "start": "node .output/server/index.mjs", + "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", + "clean": "npx nuxi cleanup", + "test": "playwright test", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@pinia/nuxt": "^0.5.5", + "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "ioredis": "5.10.1", + "mysql": "^2.18.1", + "nuxt": "^4.1.2" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json", + "node": "22.20.0" + }, + "sentryTest": { + "variants": [ + { + "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", + "assert-command": "E2E_TEST_OTEL_SETUP=true pnpm test:assert", + "label": "nuxt-4-static (tracer provider)" + } + ], + "optionalVariants": [ + { + "build-command": "pnpm test:build-canary", + "label": "nuxt-4-static (canary)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/playwright.config.ts new file mode 100644 index 000000000000..0b61db7cde11 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/playwright.config.ts @@ -0,0 +1,29 @@ +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 "NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev -p 3030"; + } + + if (testEnv === 'production') { + return 'pnpm start:import'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-4-static/public/favicon.ico new file mode 100644 index 000000000000..18993ad91cfd Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nuxt-4-static/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.client.config.ts new file mode 100644 index 000000000000..0bab1499bcde --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.client.config.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nuxt'; +import { usePinia, useRuntimeConfig } from '#imports'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: useRuntimeConfig().public.sentry.dsn, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + integrations: [ + Sentry.piniaIntegration(usePinia(), { + actionTransformer: action => `${action}.transformed`, + stateTransformer: state => ({ + transformed: true, + ...state, + }), + }), + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts new file mode 100644 index 000000000000..626a375fe574 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server + // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. + // Leaving it `undefined` otherwise keeps the SDK's default (no provider). + enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/cache-test.ts new file mode 100644 index 000000000000..0fb4ace46bd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static/server/api/db-ioredis.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-ioredis.ts new file mode 100644 index 000000000000..d623fa65a27e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-ioredis.ts @@ -0,0 +1,17 @@ +import { defineEventHandler } from '#imports'; +import Redis from 'ioredis'; + +export default defineEventHandler(async () => { + const redis = new Redis({ + // Don't keep retrying forever if Redis goes away (e.g. on test teardown) + maxRetriesPerRequest: 1, + retryStrategy: () => null, + }); + + try { + await redis.set('test-key', 'test-value'); + return await redis.get('test-key'); + } finally { + redis.disconnect(); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-multi-test.ts new file mode 100644 index 000000000000..53f110c1ce28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static/server/api/db-mysql.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-mysql.ts new file mode 100644 index 000000000000..5c05d2062105 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-mysql.ts @@ -0,0 +1,27 @@ +import { defineEventHandler } from '#imports'; +import mysql from 'mysql'; + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +export default defineEventHandler(() => { + return new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', error => { + if (error) { + reject(error); + return; + } + + connection.query('SELECT NOW()', ['1', '2'], nestedError => { + if (nestedError) { + reject(nestedError); + return; + } + + resolve({ status: 'ok' }); + }); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-test.ts new file mode 100644 index 000000000000..4460758ab414 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static/server/api/flush-regression.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/flush-regression.ts new file mode 100644 index 000000000000..1a0b39097710 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/flush-regression.ts @@ -0,0 +1,9 @@ +import { defineEventHandler } from '#imports'; +import * as Sentry from '@sentry/nuxt'; + +export default defineEventHandler(() => { + // Seed a pending client-report outcome without sending an event, so the test can observe whether the + // SDK wrongly flushes (and ships) it per HTTP response instead of aggregating it. + Sentry.getClient()?.recordDroppedEvent('before_send', 'error'); + return { seeded: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/middleware-test.ts new file mode 100644 index 000000000000..8973690e6adb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/middleware-test.ts @@ -0,0 +1,15 @@ +import { defineEventHandler, getHeader } from '#imports'; + +export default defineEventHandler(async event => { + // Simple API endpoint that will trigger all server middleware + return { + message: 'Server middleware test endpoint', + path: event.path, + method: event.method, + headers: { + 'x-first-middleware': getHeader(event, 'x-first-middleware'), + 'x-second-middleware': getHeader(event, 'x-second-middleware'), + 'x-auth-middleware': getHeader(event, 'x-auth-middleware'), + }, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/nitro-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/nitro-fetch.ts new file mode 100644 index 000000000000..6864ce6efafc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/nitro-fetch.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(async () => { + return await $fetch('https://example.com'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/param-error/[param].ts new file mode 100644 index 000000000000..49c4cee110a0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(_e => { + throw new Error('Nuxt 4 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/server-error.ts new file mode 100644 index 000000000000..0bc3db14fb69 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/server-error.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(event => { + throw new Error('Nuxt 4 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static/server/api/test-param/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/test-param/[param].ts new file mode 100644 index 000000000000..1867874cd494 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/test-param/[param].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const param = getRouterParam(event, 'param'); + + return `Param: ${param}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/user/[userId].ts new file mode 100644 index 000000000000..eb268d2f3e13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/01.first.ts new file mode 100644 index 000000000000..b146c42e3483 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/01.first.ts @@ -0,0 +1,6 @@ +import { defineEventHandler, setHeader } from '#imports'; + +export default defineEventHandler(async event => { + // Set a header to indicate this middleware ran + setHeader(event, 'x-first-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/02.second.ts new file mode 100644 index 000000000000..3b665d48fc5a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/02.second.ts @@ -0,0 +1,7 @@ +import { eventHandler, setHeader } from '#imports'; + +// tests out the eventHandler alias +export default eventHandler(async event => { + // Set a header to indicate this middleware ran + setHeader(event, 'x-second-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/03.auth.ts new file mode 100644 index 000000000000..6dcd9a075589 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/03.auth.ts @@ -0,0 +1,12 @@ +import { defineEventHandler, setHeader, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + // Check if we should throw an error + const query = getQuery(event); + if (query.throwError === 'true') { + throw new Error('Auth middleware error'); + } + + // Set a header to indicate this middleware ran + setHeader(event, 'x-auth-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/04.hooks.ts new file mode 100644 index 000000000000..1f9cf40a1c02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/04.hooks.ts @@ -0,0 +1,36 @@ +import { defineEventHandler, setHeader, getQuery } from '#imports'; + +export default defineEventHandler({ + onRequest: async event => { + // Set a header to indicate the onRequest hook ran + setHeader(event, 'x-hooks-onrequest', 'executed'); + + // Check if we should throw an error in onRequest + const query = getQuery(event); + if (query.throwOnRequestError === 'true') { + throw new Error('OnRequest hook error'); + } + }, + + handler: async event => { + // Set a header to indicate the main handler ran + setHeader(event, 'x-hooks-handler', 'executed'); + + // Check if we should throw an error in handler + const query = getQuery(event); + if (query.throwHandlerError === 'true') { + throw new Error('Handler error'); + } + }, + + onBeforeResponse: async (event, response) => { + // Set a header to indicate the onBeforeResponse hook ran + setHeader(event, 'x-hooks-onbeforeresponse', 'executed'); + + // Check if we should throw an error in onBeforeResponse + const query = getQuery(event); + if (query.throwOnBeforeResponseError === 'true') { + throw new Error('OnBeforeResponse hook error'); + } + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/05.array-hooks.ts new file mode 100644 index 000000000000..cc815bfb2fbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/05.array-hooks.ts @@ -0,0 +1,47 @@ +import { defineEventHandler, setHeader, getQuery } from '#imports'; + +export default defineEventHandler({ + // Array of onRequest handlers + onRequest: [ + async event => { + setHeader(event, 'x-array-onrequest-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest0Error === 'true') { + throw new Error('OnRequest[0] hook error'); + } + }, + async event => { + setHeader(event, 'x-array-onrequest-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest1Error === 'true') { + throw new Error('OnRequest[1] hook error'); + } + }, + ], + + handler: async event => { + setHeader(event, 'x-array-handler', 'executed'); + }, + + // Array of onBeforeResponse handlers + onBeforeResponse: [ + async (event, response) => { + setHeader(event, 'x-array-onbeforeresponse-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse0Error === 'true') { + throw new Error('OnBeforeResponse[0] hook error'); + } + }, + async (event, response) => { + setHeader(event, 'x-array-onbeforeresponse-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse1Error === 'true') { + throw new Error('OnBeforeResponse[1] hook error'); + } + }, + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/tsconfig.json new file mode 100644 index 000000000000..b9ed69c19eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-static/start-event-proxy.mjs new file mode 100644 index 000000000000..c6547b7ab1f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-4-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/stores/cart.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/stores/cart.ts new file mode 100644 index 000000000000..cad52916ac25 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/stores/cart.ts @@ -0,0 +1,43 @@ +import { acceptHMRUpdate, defineStore } from '#imports'; + +export const useCartStore = defineStore({ + id: 'cart', + state: () => ({ + rawItems: [] as string[], + }), + getters: { + items: (state): Array<{ name: string; amount: number }> => + state.rawItems.reduce( + (items: any, item: any) => { + const existingItem = items.find((it: any) => it.name === item); + + if (!existingItem) { + items.push({ name: item, amount: 1 }); + } else { + existingItem.amount++; + } + + return items; + }, + [] as Array<{ name: string; amount: number }>, + ), + }, + actions: { + addItem(name: string) { + this.rawItems.push(name); + }, + + removeItem(name: string) { + const i = this.rawItems.lastIndexOf(name); + if (i > -1) this.rawItems.splice(i, 1); + }, + + throwError() { + throw new Error('error'); + }, + }, +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot)); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/build-injection.test.ts new file mode 100644 index 000000000000..796a7538c0ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/build-injection.test.ts @@ -0,0 +1,45 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +function readServerBundle(): string { + const serverDir = path.join(process.cwd(), '.output/server'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.mjs')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +function readClientBundle(): string { + const serverDir = path.join(process.cwd(), '.output/public'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.js')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +test.describe('Orchestrion build-time injection', () => { + const serverBundle = readServerBundle(); + const clientBundle = readClientBundle(); + + test('force-bundles instrumented dependencies', () => { + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']standard-as-callback["']|require\(["']standard-as-callback["']\))/); + }); + + test('injects diagnostics-channel publishers into the server build', () => { + expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + // Each transformed module carries the module-injected snippet that records it + // on the global marker (and registers its subscriber factory) when evaluated. + expect(serverBundle).toContain('orchestrionModuleInjected'); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('does not inject diagnostics-channel publishers into the client build', () => { + expect(clientBundle).not.toContain('__SENTRY_ORCHESTRION__'); + expect(clientBundle).not.toMatch(/orchestrion:/); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/cache.test.ts new file mode 100644 index 000000000000..d8ad57bca377 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/cache.test.ts @@ -0,0 +1,157 @@ +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-static', 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 findSpansByMethod = (method: string) => { + return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; + }; + + // 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 = findSpansByMethod('getItem'); + 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', + [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', + [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 = findSpansByMethod('setItem'); + 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.put', + [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-static', 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'); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.put'); + + // 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-static/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database-multi.test.ts new file mode 100644 index 000000000000..86f177c155d0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static', 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-static', 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-static', 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-static', 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-static', 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-static', 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-static/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database.test.ts new file mode 100644 index 000000000000..a046d2924bef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database.test.ts @@ -0,0 +1,201 @@ +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-static', 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-static', 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-static', 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-static', 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-static', 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-static', 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-static', errorEvent => { + return !!errorEvent?.exception?.values?.some( + value => value.mechanism?.type === 'auto.db.nuxt' && value.value?.includes('no such table'), + ); + }); + + const transactionPromise = waitForTransaction('nuxt-4-static', 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]); + + const dbException = error.exception?.values?.find(value => value.mechanism?.type === 'auto.db.nuxt'); + + expect(dbException).toBeDefined(); + expect(dbException?.value).toContain('no such table'); + expect(dbException?.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-static', 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-static', 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-static/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/db-drivers.test.ts new file mode 100644 index 000000000000..5ef0c290e946 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/db-drivers.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// The Nuxt module auto-wires the orchestrion build-time transform, which injects +// `diagnostics_channel` publishers into these drivers as Nitro bundles them. That +// only happens in the production build, so these tests are excluded from the +// `test:dev` pass (which filters to `environment`). +test('Instruments ioredis automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-ioredis' + ); + }); + + const response = await fetch(`${baseURL}/api/db-ioredis`); + expect(response.status).toBe(200); + expect(await response.text()).toBe('test-value'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /api/db-ioredis'); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'set', + 'db.query.text': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'get', + 'db.query.text': 'get test-key', + }), + }), + ); +}); + +test('Instruments mysql automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mysql' + ); + }); + + const response = await fetch(`${baseURL}/api/db-mysql`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'ok' }); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/environment.test.ts new file mode 100644 index 000000000000..b293230cab44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/environment.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test.describe('environment detection', async () => { + test('sets correct environment for client-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 E2E test app'; + }); + + // We have to wait for networkidle in dev mode because clicking the button is a no-op otherwise (network requests are blocked during page load) + await page.goto(`/client-error`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for client-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const transaction = await transactionPromise; + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toBe('GET /api/server-error'); + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace.op).toBe('http.server'); + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts new file mode 100644 index 000000000000..1c747e73fe77 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', async () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('/client-error'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-4 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('captures error thrown in NuxtErrorBoundary', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown in Error Boundary'; + }); + + await page.goto(`/client-error`); + await page.locator('#error-in-error-boundary').click(); + + const error = await errorPromise; + + const expectedBreadcrumb = { + category: 'console', + message: 'Additional functionality in NuxtErrorBoundary', + }; + + const matchingBreadcrumb = error.breadcrumbs.find( + (breadcrumb: { category: string; message: string }) => + breadcrumb.category === expectedBreadcrumb.category && breadcrumb.message === expectedBreadcrumb.message, + ); + + expect(matchingBreadcrumb).toBeTruthy(); + expect(matchingBreadcrumb?.category).toBe(expectedBreadcrumb.category); + expect(matchingBreadcrumb?.message).toBe(expectedBreadcrumb.message); + + expect(error.transaction).toEqual('/client-error'); + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown in Error Boundary', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('shows parametrized route on button error', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Param Route Button'; + }); + + await page.goto(`/test-param/1234`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error.transaction).toEqual('/test-param/:param()'); + expect(error.request.url).toMatch(/\/test-param\/1234/); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Param Route Button', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from Nuxt-4 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-4 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from Nuxt-4 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts new file mode 100644 index 000000000000..b7dce20b0714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', async () => { + test('captures api fetch error (fetched on click)', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/server-error'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Server error'); + expect(exception0.mechanism).toEqual({ + handled: true, + type: 'chained', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('Error'); + expect(exception1.value).toEqual('Nuxt 4 Server error'); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); + }); + + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Param Server error'; + }); + + await page.goto(`/test-param/1234`); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/param-error/1234'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Param Server error'); + expect(exception0.mechanism).toEqual({ + handled: true, + type: 'chained', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('Error'); + expect(exception1.value).toEqual('Nuxt 4 Param Server error'); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/flush-regression.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/flush-regression.test.ts new file mode 100644 index 000000000000..831eb85c2ab3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/flush-regression.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/23306 +// +// The httpIntegration `responseHook` used to call `vercelWaitUntil(flushSafelyWithTimeout())`, which +// evaluates the flush eagerly regardless of platform. On a long-running server that meant `flush()` ran +// on every HTTP response, shipping each pending outcome as its own `client_report` envelope instead of +// aggregating it on the flush interval. +test('does not ship a client_report per HTTP response', async ({ request }) => { + const requestCount = 5; + + // Resolve as soon as a second client_report is observed. A single one is tolerated because the 60s + // client-report interval may happen to flush the seeded outcomes once during the window. + let seen = 0; + const twoReportsSeen = waitForEnvelopeItem('nuxt-4-static', item => item[0].type === 'client_report' && ++seen >= 2) + .then(() => 'two-or-more' as const) + .catch(() => 'two-or-more' as const); + + // Each request seeds one pending outcome (see server/api/flush-regression.ts). + for (let i = 0; i < requestCount; i++) { + await request.get('/api/flush-regression'); + } + + // Buggy build: each response flushes -> one client_report per request, so a second arrives within ms. + // Fixed build: flushIfServerless no-ops on a long-running server, so the outcomes aggregate and at + // most the single interval flush ships within this window. + const result = await Promise.race([ + twoReportsSeen, + new Promise<'at-most-one'>(resolve => setTimeout(() => resolve('at-most-one'), 5000)), + ]); + + expect(result).toBe('at-most-one'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/middleware.test.ts new file mode 100644 index 000000000000..5d3d1246d0e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/middleware.test.ts @@ -0,0 +1,331 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +test.describe('Server Middleware Instrumentation', () => { + test('should create separate spans for each server middleware', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to the API endpoint that will trigger all server middleware + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const responseData = await response.json(); + expect(responseData.message).toBe('Server middleware test endpoint'); + + const serverTxnEvent = await serverTxnEventPromise; + + // Verify that we have spans for each middleware + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse + expect(middlewareSpans).toHaveLength(11); + + // Check for specific middleware spans + const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); + const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second'); + const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth'); + const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + const arrayHooksHandlerSpan = middlewareSpans.find( + span => span.data?.['nuxt.middleware.name'] === '05.array-hooks', + ); + + expect(firstMiddlewareSpan).toBeDefined(); + expect(secondMiddlewareSpan).toBeDefined(); + expect(authMiddlewareSpan).toBeDefined(); + expect(hooksOnRequestSpan).toBeDefined(); + expect(arrayHooksHandlerSpan).toBeDefined(); + + // Verify each span has the correct attributes + [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { + expect(span).toEqual( + expect.objectContaining({ + op: 'middleware', + data: expect.objectContaining({ + 'sentry.op': 'middleware', + 'sentry.origin': 'auto.middleware.nuxt', + 'http.request.method': 'GET', + 'http.route': '/api/middleware-test', + }), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); + }); + + // Verify spans have different span IDs (each middleware gets its own span) + const spanIds = middlewareSpans.map(span => span.span_id); + const uniqueSpanIds = new Set(spanIds); + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) + expect(uniqueSpanIds.size).toBe(11); + + // Verify spans share the same trace ID + const traceIds = middlewareSpans.map(span => span.trace_id); + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(1); + }); + + test('middleware spans should have proper parent-child relationship', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + await request.get('/api/middleware-test'); + const serverTxnEvent = await serverTxnEventPromise; + + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // All middleware spans should be children of the main transaction + middlewareSpans.forEach(span => { + expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); + }); + + test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; + }); + + // Make request with query param to trigger error in auth middleware + const response = await request.get('/api/middleware-test?throwError=true'); + + // The request should fail due to the middleware error + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the auth middleware span + const authMiddlewareSpan = serverTxnEvent.spans?.find( + span => span.op === 'middleware' && span.data?.['nuxt.middleware.name'] === '03.auth', + ); + + expect(authMiddlewareSpan).toBeDefined(); + + // Verify the span has error status + expect(authMiddlewareSpan?.status).toBe('internal_error'); + + // Verify the error event is associated with the correct transaction + expect(errorEvent.transaction).toContain('GET /api/middleware-test'); + + // Verify the error has the correct mechanism + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + value: 'Auth middleware error', + type: 'Error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.middleware.nuxt', + }), + }), + ); + }); + + test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // Find spans for the hooks middleware + const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + + // Should have spans for onRequest, handler, and onBeforeResponse + expect(hooksSpans).toHaveLength(3); + + // Find specific hook spans + const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const onBeforeResponseSpan = hooksSpans.find( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onRequestSpan).toBeDefined(); + expect(handlerSpan).toBeDefined(); + expect(onBeforeResponseSpan).toBeDefined(); + + // Verify span names include hook types + expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); + expect(handlerSpan?.description).toBe('04.hooks'); + expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); + + // Verify all spans have correct middleware name (without hook suffix) + [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { + expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); + }); + + // Verify hook-specific attributes + expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); + expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); + expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); + + // Verify no index attributes for single hooks + expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should create spans with index attributes for array hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with array hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // Find spans for the array hooks middleware + const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); + + // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans + expect(arrayHooksSpans).toHaveLength(5); + + // Find onRequest array spans + const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + expect(onRequestSpans).toHaveLength(2); + + // Find onBeforeResponse array spans + const onBeforeResponseSpans = arrayHooksSpans.filter( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + expect(onBeforeResponseSpans).toHaveLength(2); + + // Find handler span + const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + expect(handlerSpan).toBeDefined(); + + // Verify index attributes for onRequest array + const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest1Span).toBeDefined(); + + // Verify index attributes for onBeforeResponse array + const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onBeforeResponse0Span).toBeDefined(); + expect(onBeforeResponse1Span).toBeDefined(); + + // Verify span names for array handlers + expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); + expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); + expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); + expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + + // Verify handler has no index + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should handle errors in onRequest hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; + }); + + // Make request with query param to trigger error in onRequest + const response = await request.get('/api/middleware-test?throwOnRequestError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onRequest span that should have error status + const onRequestSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + ); + + expect(onRequestSpan).toBeDefined(); + expect(onRequestSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); + }); + + test('should handle errors in onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; + }); + + // Make request with query param to trigger error in onBeforeResponse + const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onBeforeResponse span that should have error status + const onBeforeResponseSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onBeforeResponseSpan).toBeDefined(); + expect(onBeforeResponseSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); + }); + + test('should handle errors in array hooks with proper index attribution', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; + }); + + // Make request with query param to trigger error in second onRequest handler + const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the second onRequest span that should have error status + const onRequest1Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 1, + ); + + expect(onRequest1Span).toBeDefined(); + expect(onRequest1Span?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); + + // Verify the first onRequest handler still executed successfully + const onRequest0Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 0, + ); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest0Span?.status).not.toBe('internal_error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/pinia.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/pinia.test.ts new file mode 100644 index 000000000000..2af7a89544b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/pinia.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('sends pinia action breadcrumbs and state context', async ({ page }) => { + await page.goto('/pinia-cart'); + + await page.locator('#item-input').fill('item'); + await page.locator('#item-add').click(); + + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0].value === 'This is an error'; + }); + + await page.locator('#throw-error').click(); + + const error = await errorPromise; + + expect(error).toBeTruthy(); + expect(error.breadcrumbs?.length).toBeGreaterThan(0); + + const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'pinia.action'); + + expect(actionBreadcrumb).toBeDefined(); + expect(actionBreadcrumb?.message).toBe('Store: cart | Action: addItem.transformed'); + expect(actionBreadcrumb?.level).toBe('info'); + + const stateContext = error.contexts?.state?.state; + + expect(stateContext).toBeDefined(); + expect(stateContext?.type).toBe('pinia'); + expect(stateContext?.value).toEqual({ + transformed: true, + cart: { rawItems: ['item'] }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..ee1950050482 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static', 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 findSpansByMethod = (method: string) => { + return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByMethod('setItem'); + 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.put', + [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 = findSpansByMethod('getItem'); + 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', + [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 = findSpansByMethod('hasItem'); + 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.get', + [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 = findSpansByMethod('removeItem'); + 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', + [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', + [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-static/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage.test.ts new file mode 100644 index 000000000000..b4138a8eb6e8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-static', 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 findSpansByMethod = (method: string) => { + return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByMethod('setItem'); + 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.put', + [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 = findSpansByMethod('setItemRaw'); + 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.put', + [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 = findSpansByMethod('hasItem'); + 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.get', + [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 = findSpansByMethod('getItem'); + 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', + [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 = findSpansByMethod('getItemRaw'); + 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', + [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 = findSpansByMethod('getKeys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [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 = findSpansByMethod('removeItem'); + 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', + [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 = findSpansByMethod('clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', + [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-static/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.cached-html.test.ts new file mode 100644 index 000000000000..ab5a31a7410a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.cached-html.test.ts @@ -0,0 +1,206 @@ +import { expect, test, type Page } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Rendering Modes with Cached HTML', () => { + test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/client-side-only-page', 'Client Side Only Page'); + }); + + test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-cached-page', 'ISR Cached Page'); + }); + + test('changes tracing meta tags with multiple requests on 1h ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); + }); + + test('exclude tracing meta tags on SWR-cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page'); + }); + + test('exclude tracing meta tags on SWR 1h cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page'); + }); + + test('exclude tracing meta tags on pre-rendered page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); + }); +}); + +/** + * Tests that tracing meta-tags change with multiple requests on ISR-cached pages + * This utility handles the common pattern of: + * 1. Making two requests to an ISR-cached page + * 2. Verifying tracing meta-tags are present and change between requests + * 3. Verifying distributed tracing works correctly for both requests + * 4. Verifying trace IDs are different between requests + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/isr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'ISR Cached Page') + */ +export async function testChangingTracingMetaTagsOnISRPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || []; + + // === 2. Request === + + const clientTxnEventPromise2 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise2 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise2, + serverTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id; + + await test.step('Test distributed trace from 1. request', () => { + expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`); + + expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId); + expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id); + expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1); + }); + + await test.step('Test distributed trace from 2. request', () => { + expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`); + + expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId); + expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id); + expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2); + }); + + await test.step('Test that trace IDs from subsequent requests are different', () => { + // Different trace IDs for the server transactions + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId); + expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2); + }); +} + +/** + * Tests that tracing meta-tags are excluded on cached pages (SWR, pre-rendered, etc.) + * This utility handles the common pattern of: + * 1. Making two requests to a cached page + * 2. Verifying no tracing meta-tags are present + * 3. Verifying only the first request creates a server transaction + * 4. Verifying traces are not distributed + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') + * @returns Object containing transaction events for additional custom assertions + */ +export async function testExcludeTracingMetaTagsOnCachedPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + // Only the 1. request creates a server transaction + const serverTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + // Verify no baggage and sentry-trace meta-tags are present on first request + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + // === 2. Request === + + await page.goto(routePath); + + const clientTxnEventPromise2 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + let serverTxnEvent2 = undefined; + const serverTxnEventPromise2 = Promise.race([ + waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)), + ]); + + try { + serverTxnEvent2 = await serverTxnEventPromise2; + throw new Error('Second server transaction should not have been sent'); + } catch (error) { + expect(error.message).toBe('No second server transaction expected'); + } + + const [clientTxnEvent2] = await Promise.all([ + clientTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id; + const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id; + + await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + }); + + await test.step('1. Server Transaction and all Client Transactions are defined', () => { + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent2TraceId).toBeDefined(); + expect(serverTxnEvent2).toBeUndefined(); + expect(serverTxnEvent2TraceId).toBeUndefined(); + }); + + await test.step('Trace is not distributed', () => { + // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) + expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId); + expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts new file mode 100644 index 000000000000..81121e0de396 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { Span } from '@sentry/nuxt'; + +test('sends a pageload root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.segment.name.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/test-param/:param()' + ); + }); + + await page.goto(`/`); + await page.getByText('Fetch Param').click(); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.segment.name.source': 'route', + 'sentry.origin': 'auto.navigation.vue', + 'sentry.op': 'navigation', + 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), + }, + op: 'navigation', + origin: 'auto.navigation.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === '/client-error'; + }); + + await page.goto(`/client-error`); + + const rootSpan = await transactionPromise; + const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + + const expected = { + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount' }, + description: 'Vue ', + op: 'ui.mount', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.ui.vue', + }; + + expect(errorButtonSpan).toMatchObject(expected); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.server.test.ts new file mode 100644 index 000000000000..2bc3c805f172 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.server.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.http_server', + }), + }), + ); +}); + +test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { + let buildAssetFolderOccurred = false; + + waitForTransaction('nuxt-4-static', transactionEvent => { + if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + buildAssetFolderOccurred = true; + } + return false; // expects to return a boolean (but not relevant here) + }); + + const transactionEventPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transactionEvent = await transactionEventPromise; + + expect(buildAssetFolderOccurred).toBe(false); + + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); +}); + +test('captures server API calls made with Nitro $fetch', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const httpServerFetchSpan = await transactionPromise; + const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + + expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); + expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + + expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); + expect(httpClientSpan.op).toEqual('http.client'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.test.ts new file mode 100644 index 000000000000..e527e5555856 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('distributed tracing', () => { + const PARAM = 's0me-param'; + + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/') ?? false; + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: `GET /test-param/:param()`, // parametrized route + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.http_server', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + }); + + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + 'url.full': expect.stringContaining(`/api/user/${PARAM}`), + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.request.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.http_server', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.http_server', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 0bab1499bcde..7b5d97ff0b09 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nuxt'; import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ - traceLifecycle: 'static', dsn: useRuntimeConfig().public.sentry.dsn, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index 626a375fe574..50ea077473eb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server 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 index 4460758ab414..4fa90c6e005d 100644 --- 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 @@ -59,6 +59,11 @@ export default defineEventHandler(async event => { } case 'error': { + // A successful query runs first so the captured error carries a query breadcrumb: with span + // streaming there is no transaction event left to read breadcrumbs off. + await db.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); await stmt.get(1); return { success: false, message: 'Should have thrown an error' }; 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 index 47a0799c736c..2151f91d8479 100644 --- 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 @@ -1,33 +1,37 @@ 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'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; test.describe('Cache Instrumentation', () => { const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + async function collectCacheSpans() { + const spans = await collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test'); + + return spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt', + ); + } + 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 cacheSpansPromise = collectCacheSpans(); const response = await request.get('/api/cache-test'); expect(response.status()).toBe(200); - const transaction = await transactionPromise; + const allCacheSpans = await cacheSpansPromise; + expect(allCacheSpans.length).toBeGreaterThan(0); // Helper to find spans by operation - const findSpansByMethod = (method: string) => { - return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; - }; + const findSpansByMethod = (method: string) => + allCacheSpans.filter(span => span.attributes['db.operation.name']?.value === method); - // 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); + const getCacheKey = (span: (typeof allCacheSpans)[number]) => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value; // Test getItem spans for cachedFunction - should have both cache miss and cache hit const getItemSpans = findSpansByMethod('getItem'); @@ -36,34 +40,36 @@ test.describe('Cache Instrumentation', () => { // 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], + typeof getCacheKey(span) === 'string' && + (getCacheKey(span) as string).includes('user:123') && + !span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value, ); if (cacheMissSpan) { - expect(cacheMissSpan.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, - 'db.operation.name': 'getItem', - 'db.collection.name': expect.stringMatching(/^(cache)?$/), + expect(cacheMissSpan.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: false }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: 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], + typeof getCacheKey(span) === 'string' && + (getCacheKey(span) as string).includes('user:123') && + span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value, ); if (cacheHitSpan) { - expect(cacheHitSpan.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, - 'db.operation.name': 'getItem', - 'db.collection.name': expect.stringMatching(/^(cache)?$/), + expect(cacheHitSpan.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: expect.stringMatching(/^(cache)?$/) }, }); } @@ -72,42 +78,34 @@ test.describe('Cache Instrumentation', () => { 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'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).includes('user:123'), ); if (cacheSetSpan) { - expect(cacheSetSpan.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - 'db.operation.name': 'setItem', - 'db.collection.name': expect.stringMatching(/^(cache)?$/), + expect(cacheSetSpan.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + 'db.operation.name': { type: 'string', value: 'setItem' }, + 'db.collection.name': { type: 'string', value: 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'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).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'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).includes('cachedHandler'), ); expect(cachedHandlerSpans.length).toBeGreaterThan(0); - // Verify all cache spans have OK status - allCacheSpans?.forEach(span => { + // Verify all cache spans have OK status and are nested under the request's root span + allCacheSpans.forEach(span => { expect(span.status).toBe('ok'); - }); - - // Verify cache spans are properly nested under the transaction - allCacheSpans?.forEach(span => { + expect(span.is_segment).toBe(false); expect(span.parent_span_id).toBeDefined(); }); }); @@ -117,41 +115,38 @@ test.describe('Cache Instrumentation', () => { 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; - }); + const cacheSpansPromise = collectCacheSpans(); 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', - ); + const allCacheSpans = await cacheSpansPromise; // We should have cache operations - expect(allCacheSpans?.length).toBeGreaterThan(0); + expect(allCacheSpans.length).toBeGreaterThan(0); // Get all getItem operations - const allGetItemSpans = allCacheSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get'); + const allGetItemSpans = allCacheSpans.filter(span => span.attributes['sentry.op']?.value === 'cache.get'); // Get all setItem operations - const allSetItemSpans = allCacheSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.put'); + const allSetItemSpans = allCacheSpans.filter(span => span.attributes['sentry.op']?.value === 'cache.put'); // We should have both get and set operations - expect(allGetItemSpans?.length).toBeGreaterThan(0); - expect(allSetItemSpans?.length).toBeGreaterThan(0); + 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); + const cacheMissSpans = allGetItemSpans.filter( + span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value === false, + ); // Check for cache hits (cache.hit = true) - const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + const cacheHitSpans = allGetItemSpans.filter(span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value === true); // We should have at least one cache miss (first calls to getCachedUser and getCachedData) - expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + expect(cacheMissSpans.length).toBeGreaterThanOrEqual(1); // We should have at least one cache hit (second calls to getCachedUser and getCachedData) - expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + 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 index 9d995fa1b37c..6abe2528ed31 100644 --- 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 @@ -1,156 +1,155 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +async function collectDbSpans() { + const spans = await collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-multi-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-multi-test'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id && getSpanOp(span) === 'db.query'); +} 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'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=default-db'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const selectSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(selectSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT default_table' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM default_table WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'sentry.origin': { type: 'string', value: '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'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=users-db'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const selectSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(selectSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT user_profiles' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM user_profiles WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + 'sentry.origin': { type: 'string', value: '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'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=analytics-db'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const selectSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(selectSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT events' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM events WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, + 'sentry.origin': { type: 'string', value: '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'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=multiple-dbs'); - const transaction = await transactionPromise; + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const sessionSpan = dbSpans.find(span => span.name === 'SELECT sessions'); + const accountSpan = dbSpans.find(span => span.name === 'SELECT accounts'); + const metricSpan = dbSpans.find(span => span.name === 'SELECT metrics'); - 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'); + // Each instance keeps its own namespace, while the span name stays low cardinality + expect(sessionSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT sessions' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM sessions WHERE id = ?' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); + expect(accountSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT accounts' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM accounts WHERE id = ?' }, + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); + expect(metricSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT metrics' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM metrics WHERE id = ?' }, + 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); - // 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'); + // All spans belong to the same trace as the request's root span + const traceIds = new Set(dbSpans.map(span => span.trace_id)); + expect(traceIds.size).toBe(1); }); test('instruments SQL template tag across multiple databases', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const logsInsertSpan = dbSpans.find(span => span.name === 'INSERT logs'); + const auditLogsInsertSpan = dbSpans.find(span => span.name === 'INSERT 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(logsInsertSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT logs' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO logs (message) VALUES (?)' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'sentry.origin': { type: 'string', value: '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'); + expect(auditLogsInsertSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT audit_logs' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO audit_logs (action) VALUES (?)' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + 'sentry.origin': { type: 'string', value: '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'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=multiple-dbs'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; // 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); + 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 index a74df938bc32..9902a02a7424 100644 --- 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 @@ -1,129 +1,128 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; + +async function collectDbSpans() { + const spans = await collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-test'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id && getSpanOp(span) === 'db.query'); +} 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'; - }); + const dbSpansPromise = collectDbSpans(); 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')); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'SELECT users'); 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'); + expect(dbSpan?.status).toBe('ok'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT users' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.prepare().all() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'SELECT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT products' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM products WHERE price > ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.prepare().run() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'INSERT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT orders' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO orders (customer, amount) VALUES (?, ?)' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: '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'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'SELECT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT items' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM items WHERE category = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: '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'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'INSERT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT messages' }, + 'db.query.text': { + type: 'string', + value: 'INSERT INTO messages (content, created_at) VALUES (?, ?)', + }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.exec() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-test?method=exec'); - const transaction = await transactionPromise; + const dbSpans = await dbSpansPromise; + const insertSpan = dbSpans.find(span => span.name === 'INSERT logs'); - const dbSpan = transaction.spans?.find( - span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), - ); + expect(insertSpan).toBeDefined(); + expect(insertSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT logs' }, + 'db.query.text': { type: 'string', value: `INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')` }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); - 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'); + // DDL statements are summarized as `{operation} {table}` as well, with the statement on the attribute + expect(dbSpans.find(span => span.name === 'DROP TABLE logs')?.attributes).toMatchObject({ + 'db.query.text': { type: 'string', value: 'DROP TABLE IF EXISTS logs' }, + }); + expect(dbSpans.find(span => span.name === 'CREATE TABLE logs')?.attributes).toMatchObject({ + 'db.query.text': { + type: 'string', + value: 'CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)', + }, + }); }); test('captures database error and marks span as failed', async ({ request }) => { @@ -133,15 +132,13 @@ test.describe('database integration', () => { ); }); - const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-test?method=error').catch(() => { // Expected to fail }); - const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + const [error, dbSpans] = await Promise.all([errorPromise, dbSpansPromise]); const dbException = error.exception?.values?.find(value => value.mechanism?.type === 'auto.db.nuxt'); @@ -152,50 +149,46 @@ test.describe('database integration', () => { type: 'auto.db.nuxt', }); - const dbSpan = transaction.spans?.find( - span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), - ); + const dbSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(dbSpan?.status).toBe('error'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT nonexistent_table' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM nonexistent_table WHERE invalid_column = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures breadcrumb for db.exec() queries', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; + const errorPromise = waitForError('nuxt-4', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.mechanism?.type === 'auto.db.nuxt'); }); - await request.get('/api/db-test?method=exec'); + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); - const transaction = await transactionPromise; + const error = await errorPromise; - const dbBreadcrumb = transaction.breadcrumbs?.find( + const dbBreadcrumb = error.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'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-test?method=prepare-get'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; - expect(dbSpans).toBeDefined(); - expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + expect(dbSpans.length).toBeGreaterThanOrEqual(1); + expect(dbSpans.every(span => !span.is_segment)).toBe(true); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts index a4ef30dc0367..455632b544fb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts @@ -1,100 +1,114 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; // The Nuxt module auto-wires the orchestrion build-time transform, which injects // `diagnostics_channel` publishers into these drivers as Nitro bundles them. That // only happens in the production build, so these tests are excluded from the // `test:dev` pass (which filters to `environment`). + +// Exact-match API routes keep a bare method-only segment name under h3 v1, so the +// segment is selected via its `url.path` attribute. Driver spans can flush before +// the segment, so accumulate until the segment arrives and filter by its trace. +async function collectRequestSpans(path: string) { + const spans = await collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === path), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === path); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id); +} + test('Instruments ioredis automatically', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nuxt-4', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-ioredis' - ); - }); + const spansPromise = collectRequestSpans('/api/db-ioredis'); const response = await fetch(`${baseURL}/api/db-ioredis`); expect(response.status).toBe(200); expect(await response.text()).toBe('test-value'); - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); - expect(transactionEvent.transaction).toEqual('GET /api/db-ioredis'); + const spans = await spansPromise; - const spans = transactionEvent.spans || []; + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan).toBeDefined(); + expect(getSpanOp(rootSpan!)).toBe('http.server'); expect(spans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'set test-key [1 other arguments]', + name: 'set localhost:6379', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'set', - 'db.query.text': 'set test-key [1 other arguments]', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.redis' }, + 'db.system.name': { type: 'string', value: 'redis' }, + 'db.operation.name': { type: 'string', value: 'set' }, + 'db.query.text': { type: 'string', value: 'set test-key [1 other arguments]' }, + 'server.address': { type: 'string', value: 'localhost' }, + 'server.port': { type: 'integer', value: 6379 }, }), }), ); expect(spans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'get test-key', + name: 'get localhost:6379', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'get', - 'db.query.text': 'get test-key', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.redis' }, + 'db.system.name': { type: 'string', value: 'redis' }, + 'db.operation.name': { type: 'string', value: 'get' }, + 'db.query.text': { type: 'string', value: 'get test-key' }, + 'server.address': { type: 'string', value: 'localhost' }, + 'server.port': { type: 'integer', value: 6379 }, }), }), ); }); test('Instruments mysql automatically', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nuxt-4', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mysql' - ); - }); + const spansPromise = collectRequestSpans('/api/db-mysql'); const response = await fetch(`${baseURL}/api/db-mysql`); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ status: 'ok' }); - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; + const spans = await spansPromise; + // Neither query has a FROM target, so the low-cardinality summary (and streamed + // span name) is just the operation. The raw SQL stays on `db.query.text`. expect(spans).toContainEqual( expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT 1 + 1 AS solution', + name: 'SELECT', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db' }, + 'sentry.origin': { type: 'string', value: 'auto.db.mysql' }, + 'db.system.name': { type: 'string', value: 'mysql' }, + 'db.query.text': { type: 'string', value: 'SELECT 1 + 1 AS solution' }, + 'db.query.summary': { type: 'string', value: 'SELECT' }, + 'db.user': { type: 'string', value: 'root' }, + 'db.connection_string': { type: 'string', value: expect.any(String) }, + 'server.address': { type: 'string', value: expect.any(String) }, + 'server.port': { type: 'integer', value: 3306 }, }), }), ); expect(spans).toContainEqual( expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT NOW()', + name: 'SELECT', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db' }, + 'sentry.origin': { type: 'string', value: 'auto.db.mysql' }, + 'db.system.name': { type: 'string', value: 'mysql' }, + 'db.query.text': { type: 'string', value: 'SELECT NOW()' }, + 'db.query.summary': { type: 'string', value: 'SELECT' }, + 'db.user': { type: 'string', value: 'root' }, + 'db.connection_string': { type: 'string', value: expect.any(String) }, + 'server.address': { type: 'string', value: expect.any(String) }, + 'server.port': { type: 'integer', value: 3306 }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts index b59e4560165b..35e626110382 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; test.describe('environment detection', async () => { @@ -21,19 +21,19 @@ test.describe('environment detection', async () => { } }); - test('sets correct environment for client-side transactions', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { - return transactionEvent.transaction === '/test-param/:param()'; + test('sets correct environment for client-side spans', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name === '/test-param/:param()'; }); await page.goto(`/test-param/1234`); - const transaction = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; if (isDevMode) { - expect(transaction.environment).toBe('development'); + expect(pageloadSpan.attributes['sentry.environment']?.value).toBe('development'); } else { - expect(transaction.environment).toBe('production'); + expect(pageloadSpan.attributes['sentry.environment']?.value).toBe('production'); } }); @@ -56,22 +56,22 @@ test.describe('environment detection', async () => { } }); - test('sets correct environment for server-side transactions', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { - return transactionEvent.transaction === 'GET /api/nitro-fetch'; + test('sets correct environment for server-side spans', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch'; }); await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); - const transaction = await transactionPromise; + const serverSpan = await serverSpanPromise; - expect(transaction.contexts.trace.op).toBe('http.server'); + expect(getSpanOp(serverSpan)).toBe('http.server'); if (isDevMode) { - expect(transaction.environment).toBe('development'); + expect(serverSpan.attributes['sentry.environment']?.value).toBe('development'); } else { - expect(transaction.environment).toBe('production'); + expect(serverSpan.attributes['sentry.environment']?.value).toBe('production'); } }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts index 05188318e755..8c0a618b353a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts @@ -1,11 +1,18 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; + +async function collectRequestSpans() { + const spans = await collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id); +} test.describe('Server Middleware Instrumentation', () => { test('should create separate spans for each server middleware', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); // Make request to the API endpoint that will trigger all server middleware const response = await request.get('/api/middleware-test'); @@ -14,22 +21,23 @@ test.describe('Server Middleware Instrumentation', () => { const responseData = await response.json(); expect(responseData.message).toBe('Server middleware test endpoint'); - const serverTxnEvent = await serverTxnEventPromise; + const spans = await spansPromise; // Verify that we have spans for each middleware - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); - // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) expect(middlewareSpans).toHaveLength(11); // Check for specific middleware spans - const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); - const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second'); - const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth'); - const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); - const arrayHooksHandlerSpan = middlewareSpans.find( - span => span.data?.['nuxt.middleware.name'] === '05.array-hooks', - ); + const findSpanByName = (name: string) => + middlewareSpans.find(span => span.attributes['nuxt.middleware.name']?.value === name); + + const firstMiddlewareSpan = findSpanByName('01.first'); + const secondMiddlewareSpan = findSpanByName('02.second'); + const authMiddlewareSpan = findSpanByName('03.auth'); + const hooksOnRequestSpan = findSpanByName('04.hooks'); + const arrayHooksHandlerSpan = findSpanByName('05.array-hooks'); expect(firstMiddlewareSpan).toBeDefined(); expect(secondMiddlewareSpan).toBeDefined(); @@ -41,12 +49,12 @@ test.describe('Server Middleware Instrumentation', () => { [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { expect(span).toEqual( expect.objectContaining({ - op: 'middleware', - data: expect.objectContaining({ - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nuxt', - 'http.request.method': 'GET', - 'http.route': '/api/middleware-test', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nuxt' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'http.route': { type: 'string', value: '/api/middleware-test' }, }), parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -68,25 +76,24 @@ test.describe('Server Middleware Instrumentation', () => { }); test('middleware spans should have proper parent-child relationship', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); await request.get('/api/middleware-test'); - const serverTxnEvent = await serverTxnEventPromise; + const spans = await spansPromise; - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const segmentSpan = spans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test', + ); + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); - // All middleware spans should be children of the main transaction + // All middleware spans should be children of the request's segment span middlewareSpans.forEach(span => { - expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(span.parent_span_id).toBe(segmentSpan?.span_id); }); }); test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-4', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; @@ -98,19 +105,19 @@ test.describe('Server Middleware Instrumentation', () => { // The request should fail due to the middleware error expect(response.status()).toBe(500); - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); // Find the auth middleware span - const authMiddlewareSpan = serverTxnEvent.spans?.find( - span => span.op === 'middleware' && span.data?.['nuxt.middleware.name'] === '03.auth', + const authMiddlewareSpan = spans.find( + span => getSpanOp(span) === 'middleware' && span.attributes['nuxt.middleware.name']?.value === '03.auth', ); expect(authMiddlewareSpan).toBeDefined(); // Verify the span has error status - expect(authMiddlewareSpan?.status).toBe('internal_error'); + expect(authMiddlewareSpan?.status).toBe('error'); - // Verify the error event is associated with the correct transaction + // Verify the error event is associated with the correct request expect(errorEvent.transaction).toContain('GET /api/middleware-test'); // Verify the error has the correct mechanism @@ -127,115 +134,118 @@ test.describe('Server Middleware Instrumentation', () => { }); test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); // Make request to trigger middleware with hooks const response = await request.get('/api/middleware-test'); expect(response.status()).toBe(200); - const serverTxnEvent = await serverTxnEventPromise; - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const spans = await spansPromise; + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); // Find spans for the hooks middleware - const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + const hooksSpans = middlewareSpans.filter(span => span.attributes['nuxt.middleware.name']?.value === '04.hooks'); // Should have spans for onRequest, handler, and onBeforeResponse expect(hooksSpans).toHaveLength(3); // Find specific hook spans - const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); - const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); - const onBeforeResponseSpan = hooksSpans.find( - span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', - ); + const findSpanByHook = (hook: string) => + hooksSpans.find(span => span.attributes['nuxt.middleware.hook.name']?.value === hook); + + const onRequestSpan = findSpanByHook('onRequest'); + const handlerSpan = findSpanByHook('handler'); + const onBeforeResponseSpan = findSpanByHook('onBeforeResponse'); expect(onRequestSpan).toBeDefined(); expect(handlerSpan).toBeDefined(); expect(onBeforeResponseSpan).toBeDefined(); // Verify span names include hook types - expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); - expect(handlerSpan?.description).toBe('04.hooks'); - expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); + expect(onRequestSpan?.name).toBe('04.hooks.onRequest'); + expect(handlerSpan?.name).toBe('04.hooks'); + expect(onBeforeResponseSpan?.name).toBe('04.hooks.onBeforeResponse'); // Verify all spans have correct middleware name (without hook suffix) [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { - expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); + expect(span?.attributes['nuxt.middleware.name']?.value).toBe('04.hooks'); }); // Verify hook-specific attributes - expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); - expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); - expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); + expect(onRequestSpan?.attributes['nuxt.middleware.hook.name']?.value).toBe('onRequest'); + expect(handlerSpan?.attributes['nuxt.middleware.hook.name']?.value).toBe('handler'); + expect(onBeforeResponseSpan?.attributes['nuxt.middleware.hook.name']?.value).toBe('onBeforeResponse'); // Verify no index attributes for single hooks - expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); - expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); - expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(onRequestSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); + expect(handlerSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); + expect(onBeforeResponseSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); }); test('should create spans with index attributes for array hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); // Make request to trigger middleware with array hooks const response = await request.get('/api/middleware-test'); expect(response.status()).toBe(200); - const serverTxnEvent = await serverTxnEventPromise; - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const spans = await spansPromise; + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); // Find spans for the array hooks middleware - const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); + const arrayHooksSpans = middlewareSpans.filter( + span => span.attributes['nuxt.middleware.name']?.value === '05.array-hooks', + ); // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans expect(arrayHooksSpans).toHaveLength(5); // Find onRequest array spans - const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const onRequestSpans = arrayHooksSpans.filter( + span => span.attributes['nuxt.middleware.hook.name']?.value === 'onRequest', + ); expect(onRequestSpans).toHaveLength(2); // Find onBeforeResponse array spans const onBeforeResponseSpans = arrayHooksSpans.filter( - span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + span => span.attributes['nuxt.middleware.hook.name']?.value === 'onBeforeResponse', ); expect(onBeforeResponseSpans).toHaveLength(2); // Find handler span - const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const handlerSpan = arrayHooksSpans.find(span => span.attributes['nuxt.middleware.hook.name']?.value === 'handler'); expect(handlerSpan).toBeDefined(); // Verify index attributes for onRequest array - const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); - const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + const onRequest0Span = onRequestSpans.find(span => span.attributes['nuxt.middleware.hook.index']?.value === 0); + const onRequest1Span = onRequestSpans.find(span => span.attributes['nuxt.middleware.hook.index']?.value === 1); expect(onRequest0Span).toBeDefined(); expect(onRequest1Span).toBeDefined(); // Verify index attributes for onBeforeResponse array - const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); - const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + const onBeforeResponse0Span = onBeforeResponseSpans.find( + span => span.attributes['nuxt.middleware.hook.index']?.value === 0, + ); + const onBeforeResponse1Span = onBeforeResponseSpans.find( + span => span.attributes['nuxt.middleware.hook.index']?.value === 1, + ); expect(onBeforeResponse0Span).toBeDefined(); expect(onBeforeResponse1Span).toBeDefined(); // Verify span names for array handlers - expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); - expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); - expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); - expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + expect(onRequest0Span?.name).toBe('05.array-hooks.onRequest'); + expect(onRequest1Span?.name).toBe('05.array-hooks.onRequest'); + expect(onBeforeResponse0Span?.name).toBe('05.array-hooks.onBeforeResponse'); + expect(onBeforeResponse1Span?.name).toBe('05.array-hooks.onBeforeResponse'); // Verify handler has no index - expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(handlerSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); }); test('should handle errors in onRequest hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-4', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; @@ -245,25 +255,23 @@ test.describe('Server Middleware Instrumentation', () => { const response = await request.get('/api/middleware-test?throwOnRequestError=true'); expect(response.status()).toBe(500); - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); // Find the onRequest span that should have error status - const onRequestSpan = serverTxnEvent.spans?.find( + const onRequestSpan = spans.find( span => - span.op === 'middleware' && - span.data?.['nuxt.middleware.name'] === '04.hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + getSpanOp(span) === 'middleware' && + span.attributes['nuxt.middleware.name']?.value === '04.hooks' && + span.attributes['nuxt.middleware.hook.name']?.value === 'onRequest', ); expect(onRequestSpan).toBeDefined(); - expect(onRequestSpan?.status).toBe('internal_error'); + expect(onRequestSpan?.status).toBe('error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); }); test('should handle errors in onBeforeResponse hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-4', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; @@ -273,25 +281,23 @@ test.describe('Server Middleware Instrumentation', () => { const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); expect(response.status()).toBe(500); - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); // Find the onBeforeResponse span that should have error status - const onBeforeResponseSpan = serverTxnEvent.spans?.find( + const onBeforeResponseSpan = spans.find( span => - span.op === 'middleware' && - span.data?.['nuxt.middleware.name'] === '04.hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + getSpanOp(span) === 'middleware' && + span.attributes['nuxt.middleware.name']?.value === '04.hooks' && + span.attributes['nuxt.middleware.hook.name']?.value === 'onBeforeResponse', ); expect(onBeforeResponseSpan).toBeDefined(); - expect(onBeforeResponseSpan?.status).toBe('internal_error'); + expect(onBeforeResponseSpan?.status).toBe('error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); }); test('should handle errors in array hooks with proper index attribution', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-4', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; @@ -301,31 +307,28 @@ test.describe('Server Middleware Instrumentation', () => { const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); expect(response.status()).toBe(500); - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); + + const findArrayHookSpan = (index: number) => + spans.find( + span => + getSpanOp(span) === 'middleware' && + span.attributes['nuxt.middleware.name']?.value === '05.array-hooks' && + span.attributes['nuxt.middleware.hook.name']?.value === 'onRequest' && + span.attributes['nuxt.middleware.hook.index']?.value === index, + ); // Find the second onRequest span that should have error status - const onRequest1Span = serverTxnEvent.spans?.find( - span => - span.op === 'middleware' && - span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && - span.data?.['nuxt.middleware.hook.index'] === 1, - ); + const onRequest1Span = findArrayHookSpan(1); expect(onRequest1Span).toBeDefined(); - expect(onRequest1Span?.status).toBe('internal_error'); + expect(onRequest1Span?.status).toBe('error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); // Verify the first onRequest handler still executed successfully - const onRequest0Span = serverTxnEvent.spans?.find( - span => - span.op === 'middleware' && - span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && - span.data?.['nuxt.middleware.hook.index'] === 0, - ); + const onRequest0Span = findArrayHookSpan(0); expect(onRequest0Span).toBeDefined(); - expect(onRequest0Span?.status).not.toBe('internal_error'); + expect(onRequest0Span?.status).toBe('ok'); }); }); 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 index 31d4d0af5e48..97d9c782b502 100644 --- 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 @@ -1,6 +1,16 @@ 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'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; + +async function collectStorageSpans(route: string) { + const spans = await collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === route), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === route); + + return spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt', + ); +} test.describe('Storage Instrumentation - Aliases', () => { const prefixKey = (key: string) => `test-storage:${key}`; @@ -8,100 +18,98 @@ test.describe('Storage Instrumentation - Aliases', () => { 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 storageSpansPromise = collectStorageSpans('/api/storage-aliases-test'); const response = await request.get('/api/storage-aliases-test'); expect(response.status()).toBe(200); - const transaction = await transactionPromise; + const allStorageSpans = await storageSpansPromise; // Helper to find spans by operation - const findSpansByMethod = (method: string) => { - return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; - }; + const findSpansByMethod = (method: string) => + allStorageSpans.filter(span => span.attributes['db.operation.name']?.value === method); + + const findByKey = (method: string, key: string) => + findSpansByMethod(method).find(span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value === key); // Test set (alias for setItem) - const setSpans = findSpansByMethod('setItem'); - expect(setSpans.length).toBeGreaterThanOrEqual(1); - const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(findSpansByMethod('setItem').length).toBeGreaterThanOrEqual(1); + const setSpan = findByKey('setItem', prefixKey('alias:user')); expect(setSpan).toBeDefined(); - expect(setSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, + 'db.operation.name': { type: 'string', value: 'setItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(setSpan?.description).toBe(prefixKey('alias:user')); + expect(setSpan?.name).toBe('cache.put'); // Test get (alias for getItem) - const getSpans = findSpansByMethod('getItem'); - expect(getSpans.length).toBeGreaterThanOrEqual(1); - const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(findSpansByMethod('getItem').length).toBeGreaterThanOrEqual(1); + const getSpan = findByKey('getItem', prefixKey('alias:user')); expect(getSpan).toBeDefined(); - expect(getSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(getSpan?.description).toBe(prefixKey('alias:user')); + expect(getSpan?.name).toBe('cache.get'); // Test has (alias for hasItem) - const hasSpans = findSpansByMethod('hasItem'); - expect(hasSpans.length).toBeGreaterThanOrEqual(1); - const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(findSpansByMethod('hasItem').length).toBeGreaterThanOrEqual(1); + const hasSpan = findByKey('hasItem', prefixKey('alias:user')); expect(hasSpan).toBeDefined(); - expect(hasSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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', + expect(hasSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'hasItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test del and remove (both aliases for removeItem) - const removeSpans = findSpansByMethod('removeItem'); - expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + expect(findSpansByMethod('removeItem').length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls - const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + const delSpan = findByKey('removeItem', prefixKey('alias:temp1')); expect(delSpan).toBeDefined(); - expect(delSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp1') }, + 'db.operation.name': { type: 'string', value: 'removeItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + expect(delSpan?.name).toBe('cache.remove'); - const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + const removeSpan = findByKey('removeItem', prefixKey('alias:temp2')); expect(removeSpan).toBeDefined(); - expect(removeSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp2') }, + 'db.operation.name': { type: 'string', value: 'removeItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + expect(removeSpan?.name).toBe('cache.remove'); // 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(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 index 98b457ba0f34..370e1424c823 100644 --- 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 @@ -1,6 +1,16 @@ 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'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; + +async function collectStorageSpans(route: string) { + const spans = await collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === route), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === route); + + return spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt', + ); +} test.describe('Storage Instrumentation', () => { const prefixKey = (key: string) => `test-storage:${key}`; @@ -8,143 +18,138 @@ test.describe('Storage Instrumentation', () => { 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 storageSpansPromise = collectStorageSpans('/api/storage-test'); const response = await request.get('/api/storage-test'); expect(response.status()).toBe(200); - const transaction = await transactionPromise; + const allStorageSpans = await storageSpansPromise; // Helper to find spans by operation - const findSpansByMethod = (method: string) => { - return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; - }; + const findSpansByMethod = (method: string) => + allStorageSpans.filter(span => span.attributes['db.operation.name']?.value === method); + + const findSpanByCacheKey = (method: string, key: string) => + findSpansByMethod(method).find(span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value === key); // Test setItem spans - const setItemSpans = findSpansByMethod('setItem'); - expect(setItemSpans.length).toBeGreaterThanOrEqual(1); - const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(findSpansByMethod('setItem').length).toBeGreaterThanOrEqual(1); + const setItemSpan = findSpanByCacheKey('setItem', prefixKey('user:123')); expect(setItemSpan).toBeDefined(); - expect(setItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, + 'db.operation.name': { type: 'string', value: 'setItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + expect(setItemSpan?.name).toBe('cache.put'); // Test setItemRaw spans - const setItemRawSpans = findSpansByMethod('setItemRaw'); - expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); - const setItemRawSpan = setItemRawSpans.find( - span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), - ); + expect(findSpansByMethod('setItemRaw').length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = findSpanByCacheKey('setItemRaw', prefixKey('raw:data')); + expect(setItemRawSpan).toBeDefined(); - expect(setItemRawSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [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', + expect(setItemRawSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') }, + 'db.operation.name': { type: 'string', value: 'setItemRaw' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test hasItem spans - should have cache hit attribute - const hasItemSpans = findSpansByMethod('hasItem'); - expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); - const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(findSpansByMethod('hasItem').length).toBeGreaterThanOrEqual(1); + const hasItemSpan = findSpanByCacheKey('hasItem', prefixKey('user:123')); expect(hasItemSpan).toBeDefined(); - expect(hasItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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', + expect(hasItemSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'hasItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test getItem spans - should have cache hit attribute - const getItemSpans = findSpansByMethod('getItem'); - expect(getItemSpans.length).toBeGreaterThanOrEqual(1); - const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(findSpansByMethod('getItem').length).toBeGreaterThanOrEqual(1); + const getItemSpan = findSpanByCacheKey('getItem', prefixKey('user:123')); expect(getItemSpan).toBeDefined(); - expect(getItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(getItemSpan?.description).toBe(prefixKey('user:123')); + expect(getItemSpan?.name).toBe('cache.get'); // Test getItemRaw spans - should have cache hit attribute - const getItemRawSpans = findSpansByMethod('getItemRaw'); - expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); - const getItemRawSpan = getItemRawSpans.find( - span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), - ); + expect(findSpansByMethod('getItemRaw').length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = findSpanByCacheKey('getItemRaw', prefixKey('raw:data')); expect(getItemRawSpan).toBeDefined(); - expect(getItemRawSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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', + expect(getItemRawSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItemRaw' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test getKeys spans const getKeysSpans = findSpansByMethod('getKeys'); expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); - expect(getKeysSpans[0]?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - 'db.operation.name': 'getKeys', - 'db.collection.name': 'test-storage', - 'db.system.name': 'memory', + expect(getKeysSpans[0]?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + 'db.operation.name': { type: 'string', value: 'getKeys' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test removeItem spans - const removeItemSpans = findSpansByMethod('removeItem'); - expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); - const removeItemSpan = removeItemSpans.find( - span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), - ); + expect(findSpansByMethod('removeItem').length).toBeGreaterThanOrEqual(1); + const removeItemSpan = findSpanByCacheKey('removeItem', prefixKey('batch:1')); expect(removeItemSpan).toBeDefined(); - expect(removeItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [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', + expect(removeItemSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('batch:1') }, + 'db.operation.name': { type: 'string', value: 'removeItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test clear spans const clearSpans = findSpansByMethod('clear'); expect(clearSpans.length).toBeGreaterThanOrEqual(1); - expect(clearSpans[0]?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - 'db.operation.name': 'clear', - 'db.collection.name': 'test-storage', - 'db.system.name': 'memory', + expect(clearSpans[0]?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + 'db.operation.name': { type: 'string', value: 'clear' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: '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(allStorageSpans.length).toBeGreaterThan(0); + allStorageSpans.forEach(span => { expect(span.status).toBe('ok'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts index 1dea68dd77ff..45afb6475b20 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.describe('Rendering Modes with Cached HTML', () => { test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { @@ -27,6 +27,20 @@ test.describe('Rendering Modes with Cached HTML', () => { }); }); +// Cached pages have exact-match routes, so the server segment is selected by `url.path` instead of +// its name (route matching may or may not rename exact-match segments under span streaming). +function waitForServerSegment(routePath: string) { + return waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === routePath; + }); +} + +function waitForPageloadSegment(routePath: string) { + return waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && getSpanOp(span) === 'pageload' && span.attributes['url.path']?.value === routePath; + }); +} + /** * Tests that tracing meta-tags change with multiple requests on ISR-cached pages * This utility handles the common pattern of: @@ -45,18 +59,13 @@ export async function testChangingTracingMetaTagsOnISRPage( expectedPageText: string, ): Promise { // === 1. Request === - const clientTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction === routePath; - }); + const clientSpanPromise1 = waitForPageloadSegment(routePath); + const serverSpanPromise1 = waitForServerSegment(routePath); - const serverTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }); - - const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + const [_1, clientSpan1, serverSpan1] = await Promise.all([ page.goto(routePath), - clientTxnEventPromise1, - serverTxnEventPromise1, + clientSpanPromise1, + serverSpanPromise1, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); @@ -66,18 +75,13 @@ export async function testChangingTracingMetaTagsOnISRPage( // === 2. Request === - const clientTxnEventPromise2 = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction === routePath; - }); - - const serverTxnEventPromise2 = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }); + const clientSpanPromise2 = waitForPageloadSegment(routePath); + const serverSpanPromise2 = waitForServerSegment(routePath); - const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([ + const [_2, clientSpan2, serverSpan2] = await Promise.all([ page.goto(routePath), - clientTxnEventPromise2, - serverTxnEventPromise2, + clientSpanPromise2, + serverSpanPromise2, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); @@ -85,30 +89,30 @@ export async function testChangingTracingMetaTagsOnISRPage( const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; - const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; - const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id; + const serverSpan1TraceId = serverSpan1.trace_id; + const serverSpan2TraceId = serverSpan2.trace_id; await test.step('Test distributed trace from 1. request', () => { - expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`); + expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverSpan1TraceId}`); - expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId); - expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id); - expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1); + expect(clientSpan1.trace_id).toBe(serverSpan1TraceId); + expect(clientSpan1.parent_span_id).toBe(serverSpan1.span_id); + expect(serverSpan1TraceId).toBe(htmlMetaTraceId1); }); await test.step('Test distributed trace from 2. request', () => { - expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`); + expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverSpan2TraceId}`); - expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId); - expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id); - expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2); + expect(clientSpan2.trace_id).toBe(serverSpan2TraceId); + expect(clientSpan2.parent_span_id).toBe(serverSpan2.span_id); + expect(serverSpan2TraceId).toBe(htmlMetaTraceId2); }); await test.step('Test that trace IDs from subsequent requests are different', () => { - // Different trace IDs for the server transactions - expect(serverTxnEvent1TraceId).toBeDefined(); - expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId); - expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2); + // Different trace IDs for the server root spans + expect(serverSpan1TraceId).toBeDefined(); + expect(serverSpan1TraceId).not.toBe(serverSpan2TraceId); + expect(serverSpan1TraceId).not.toBe(htmlMetaTraceId2); }); } @@ -117,13 +121,11 @@ export async function testChangingTracingMetaTagsOnISRPage( * This utility handles the common pattern of: * 1. Making two requests to a cached page * 2. Verifying no tracing meta-tags are present - * 3. Verifying only the first request creates a server transaction - * 4. Verifying traces are not distributed + * 3. Verifying traces are not distributed (each pageload starts its own trace) * * @param page - Playwright page object * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') - * @returns Object containing transaction events for additional custom assertions */ export async function testExcludeTracingMetaTagsOnCachedPage( page: Page, @@ -131,19 +133,15 @@ export async function testExcludeTracingMetaTagsOnCachedPage( expectedPageText: string, ): Promise { // === 1. Request === - const clientTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction === routePath; - }); + const clientSpanPromise1 = waitForPageloadSegment(routePath); - // Only the 1. request creates a server transaction - const serverTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }); + // Only the 1. request creates a server root span + const serverSpanPromise1 = waitForServerSegment(routePath); - const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + const [_1, clientSpan1, serverSpan1] = await Promise.all([ page.goto(routePath), - clientTxnEventPromise1, - serverTxnEventPromise1, + clientSpanPromise1, + serverSpanPromise1, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); @@ -153,54 +151,37 @@ export async function testExcludeTracingMetaTagsOnCachedPage( // === 2. Request === - await page.goto(routePath); - - const clientTxnEventPromise2 = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction === routePath; - }); - - let serverTxnEvent2 = undefined; - const serverTxnEventPromise2 = Promise.race([ - waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }), - new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)), - ]); + const clientSpanPromise2 = waitForPageloadSegment(routePath); - try { - serverTxnEvent2 = await serverTxnEventPromise2; - throw new Error('Second server transaction should not have been sent'); - } catch (error) { - expect(error.message).toBe('No second server transaction expected'); - } - - const [clientTxnEvent2] = await Promise.all([ - clientTxnEventPromise2, + const [_2, clientSpan2] = await Promise.all([ + page.goto(routePath), + clientSpanPromise2, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); - const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id; - const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id; + const clientSpan1TraceId = clientSpan1.trace_id; + const clientSpan2TraceId = clientSpan2.trace_id; - const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; - const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id; + const serverSpan1TraceId = serverSpan1.trace_id; await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { expect(await page.locator('meta[name="baggage"]').count()).toBe(0); expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); }); - await test.step('1. Server Transaction and all Client Transactions are defined', () => { - expect(serverTxnEvent1TraceId).toBeDefined(); - expect(clientTxnEvent1TraceId).toBeDefined(); - expect(clientTxnEvent2TraceId).toBeDefined(); - expect(serverTxnEvent2).toBeUndefined(); - expect(serverTxnEvent2TraceId).toBeUndefined(); + await test.step('1. server root span and all client root spans are defined', () => { + expect(serverSpan1TraceId).toBeDefined(); + expect(clientSpan1TraceId).toBeDefined(); + expect(clientSpan2TraceId).toBeDefined(); }); await test.step('Trace is not distributed', () => { // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) - expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId); - expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId); + expect(clientSpan1TraceId).not.toBe(clientSpan2TraceId); + expect(clientSpan1TraceId).not.toBe(serverSpan1TraceId); + expect(clientSpan2TraceId).not.toBe(serverSpan1TraceId); + // Without meta tags the pageloads have no parent and start their own traces + expect(clientSpan1.parent_span_id).toBeUndefined(); + expect(clientSpan2.parent_span_id).toBeUndefined(); }); } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts index dce04b2f84b7..1ae00218f323 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts @@ -1,95 +1,71 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; -import type { Span } from '@sentry/nuxt'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('sends a pageload root span with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { - return transactionEvent.transaction === '/test-param/:param()'; + const pageloadSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/test-param/1234`); - const rootSpan = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.pageload.vue', - 'sentry.op': 'pageload', - 'params.param': '1234', - 'url.template': '/test-param/:param()', - 'url.path': '/test-param/1234', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), - }, - op: 'pageload', - origin: 'auto.pageload.vue', - }, - }, - transaction: '/test-param/:param()', - transaction_info: { - source: 'route', - }, + expect(pageloadSpan.name).toBe('/test-param/:param()'); + expect(pageloadSpan.status).toBe('ok'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.op': { type: 'string', value: 'pageload' }, + 'params.param': { type: 'string', value: '1234' }, + 'url.template': { type: 'string', value: '/test-param/:param()' }, + 'url.path': { type: 'string', value: '/test-param/1234' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/) }, }); }); test('sends a navigation root span with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/test-param/:param()' - ); + const navigationSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/test-param/:param()'; }); await page.goto(`/`); await page.getByText('Fetch Param').click(); - const rootSpan = await transactionPromise; + const navigationSpan = await navigationSpanPromise; - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.navigation.vue', - 'sentry.op': 'navigation', - 'params.param': '1234', - 'url.template': '/test-param/:param()', - 'url.path': '/test-param/1234', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), - }, - op: 'navigation', - origin: 'auto.navigation.vue', - }, - }, - transaction: '/test-param/:param()', - transaction_info: { - source: 'route', - }, + expect(navigationSpan.name).toBe('/test-param/:param()'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.navigation.vue' }, + 'sentry.op': { type: 'string', value: 'navigation' }, + 'params.param': { type: 'string', value: '1234' }, + 'url.template': { type: 'string', value: '/test-param/:param()' }, + 'url.path': { type: 'string', value: '/test-param/1234' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/) }, }); }); test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { - return transactionEvent.transaction === '/client-error'; - }); + const spansPromise = collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'), + ); await page.goto(`/client-error`); - const rootSpan = await transactionPromise; - const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + const spans = await spansPromise; + const errorButtonSpan = spans.find(span => span.name === 'Vue '); - const expected = { - data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount' }, - description: 'Vue ', - op: 'ui.mount', + expect(errorButtonSpan).toMatchObject({ + name: 'Vue ', + is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.vue', - }; - - expect(errorButtonSpan).toMatchObject(expected); + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.mount' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + }), + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts index 7c9298cbfd6b..16097e9d1dd2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts @@ -1,63 +1,71 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a server action transaction on pageload', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); +test('sends a server root span on pageload', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); await page.goto('/test-param/1234'); - const transaction = await transactionPromise; + const serverSpan = await serverSpanPromise; - expect(transaction.contexts.trace).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.http_server', - }), - }), - ); + expect(getSpanOp(serverSpan)).toBe('http.server'); + expect(serverSpan.attributes['sentry.origin']?.value).toBe('auto.http.http_server'); }); -test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { +test('does not send spans for build asset folder "_nuxt"', async ({ page }) => { let buildAssetFolderOccurred = false; - waitForTransaction('nuxt-4', transactionEvent => { - if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + waitForStreamedSpan('nuxt-4', span => { + if (span.is_segment && /^GET \/_nuxt\//.test(span.name)) { buildAssetFolderOccurred = true; } return false; // expects to return a boolean (but not relevant here) }); - const transactionEventPromise = waitForTransaction('nuxt-4', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); + const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); await page.goto('/test-param/1234'); - const transactionEvent = await transactionEventPromise; + const serverSpan = await serverSpanPromise; expect(buildAssetFolderOccurred).toBe(false); - expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); + expect(serverSpan.name).toBe('GET /test-param/:param()'); + expect(serverSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('captures server API calls made with Nitro $fetch', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { - return transactionEvent.transaction === 'GET /api/nitro-fetch'; + // The server-side http.client span can flush before its segment. Accumulate until both arrived. + const spansPromise = collectStreamedSpans('nuxt-4', spans => { + return ( + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch') && + spans.some( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes('example.com'), + ) + ); }); await page.goto(`/fetch-server-routes`); await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); - const httpServerFetchSpan = await transactionPromise; - const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + const spans = await spansPromise; + + const serverSegmentSpan = spans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch', + ); + const httpClientSpan = spans.find( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes('example.com'), + ); - expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); - expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + expect(serverSegmentSpan).toBeDefined(); + expect(getSpanOp(serverSegmentSpan!)).toBe('http.server'); - expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); - expect(httpClientSpan.op).toEqual('http.client'); + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan?.name).toBe('GET example.com'); + expect(httpClientSpan?.trace_id).toBe(serverSegmentSpan?.trace_id); + expect(httpClientSpan?.parent_span_id).toBe(serverSegmentSpan?.span_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index 7979a9e82782..74ee67cee564 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -1,22 +1,23 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.describe('distributed tracing', () => { const PARAM = 's0me-param'; + const API_PATH = `/api/user/${PARAM}`; test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction === '/test-param/:param()'; + const clientSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /test-param/') ?? false; + const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); - const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + const [_, clientSpan, serverSpan] = await Promise.all([ page.goto(`/test-param/${PARAM}`), - clientTxnEventPromise, - serverTxnEventPromise, + clientSpanPromise, + serverSpanPromise, expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); @@ -24,7 +25,7 @@ test.describe('distributed tracing', () => { // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); - expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverSpan.trace_id}`); expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -33,125 +34,115 @@ test.describe('distributed tracing', () => { expect(metaSampled).toBe('1'); - expect(clientTxnEvent).toMatchObject({ - transaction: '/test-param/:param()', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.vue', - trace_id: metaTraceId, - parent_span_id: metaParentSpanId, - }, - }, + expect(clientSpan).toMatchObject({ + name: '/test-param/:param()', + is_segment: true, + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), }); - expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/:param()`, // parametrized route - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.http_server', - }, - }, + expect(serverSpan).toMatchObject({ + name: 'GET /test-param/:param()', // parametrized + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), }); // connected trace - expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); - - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); - expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + expect(clientSpan.parent_span_id).toBe(serverSpan.span_id); + expect(serverSpan.trace_id).toBe(metaTraceId); }); test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction === '/test-param/user/:userId()'; + // The `http.client` span ends after the pageload segment, so it can be flushed in a later + // envelope. Accumulate until both spans have arrived. + const clientSpansPromise = collectStreamedSpans('nuxt-4', spans => { + return ( + spans.some(span => span.name === '/test-param/user/:userId()' && span.is_segment) && + spans.some( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), + ) + ); }); - const ssrTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + const ssrSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name.includes('GET /test-param/user'); }); - const serverReqTxnEventPromise = waitForTransaction('nuxt-4', txnEvent => { - return txnEvent.transaction?.includes('GET /api/user/') ?? false; + const serverReqSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name.includes('GET /api/user/'); }); // Navigate to the page which will trigger an API call from the client-side await page.goto(`/test-param/user/${PARAM}`); - const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ - clientTxnEventPromise, - ssrTxnEventPromise, - serverReqTxnEventPromise, + const [clientSpans, ssrSpan, serverReqSpan] = await Promise.all([ + clientSpansPromise, + ssrSpanPromise, + serverReqSpanPromise, ]); - const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); - - expect(clientTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: '/test-param/user/:userId()', // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'pageload', - origin: 'auto.pageload.vue', - }), - }), - }), + const pageloadSpan = clientSpans.find(span => span.name === '/test-param/user/:userId()' && span.is_segment); + const httpClientSpan = clientSpans.find( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), ); + expect(pageloadSpan).toMatchObject({ + name: '/test-param/user/:userId()', + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), + }); + expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan).toEqual( - expect.objectContaining({ - description: `GET /api/user/${PARAM}`, // fixme: parametrize - parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent - data: expect.objectContaining({ - 'url.full': expect.stringContaining(`/api/user/${PARAM}`), - type: 'fetch', - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.browser', - 'http.request.method': 'GET', - }), + expect(httpClientSpan).toMatchObject({ + // A relative fetch has no domain of its own, so it resolves against the page origin. + name: 'GET localhost', + parent_span_id: pageloadSpan?.span_id, // pageload span is parent + attributes: expect.objectContaining({ + type: { type: 'string', value: 'fetch' }, + 'sentry.op': { type: 'string', value: 'http.client' }, + 'sentry.origin': { type: 'string', value: 'auto.http.browser' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: expect.stringContaining(API_PATH) }, + 'url.domain': { type: 'string', value: 'localhost' }, }), - ); + }); - expect(ssrTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: `GET /test-param/user/:userId()`, // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.http_server', - }), - }), + expect(ssrSpan).toMatchObject({ + name: 'GET /test-param/user/:userId()', // parametrized route + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, }), - ); + }); - expect(serverReqTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: `GET /api/user/:userId`, // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.http_server', - parent_span_id: httpClientSpan?.span_id, // http.client span is parent - }), - }), + expect(serverReqSpan).toMatchObject({ + name: 'GET /api/user/:userId', // parametrized route + is_segment: true, + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, }), - ); + }); - // All 3 transactions and the http.client span should share the same trace_id - expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + // All 3 root spans and the http.client span should share the same trace_id + expect(pageloadSpan?.trace_id).toBeDefined(); + expect(pageloadSpan?.trace_id).toBe(httpClientSpan?.trace_id); + expect(pageloadSpan?.trace_id).toBe(ssrSpan.trace_id); + expect(pageloadSpan?.trace_id).toBe(serverReqSpan.trace_id); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts index 342fa2b4472e..900df0811faa 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nuxt'; import { /* usePinia,*/ useRuntimeConfig } from '#imports'; Sentry.init({ - traceLifecycle: 'static', dsn: useRuntimeConfig().public.sentry.dsn, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts index aa8da2067fba..26519911072b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts index 6e17444c30bf..e814c930311b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts @@ -61,6 +61,11 @@ export default defineHandler(async event => { } case 'error': { + // A successful query runs first so the captured error carries a query breadcrumb: with span + // streaming there is no transaction event left to read breadcrumbs off. + await db.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); await stmt.get(1); return { success: false, message: 'Should have thrown an error' }; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts index 6a14162b7278..229887910b97 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts @@ -1,33 +1,37 @@ 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'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; test.describe('Cache Instrumentation', () => { const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + async function collectCacheSpans() { + const spans = await collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test'); + + return spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt', + ); + } + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ request, }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; - }); + const cacheSpansPromise = collectCacheSpans(); const response = await request.get('/api/cache-test'); expect(response.status()).toBe(200); - const transaction = await transactionPromise; + const allCacheSpans = await cacheSpansPromise; + expect(allCacheSpans.length).toBeGreaterThan(0); // Helper to find spans by operation - const findSpansByMethod = (method: string) => { - return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; - }; + const findSpansByMethod = (method: string) => + allCacheSpans.filter(span => span.attributes['db.operation.name']?.value === method); - // 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); + const getCacheKey = (span: (typeof allCacheSpans)[number]) => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value; // Test getItem spans for cachedFunction - should have both cache miss and cache hit const getItemSpans = findSpansByMethod('getItem'); @@ -36,34 +40,36 @@ test.describe('Cache Instrumentation', () => { // 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], + typeof getCacheKey(span) === 'string' && + (getCacheKey(span) as string).includes('user:123') && + !span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value, ); if (cacheMissSpan) { - expect(cacheMissSpan.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, - 'db.operation.name': 'getItem', - 'db.collection.name': expect.stringMatching(/^(cache)?$/), + expect(cacheMissSpan.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: false }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: 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], + typeof getCacheKey(span) === 'string' && + (getCacheKey(span) as string).includes('user:123') && + span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value, ); if (cacheHitSpan) { - expect(cacheHitSpan.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, - 'db.operation.name': 'getItem', - 'db.collection.name': expect.stringMatching(/^(cache)?$/), + expect(cacheHitSpan.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: expect.stringMatching(/^(cache)?$/) }, }); } @@ -72,42 +78,34 @@ test.describe('Cache Instrumentation', () => { 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'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).includes('user:123'), ); if (cacheSetSpan) { - expect(cacheSetSpan.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - 'db.operation.name': 'setItem', - 'db.collection.name': expect.stringMatching(/^(cache)?$/), + expect(cacheSetSpan.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + 'db.operation.name': { type: 'string', value: 'setItem' }, + 'db.collection.name': { type: 'string', value: 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'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).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'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).includes('cachedHandler'), ); expect(cachedHandlerSpans.length).toBeGreaterThan(0); - // Verify all cache spans have OK status - allCacheSpans?.forEach(span => { + // Verify all cache spans have OK status and are nested under the request's root span + allCacheSpans.forEach(span => { expect(span.status).toBe('ok'); - }); - - // Verify cache spans are properly nested under the transaction - allCacheSpans?.forEach(span => { + expect(span.is_segment).toBe(false); expect(span.parent_span_id).toBeDefined(); }); }); @@ -117,41 +115,38 @@ test.describe('Cache Instrumentation', () => { const uniqueUser = `test-${Date.now()}`; const uniqueData = `data-${Date.now()}`; - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; - }); + const cacheSpansPromise = collectCacheSpans(); 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', - ); + const allCacheSpans = await cacheSpansPromise; // We should have cache operations - expect(allCacheSpans?.length).toBeGreaterThan(0); + expect(allCacheSpans.length).toBeGreaterThan(0); // Get all getItem operations - const allGetItemSpans = allCacheSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get'); + const allGetItemSpans = allCacheSpans.filter(span => span.attributes['sentry.op']?.value === 'cache.get'); // Get all setItem operations - const allSetItemSpans = allCacheSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.put'); + const allSetItemSpans = allCacheSpans.filter(span => span.attributes['sentry.op']?.value === 'cache.put'); // We should have both get and set operations - expect(allGetItemSpans?.length).toBeGreaterThan(0); - expect(allSetItemSpans?.length).toBeGreaterThan(0); + 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); + const cacheMissSpans = allGetItemSpans.filter( + span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value === false, + ); // Check for cache hits (cache.hit = true) - const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + const cacheHitSpans = allGetItemSpans.filter(span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value === true); // We should have at least one cache miss (first calls to getCachedUser and getCachedData) - expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + expect(cacheMissSpans.length).toBeGreaterThanOrEqual(1); // We should have at least one cache hit (second calls to getCachedUser and getCachedData) - expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + expect(cacheHitSpans.length).toBeGreaterThanOrEqual(1); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts index 9257bbc0e8a2..6572989abcc3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts @@ -1,156 +1,155 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +async function collectDbSpans() { + const spans = await collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-multi-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-multi-test'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id && getSpanOp(span) === 'db.query'); +} test.describe('multiple database instances', () => { test('instruments default database instance', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=default-db'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const selectSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(selectSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT default_table' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM default_table WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('instruments named database instance (users)', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=users-db'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const selectSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(selectSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT user_profiles' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM user_profiles WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('instruments named database instance (analytics)', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=analytics-db'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const selectSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(selectSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT events' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM events WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('instruments multiple database instances in single request', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=multiple-dbs'); - const transaction = await transactionPromise; + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const sessionSpan = dbSpans.find(span => span.name === 'SELECT sessions'); + const accountSpan = dbSpans.find(span => span.name === 'SELECT accounts'); + const metricSpan = dbSpans.find(span => span.name === 'SELECT metrics'); - 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'); + // Each instance keeps its own namespace, while the span name stays low cardinality + expect(sessionSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT sessions' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM sessions WHERE id = ?' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); + expect(accountSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT accounts' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM accounts WHERE id = ?' }, + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); + expect(metricSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT metrics' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM metrics WHERE id = ?' }, + 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); - // 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'); + // All spans belong to the same trace as the request's root span + const traceIds = new Set(dbSpans.map(span => span.trace_id)); + expect(traceIds.size).toBe(1); }); test('instruments SQL template tag across multiple databases', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'); + const dbSpans = await dbSpansPromise; + expect(dbSpans.length).toBeGreaterThan(0); - 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')); + const logsInsertSpan = dbSpans.find(span => span.name === 'INSERT logs'); + const auditLogsInsertSpan = dbSpans.find(span => span.name === 'INSERT 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(logsInsertSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT logs' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO logs (message) VALUES (?)' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'sentry.origin': { type: 'string', value: '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'); + expect(auditLogsInsertSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT audit_logs' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO audit_logs (action) VALUES (?)' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('creates correct span count for multiple database operations', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-multi-test?method=multiple-dbs'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; // 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); + expect(dbSpans.length).toBeGreaterThanOrEqual(9); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts index ec7102449617..4ec28d9237be 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts @@ -1,129 +1,128 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; + +async function collectDbSpans() { + const spans = await collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-test'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id && getSpanOp(span) === 'db.query'); +} test.describe('database integration', () => { test('captures db.prepare().get() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); 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')); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'SELECT users'); 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'); + expect(dbSpan?.status).toBe('ok'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT users' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE id = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'db.namespace': { type: 'string', value: 'db.sqlite' }, + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.prepare().all() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'SELECT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT products' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM products WHERE price > ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.prepare().run() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'INSERT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT orders' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO orders (customer, amount) VALUES (?, ?)' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.prepare().bind().all() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'SELECT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT items' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM items WHERE category = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.sql template tag span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); 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'), - ); + const dbSpan = (await dbSpansPromise).find(span => span.name === 'INSERT 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'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT messages' }, + 'db.query.text': { + type: 'string', + value: 'INSERT INTO messages (content, created_at) VALUES (?, ?)', + }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures db.exec() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-test?method=exec'); - const transaction = await transactionPromise; + const dbSpans = await dbSpansPromise; + const insertSpan = dbSpans.find(span => span.name === 'INSERT logs'); - const dbSpan = transaction.spans?.find( - span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), - ); + expect(insertSpan).toBeDefined(); + expect(insertSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'INSERT logs' }, + 'db.query.text': { type: 'string', value: `INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')` }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); - 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'); + // DDL statements are summarized as `{operation} {table}` as well, with the statement on the attribute + expect(dbSpans.find(span => span.name === 'DROP TABLE logs')?.attributes).toMatchObject({ + 'db.query.text': { type: 'string', value: 'DROP TABLE IF EXISTS logs' }, + }); + expect(dbSpans.find(span => span.name === 'CREATE TABLE logs')?.attributes).toMatchObject({ + 'db.query.text': { + type: 'string', + value: 'CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)', + }, + }); }); test('captures database error and marks span as failed', async ({ request }) => { @@ -133,15 +132,13 @@ test.describe('database integration', () => { ); }); - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-test?method=error').catch(() => { // Expected to fail }); - const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + const [error, dbSpans] = await Promise.all([errorPromise, dbSpansPromise]); const dbException = error.exception?.values?.find(value => value.mechanism?.type === 'auto.db.nuxt'); @@ -152,50 +149,46 @@ test.describe('database integration', () => { type: 'auto.db.nuxt', }); - const dbSpan = transaction.spans?.find( - span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), - ); + const dbSpan = dbSpans.find(span => span.name === 'SELECT 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'); + expect(dbSpan?.status).toBe('error'); + expect(dbSpan?.attributes).toMatchObject({ + 'db.query.summary': { type: 'string', value: 'SELECT nonexistent_table' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM nonexistent_table WHERE invalid_column = ?' }, + 'db.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); }); test('captures breadcrumb for db.exec() queries', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; + const errorPromise = waitForError('nuxt-5', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.mechanism?.type === 'auto.db.nuxt'); }); - await request.get('/api/db-test?method=exec'); + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); - const transaction = await transactionPromise; + const error = await errorPromise; - const dbBreadcrumb = transaction.breadcrumbs?.find( + const dbBreadcrumb = error.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-5', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + const dbSpansPromise = collectDbSpans(); await request.get('/api/db-test?method=prepare-get'); - const transaction = await transactionPromise; - - const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + const dbSpans = await dbSpansPromise; - expect(dbSpans).toBeDefined(); - expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + expect(dbSpans.length).toBeGreaterThanOrEqual(1); + expect(dbSpans.every(span => !span.is_segment)).toBe(true); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts index 93f9935d048f..d7ac4e437d7e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; test.describe('environment detection', async () => { @@ -21,19 +21,19 @@ test.describe('environment detection', async () => { } }); - test('sets correct environment for client-side transactions', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { - return transactionEvent.transaction === '/test-param/:param()'; + test('sets correct environment for client-side spans', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && span.name === '/test-param/:param()'; }); await page.goto(`/test-param/1234`); - const transaction = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; if (isDevMode) { - expect(transaction.environment).toBe('development'); + expect(pageloadSpan.attributes['sentry.environment']?.value).toBe('development'); } else { - expect(transaction.environment).toBe('production'); + expect(pageloadSpan.attributes['sentry.environment']?.value).toBe('production'); } }); @@ -56,22 +56,22 @@ test.describe('environment detection', async () => { } }); - test('sets correct environment for server-side transactions', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { - return transactionEvent.transaction === 'GET /api/nitro-fetch'; + test('sets correct environment for server-side spans', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch'; }); await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); - const transaction = await transactionPromise; + const serverSpan = await serverSpanPromise; - expect(transaction.contexts.trace.op).toBe('http.server'); + expect(getSpanOp(serverSpan)).toBe('http.server'); if (isDevMode) { - expect(transaction.environment).toBe('development'); + expect(serverSpan.attributes['sentry.environment']?.value).toBe('development'); } else { - expect(transaction.environment).toBe('production'); + expect(serverSpan.attributes['sentry.environment']?.value).toBe('production'); } }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts index 2e2fb686a619..186d52cd1a03 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts @@ -1,12 +1,18 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; + +async function collectRequestSpans() { + const spans = await collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id); +} -// TODO: Skipped for Nuxt 5 as the SDK is not yet updated for that test.describe('Server Middleware Instrumentation', () => { test('should create separate spans for each server middleware', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); // Make request to the API endpoint that will trigger all server middleware const response = await request.get('/api/middleware-test'); @@ -15,22 +21,23 @@ test.describe('Server Middleware Instrumentation', () => { const responseData = await response.json(); expect(responseData.message).toBe('Server middleware test endpoint'); - const serverTxnEvent = await serverTxnEventPromise; + const spans = await spansPromise; // Verify that we have spans for each middleware - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); // 3 simple + 2 hooks (middleware+handler) + 3 array hooks (2 middleware + 1 handler) expect(middlewareSpans).toHaveLength(8); // Check for specific middleware spans - const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); - const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second'); - const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth'); - const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); - const arrayHooksHandlerSpan = middlewareSpans.find( - span => span.data?.['nuxt.middleware.name'] === '05.array-hooks', - ); + const findSpanByName = (name: string) => + middlewareSpans.find(span => span.attributes['nuxt.middleware.name']?.value === name); + + const firstMiddlewareSpan = findSpanByName('01.first'); + const secondMiddlewareSpan = findSpanByName('02.second'); + const authMiddlewareSpan = findSpanByName('03.auth'); + const hooksOnRequestSpan = findSpanByName('04.hooks'); + const arrayHooksHandlerSpan = findSpanByName('05.array-hooks'); expect(firstMiddlewareSpan).toBeDefined(); expect(secondMiddlewareSpan).toBeDefined(); @@ -42,12 +49,12 @@ test.describe('Server Middleware Instrumentation', () => { [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { expect(span).toEqual( expect.objectContaining({ - op: 'middleware', - data: expect.objectContaining({ - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nuxt', - 'http.request.method': 'GET', - 'http.route': '/api/middleware-test', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nuxt' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'http.route': { type: 'string', value: '/api/middleware-test' }, }), parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -69,25 +76,24 @@ test.describe('Server Middleware Instrumentation', () => { }); test('middleware spans should have proper parent-child relationship', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); await request.get('/api/middleware-test'); - const serverTxnEvent = await serverTxnEventPromise; + const spans = await spansPromise; - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const segmentSpan = spans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test', + ); + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); - // All middleware spans should be children of the main transaction + // All middleware spans should be children of the request's segment span middlewareSpans.forEach(span => { - expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(span.parent_span_id).toBe(segmentSpan?.span_id); }); }); test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-5', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; @@ -99,19 +105,19 @@ test.describe('Server Middleware Instrumentation', () => { // The request should fail due to the middleware error expect(response.status()).toBe(500); - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); // Find the auth middleware span - const authMiddlewareSpan = serverTxnEvent.spans?.find( - span => span.op === 'middleware' && span.data?.['nuxt.middleware.name'] === '03.auth', + const authMiddlewareSpan = spans.find( + span => getSpanOp(span) === 'middleware' && span.attributes['nuxt.middleware.name']?.value === '03.auth', ); expect(authMiddlewareSpan).toBeDefined(); // Verify the span has error status - expect(authMiddlewareSpan?.status).toBe('internal_error'); + expect(authMiddlewareSpan?.status).toBe('error'); - // Verify the error event is associated with the correct transaction + // Verify the error event is associated with the correct request expect(errorEvent.transaction).toContain('GET /api/middleware-test'); // Verify the error has the correct mechanism @@ -128,95 +134,98 @@ test.describe('Server Middleware Instrumentation', () => { }); test('should create spans for middleware and handler hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); // Make request to trigger middleware with hooks const response = await request.get('/api/middleware-test'); expect(response.status()).toBe(200); - const serverTxnEvent = await serverTxnEventPromise; - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const spans = await spansPromise; + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); // Find spans for the hooks middleware - const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + const hooksSpans = middlewareSpans.filter(span => span.attributes['nuxt.middleware.name']?.value === '04.hooks'); // Should have spans for middleware and handler (h3 v2 no longer has onBeforeResponse) expect(hooksSpans).toHaveLength(2); // Find specific hook spans - const middlewareSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'middleware'); - const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const findSpanByHook = (hook: string) => + hooksSpans.find(span => span.attributes['nuxt.middleware.hook.name']?.value === hook); + + const middlewareSpan = findSpanByHook('middleware'); + const handlerSpan = findSpanByHook('handler'); expect(middlewareSpan).toBeDefined(); expect(handlerSpan).toBeDefined(); // Verify span names include hook types - expect(middlewareSpan?.description).toBe('04.hooks.middleware'); - expect(handlerSpan?.description).toBe('04.hooks'); + expect(middlewareSpan?.name).toBe('04.hooks.middleware'); + expect(handlerSpan?.name).toBe('04.hooks'); // Verify all spans have correct middleware name (without hook suffix) [middlewareSpan, handlerSpan].forEach(span => { - expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); + expect(span?.attributes['nuxt.middleware.name']?.value).toBe('04.hooks'); }); // Verify hook-specific attributes - expect(middlewareSpan?.data?.['nuxt.middleware.hook.name']).toBe('middleware'); - expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); + expect(middlewareSpan?.attributes['nuxt.middleware.hook.name']?.value).toBe('middleware'); + expect(handlerSpan?.attributes['nuxt.middleware.hook.name']?.value).toBe('handler'); // Verify middleware has index (middleware is always an array in h3 v2) - expect(middlewareSpan?.data?.['nuxt.middleware.hook.index']).toBe(0); - expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(middlewareSpan?.attributes['nuxt.middleware.hook.index']?.value).toBe(0); + expect(handlerSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); }); test('should create spans with index attributes for array middleware', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); // Make request to trigger middleware with array hooks const response = await request.get('/api/middleware-test'); expect(response.status()).toBe(200); - const serverTxnEvent = await serverTxnEventPromise; - const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + const spans = await spansPromise; + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); // Find spans for the array hooks middleware - const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); + const arrayHooksSpans = middlewareSpans.filter( + span => span.attributes['nuxt.middleware.name']?.value === '05.array-hooks', + ); // Should have spans for 2 middleware + 1 handler = 3 spans (h3 v2 no longer has onBeforeResponse) expect(arrayHooksSpans).toHaveLength(3); // Find middleware array spans const middlewareArraySpans = arrayHooksSpans.filter( - span => span.data?.['nuxt.middleware.hook.name'] === 'middleware', + span => span.attributes['nuxt.middleware.hook.name']?.value === 'middleware', ); expect(middlewareArraySpans).toHaveLength(2); // Find handler span - const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const handlerSpan = arrayHooksSpans.find(span => span.attributes['nuxt.middleware.hook.name']?.value === 'handler'); expect(handlerSpan).toBeDefined(); // Verify index attributes for middleware array - const middleware0Span = middlewareArraySpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); - const middleware1Span = middlewareArraySpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + const middleware0Span = middlewareArraySpans.find( + span => span.attributes['nuxt.middleware.hook.index']?.value === 0, + ); + const middleware1Span = middlewareArraySpans.find( + span => span.attributes['nuxt.middleware.hook.index']?.value === 1, + ); expect(middleware0Span).toBeDefined(); expect(middleware1Span).toBeDefined(); // Verify span names for array middleware handlers - expect(middleware0Span?.description).toBe('05.array-hooks.middleware'); - expect(middleware1Span?.description).toBe('05.array-hooks.middleware'); + expect(middleware0Span?.name).toBe('05.array-hooks.middleware'); + expect(middleware1Span?.name).toBe('05.array-hooks.middleware'); // Verify handler has no index - expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(handlerSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); }); test('should handle errors in middleware hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-5', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; @@ -226,25 +235,23 @@ test.describe('Server Middleware Instrumentation', () => { const response = await request.get('/api/middleware-test?throwOnRequestError=true'); expect(response.status()).toBe(500); - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); // Find the middleware span that should have error status - const middlewareSpan = serverTxnEvent.spans?.find( + const middlewareSpan = spans.find( span => - span.op === 'middleware' && - span.data?.['nuxt.middleware.name'] === '04.hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'middleware', + getSpanOp(span) === 'middleware' && + span.attributes['nuxt.middleware.name']?.value === '04.hooks' && + span.attributes['nuxt.middleware.hook.name']?.value === 'middleware', ); expect(middlewareSpan).toBeDefined(); - expect(middlewareSpan?.status).toBe('internal_error'); + expect(middlewareSpan?.status).toBe('error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); }); test('should handle errors in array middleware with proper index attribution', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-5', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; @@ -254,31 +261,28 @@ test.describe('Server Middleware Instrumentation', () => { const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); expect(response.status()).toBe(500); - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); + + const findArrayHookSpan = (index: number) => + spans.find( + span => + getSpanOp(span) === 'middleware' && + span.attributes['nuxt.middleware.name']?.value === '05.array-hooks' && + span.attributes['nuxt.middleware.hook.name']?.value === 'middleware' && + span.attributes['nuxt.middleware.hook.index']?.value === index, + ); // Find the second middleware span that should have error status - const middleware1Span = serverTxnEvent.spans?.find( - span => - span.op === 'middleware' && - span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'middleware' && - span.data?.['nuxt.middleware.hook.index'] === 1, - ); + const middleware1Span = findArrayHookSpan(1); expect(middleware1Span).toBeDefined(); - expect(middleware1Span?.status).toBe('internal_error'); + expect(middleware1Span?.status).toBe('error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); // Verify the first middleware handler still executed successfully - const middleware0Span = serverTxnEvent.spans?.find( - span => - span.op === 'middleware' && - span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'middleware' && - span.data?.['nuxt.middleware.hook.index'] === 0, - ); + const middleware0Span = findArrayHookSpan(0); expect(middleware0Span).toBeDefined(); - expect(middleware0Span?.status).not.toBe('internal_error'); + expect(middleware0Span?.status).not.toBe('error'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts index ad9e0803a580..4fb0460fb65d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts @@ -1,6 +1,16 @@ 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'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; + +async function collectStorageSpans(route: string) { + const spans = await collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === route), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === route); + + return spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt', + ); +} test.describe('Storage Instrumentation - Aliases', () => { const prefixKey = (key: string) => `test-storage:${key}`; @@ -8,100 +18,98 @@ test.describe('Storage Instrumentation - Aliases', () => { 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-5', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; - }); + const storageSpansPromise = collectStorageSpans('/api/storage-aliases-test'); const response = await request.get('/api/storage-aliases-test'); expect(response.status()).toBe(200); - const transaction = await transactionPromise; + const allStorageSpans = await storageSpansPromise; // Helper to find spans by operation - const findSpansByMethod = (method: string) => { - return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; - }; + const findSpansByMethod = (method: string) => + allStorageSpans.filter(span => span.attributes['db.operation.name']?.value === method); + + const findByKey = (method: string, key: string) => + findSpansByMethod(method).find(span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value === key); // Test set (alias for setItem) - const setSpans = findSpansByMethod('setItem'); - expect(setSpans.length).toBeGreaterThanOrEqual(1); - const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(findSpansByMethod('setItem').length).toBeGreaterThanOrEqual(1); + const setSpan = findByKey('setItem', prefixKey('alias:user')); expect(setSpan).toBeDefined(); - expect(setSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, + 'db.operation.name': { type: 'string', value: 'setItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(setSpan?.description).toBe(prefixKey('alias:user')); + expect(setSpan?.name).toBe('cache.put'); // Test get (alias for getItem) - const getSpans = findSpansByMethod('getItem'); - expect(getSpans.length).toBeGreaterThanOrEqual(1); - const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(findSpansByMethod('getItem').length).toBeGreaterThanOrEqual(1); + const getSpan = findByKey('getItem', prefixKey('alias:user')); expect(getSpan).toBeDefined(); - expect(getSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(getSpan?.description).toBe(prefixKey('alias:user')); + expect(getSpan?.name).toBe('cache.get'); // Test has (alias for hasItem) - const hasSpans = findSpansByMethod('hasItem'); - expect(hasSpans.length).toBeGreaterThanOrEqual(1); - const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(findSpansByMethod('hasItem').length).toBeGreaterThanOrEqual(1); + const hasSpan = findByKey('hasItem', prefixKey('alias:user')); expect(hasSpan).toBeDefined(); - expect(hasSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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', + expect(hasSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:user') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'hasItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test del and remove (both aliases for removeItem) - const removeSpans = findSpansByMethod('removeItem'); - expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + expect(findSpansByMethod('removeItem').length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls - const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + const delSpan = findByKey('removeItem', prefixKey('alias:temp1')); expect(delSpan).toBeDefined(); - expect(delSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp1') }, + 'db.operation.name': { type: 'string', value: 'removeItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + expect(delSpan?.name).toBe('cache.remove'); - const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + const removeSpan = findByKey('removeItem', prefixKey('alias:temp2')); expect(removeSpan).toBeDefined(); - expect(removeSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('alias:temp2') }, + 'db.operation.name': { type: 'string', value: 'removeItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + expect(removeSpan?.name).toBe('cache.remove'); // 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(allStorageSpans.length).toBeGreaterThan(0); + allStorageSpans.forEach(span => { expect(span.status).toBe('ok'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts index ad7c3b8b2b89..cc4452473d30 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts @@ -1,6 +1,16 @@ 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'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; + +async function collectStorageSpans(route: string) { + const spans = await collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === route), + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === route); + + return spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt', + ); +} test.describe('Storage Instrumentation', () => { const prefixKey = (key: string) => `test-storage:${key}`; @@ -8,143 +18,138 @@ test.describe('Storage Instrumentation', () => { const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; - }); + const storageSpansPromise = collectStorageSpans('/api/storage-test'); const response = await request.get('/api/storage-test'); expect(response.status()).toBe(200); - const transaction = await transactionPromise; + const allStorageSpans = await storageSpansPromise; // Helper to find spans by operation - const findSpansByMethod = (method: string) => { - return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; - }; + const findSpansByMethod = (method: string) => + allStorageSpans.filter(span => span.attributes['db.operation.name']?.value === method); + + const findSpanByCacheKey = (method: string, key: string) => + findSpansByMethod(method).find(span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value === key); // Test setItem spans - const setItemSpans = findSpansByMethod('setItem'); - expect(setItemSpans.length).toBeGreaterThanOrEqual(1); - const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(findSpansByMethod('setItem').length).toBeGreaterThanOrEqual(1); + const setItemSpan = findSpanByCacheKey('setItem', prefixKey('user:123')); expect(setItemSpan).toBeDefined(); - expect(setItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, + 'db.operation.name': { type: 'string', value: 'setItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + expect(setItemSpan?.name).toBe('cache.put'); // Test setItemRaw spans - const setItemRawSpans = findSpansByMethod('setItemRaw'); - expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); - const setItemRawSpan = setItemRawSpans.find( - span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), - ); + expect(findSpansByMethod('setItemRaw').length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = findSpanByCacheKey('setItemRaw', prefixKey('raw:data')); + expect(setItemRawSpan).toBeDefined(); - expect(setItemRawSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [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', + expect(setItemRawSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.operation': { type: 'string', value: 'put' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') }, + 'db.operation.name': { type: 'string', value: 'setItemRaw' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test hasItem spans - should have cache hit attribute - const hasItemSpans = findSpansByMethod('hasItem'); - expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); - const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(findSpansByMethod('hasItem').length).toBeGreaterThanOrEqual(1); + const hasItemSpan = findSpanByCacheKey('hasItem', prefixKey('user:123')); expect(hasItemSpan).toBeDefined(); - expect(hasItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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', + expect(hasItemSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'hasItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test getItem spans - should have cache hit attribute - const getItemSpans = findSpansByMethod('getItem'); - expect(getItemSpans.length).toBeGreaterThanOrEqual(1); - const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(findSpansByMethod('getItem').length).toBeGreaterThanOrEqual(1); + const getItemSpan = findSpanByCacheKey('getItem', prefixKey('user:123')); expect(getItemSpan).toBeDefined(); - expect(getItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('user:123') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); - expect(getItemSpan?.description).toBe(prefixKey('user:123')); + expect(getItemSpan?.name).toBe('cache.get'); // Test getItemRaw spans - should have cache hit attribute - const getItemRawSpans = findSpansByMethod('getItemRaw'); - expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); - const getItemRawSpan = getItemRawSpans.find( - span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), - ); + expect(findSpansByMethod('getItemRaw').length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = findSpanByCacheKey('getItemRaw', prefixKey('raw:data')); expect(getItemRawSpan).toBeDefined(); - expect(getItemRawSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [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', + expect(getItemRawSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('raw:data') }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItemRaw' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test getKeys spans const getKeysSpans = findSpansByMethod('getKeys'); expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); - expect(getKeysSpans[0]?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - 'db.operation.name': 'getKeys', - 'db.collection.name': 'test-storage', - 'db.system.name': 'memory', + expect(getKeysSpans[0]?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.operation': { type: 'string', value: 'get' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + 'db.operation.name': { type: 'string', value: 'getKeys' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test removeItem spans - const removeItemSpans = findSpansByMethod('removeItem'); - expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); - const removeItemSpan = removeItemSpans.find( - span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), - ); + expect(findSpansByMethod('removeItem').length).toBeGreaterThanOrEqual(1); + const removeItemSpan = findSpanByCacheKey('removeItem', prefixKey('batch:1')); expect(removeItemSpan).toBeDefined(); - expect(removeItemSpan?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [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', + expect(removeItemSpan?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: { type: 'string', value: prefixKey('batch:1') }, + 'db.operation.name': { type: 'string', value: 'removeItem' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); // Test clear spans const clearSpans = findSpansByMethod('clear'); expect(clearSpans.length).toBeGreaterThanOrEqual(1); - expect(clearSpans[0]?.data).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - 'db.operation.name': 'clear', - 'db.collection.name': 'test-storage', - 'db.system.name': 'memory', + expect(clearSpans[0]?.attributes).toMatchObject({ + 'sentry.op': { type: 'string', value: 'cache.remove' }, + 'cache.operation': { type: 'string', value: 'remove' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + 'db.operation.name': { type: 'string', value: 'clear' }, + 'db.collection.name': { type: 'string', value: 'test-storage' }, + 'db.system.name': { type: 'string', value: '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(allStorageSpans.length).toBeGreaterThan(0); + allStorageSpans.forEach(span => { expect(span.status).toBe('ok'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts index 543e3cbdff9d..90f197cd8641 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.describe('Rendering Modes with Cached HTML', () => { test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { @@ -29,6 +29,20 @@ test.describe('Rendering Modes with Cached HTML', () => { }); }); +// Cached pages have exact-match routes, so the server segment is selected by `url.path` instead of +// its name (route matching may or may not rename exact-match segments under span streaming). +function waitForServerSegment(routePath: string) { + return waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === routePath; + }); +} + +function waitForPageloadSegment(routePath: string) { + return waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && getSpanOp(span) === 'pageload' && span.attributes['url.path']?.value === routePath; + }); +} + /** * Tests that tracing meta-tags change with multiple requests on ISR-cached pages * This utility handles the common pattern of: @@ -47,18 +61,13 @@ export async function testChangingTracingMetaTagsOnISRPage( expectedPageText: string, ): Promise { // === 1. Request === - const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction === routePath; - }); + const clientSpanPromise1 = waitForPageloadSegment(routePath); + const serverSpanPromise1 = waitForServerSegment(routePath); - const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }); - - const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + const [_1, clientSpan1, serverSpan1] = await Promise.all([ page.goto(routePath), - clientTxnEventPromise1, - serverTxnEventPromise1, + clientSpanPromise1, + serverSpanPromise1, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); @@ -68,18 +77,13 @@ export async function testChangingTracingMetaTagsOnISRPage( // === 2. Request === - const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction === routePath; - }); - - const serverTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }); + const clientSpanPromise2 = waitForPageloadSegment(routePath); + const serverSpanPromise2 = waitForServerSegment(routePath); - const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([ + const [_2, clientSpan2, serverSpan2] = await Promise.all([ page.goto(routePath), - clientTxnEventPromise2, - serverTxnEventPromise2, + clientSpanPromise2, + serverSpanPromise2, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); @@ -87,30 +91,30 @@ export async function testChangingTracingMetaTagsOnISRPage( const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; - const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; - const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id; + const serverSpan1TraceId = serverSpan1.trace_id; + const serverSpan2TraceId = serverSpan2.trace_id; await test.step('Test distributed trace from 1. request', () => { - expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`); + expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverSpan1TraceId}`); - expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId); - expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id); - expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1); + expect(clientSpan1.trace_id).toBe(serverSpan1TraceId); + expect(clientSpan1.parent_span_id).toBe(serverSpan1.span_id); + expect(serverSpan1TraceId).toBe(htmlMetaTraceId1); }); await test.step('Test distributed trace from 2. request', () => { - expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`); + expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverSpan2TraceId}`); - expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId); - expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id); - expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2); + expect(clientSpan2.trace_id).toBe(serverSpan2TraceId); + expect(clientSpan2.parent_span_id).toBe(serverSpan2.span_id); + expect(serverSpan2TraceId).toBe(htmlMetaTraceId2); }); await test.step('Test that trace IDs from subsequent requests are different', () => { - // Different trace IDs for the server transactions - expect(serverTxnEvent1TraceId).toBeDefined(); - expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId); - expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2); + // Different trace IDs for the server root spans + expect(serverSpan1TraceId).toBeDefined(); + expect(serverSpan1TraceId).not.toBe(serverSpan2TraceId); + expect(serverSpan1TraceId).not.toBe(htmlMetaTraceId2); }); } @@ -119,13 +123,11 @@ export async function testChangingTracingMetaTagsOnISRPage( * This utility handles the common pattern of: * 1. Making two requests to a cached page * 2. Verifying no tracing meta-tags are present - * 3. Verifying only the first request creates a server transaction - * 4. Verifying traces are not distributed + * 3. Verifying traces are not distributed (each pageload starts its own trace) * * @param page - Playwright page object * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') - * @returns Object containing transaction events for additional custom assertions */ export async function testExcludeTracingMetaTagsOnCachedPage( page: Page, @@ -133,19 +135,15 @@ export async function testExcludeTracingMetaTagsOnCachedPage( expectedPageText: string, ): Promise { // === 1. Request === - const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction === routePath; - }); + const clientSpanPromise1 = waitForPageloadSegment(routePath); - // Only the 1. request creates a server transaction - const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }); + // Only the 1. request creates a server root span + const serverSpanPromise1 = waitForServerSegment(routePath); - const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + const [_1, clientSpan1, serverSpan1] = await Promise.all([ page.goto(routePath), - clientTxnEventPromise1, - serverTxnEventPromise1, + clientSpanPromise1, + serverSpanPromise1, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); @@ -155,54 +153,37 @@ export async function testExcludeTracingMetaTagsOnCachedPage( // === 2. Request === - await page.goto(routePath); - - const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction === routePath; - }); - - let serverTxnEvent2 = undefined; - const serverTxnEventPromise2 = Promise.race([ - waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; - }), - new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)), - ]); + const clientSpanPromise2 = waitForPageloadSegment(routePath); - try { - serverTxnEvent2 = await serverTxnEventPromise2; - throw new Error('Second server transaction should not have been sent'); - } catch (error) { - expect(error.message).toBe('No second server transaction expected'); - } - - const [clientTxnEvent2] = await Promise.all([ - clientTxnEventPromise2, + const [_2, clientSpan2] = await Promise.all([ + page.goto(routePath), + clientSpanPromise2, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); - const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id; - const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id; + const clientSpan1TraceId = clientSpan1.trace_id; + const clientSpan2TraceId = clientSpan2.trace_id; - const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; - const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id; + const serverSpan1TraceId = serverSpan1.trace_id; await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { expect(await page.locator('meta[name="baggage"]').count()).toBe(0); expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); }); - await test.step('1. Server Transaction and all Client Transactions are defined', () => { - expect(serverTxnEvent1TraceId).toBeDefined(); - expect(clientTxnEvent1TraceId).toBeDefined(); - expect(clientTxnEvent2TraceId).toBeDefined(); - expect(serverTxnEvent2).toBeUndefined(); - expect(serverTxnEvent2TraceId).toBeUndefined(); + await test.step('1. server root span and all client root spans are defined', () => { + expect(serverSpan1TraceId).toBeDefined(); + expect(clientSpan1TraceId).toBeDefined(); + expect(clientSpan2TraceId).toBeDefined(); }); await test.step('Trace is not distributed', () => { // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) - expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId); - expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId); + expect(clientSpan1TraceId).not.toBe(clientSpan2TraceId); + expect(clientSpan1TraceId).not.toBe(serverSpan1TraceId); + expect(clientSpan2TraceId).not.toBe(serverSpan1TraceId); + // Without meta tags the pageloads have no parent and start their own traces + expect(clientSpan1.parent_span_id).toBeUndefined(); + expect(clientSpan2.parent_span_id).toBeUndefined(); }); } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts index adeeb34fe57a..8c28561ec20c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts @@ -1,96 +1,72 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; -import type { Span } from '@sentry/nuxt'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('sends a pageload root span with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { - return transactionEvent.transaction === '/test-param/:param()'; + const pageloadSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/test-param/1234`); - const rootSpan = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.pageload.vue', - 'sentry.op': 'pageload', - 'params.param': '1234', - 'url.template': '/test-param/:param()', - 'url.path': '/test-param/1234', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), - }, - op: 'pageload', - origin: 'auto.pageload.vue', - }, - }, - transaction: '/test-param/:param()', - transaction_info: { - source: 'route', - }, + expect(pageloadSpan.name).toBe('/test-param/:param()'); + expect(pageloadSpan.status).toBe('ok'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.op': { type: 'string', value: 'pageload' }, + 'params.param': { type: 'string', value: '1234' }, + 'url.template': { type: 'string', value: '/test-param/:param()' }, + 'url.path': { type: 'string', value: '/test-param/1234' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/) }, }); }); test('sends a navigation root span with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/test-param/:param()' - ); + const navigationSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/test-param/:param()'; }); await page.goto(`/`); await page.getByText('Fetch Param').click(); - const rootSpan = await transactionPromise; + const navigationSpan = await navigationSpanPromise; - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.navigation.vue', - 'sentry.op': 'navigation', - 'params.param': '1234', - 'url.template': '/test-param/:param()', - 'url.path': '/test-param/1234', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), - }, - op: 'navigation', - origin: 'auto.navigation.vue', - }, - }, - transaction: '/test-param/:param()', - transaction_info: { - source: 'route', - }, + expect(navigationSpan.name).toBe('/test-param/:param()'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.navigation.vue' }, + 'sentry.op': { type: 'string', value: 'navigation' }, + 'params.param': { type: 'string', value: '1234' }, + 'url.template': { type: 'string', value: '/test-param/:param()' }, + 'url.path': { type: 'string', value: '/test-param/1234' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/) }, }); }); // fixme: note that this test only works because we explictly enabled Vue’s Options API in the nuxt config test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { - return transactionEvent.transaction === '/client-error'; - }); + const spansPromise = collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'), + ); await page.goto(`/client-error`); - const rootSpan = await transactionPromise; - const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + const spans = await spansPromise; + const errorButtonSpan = spans.find(span => span.name === 'Vue '); - const expected = { - data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount' }, - description: 'Vue ', - op: 'ui.mount', + expect(errorButtonSpan).toMatchObject({ + name: 'Vue ', + is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.vue', - }; - - expect(errorButtonSpan).toMatchObject(expected); + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.mount' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + }), + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts index 70679251cfed..f9da1e425c0e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts @@ -1,64 +1,63 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a server action transaction on pageload', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); +test('sends a server root span on pageload', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); await page.goto('/test-param/1234'); - const transaction = await transactionPromise; + const serverSpan = await serverSpanPromise; - expect(transaction.contexts.trace).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.http_server', - }), - }), - ); + expect(getSpanOp(serverSpan)).toBe('http.server'); + expect(serverSpan.attributes['sentry.origin']?.value).toBe('auto.http.http_server'); }); -test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { +test('does not send spans for build asset folder "_nuxt"', async ({ page }) => { let buildAssetFolderOccurred = false; - waitForTransaction('nuxt-5', transactionEvent => { - if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + waitForStreamedSpan('nuxt-5', span => { + if (span.is_segment && /^GET \/_nuxt\//.test(span.name)) { buildAssetFolderOccurred = true; } return false; // expects to return a boolean (but not relevant here) }); - const transactionEventPromise = waitForTransaction('nuxt-5', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); + const serverSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); await page.goto('/test-param/1234'); - const transactionEvent = await transactionEventPromise; + const serverSpan = await serverSpanPromise; expect(buildAssetFolderOccurred).toBe(false); - expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); + expect(serverSpan.name).toBe('GET /test-param/:param()'); + expect(serverSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); }); // TODO: Make test work with Nuxt 5 test.skip('captures server API calls made with Nitro $fetch', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { - return transactionEvent.transaction === 'GET /api/nitro-fetch'; - }); + const spansPromise = collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch'), + ); await page.goto(`/fetch-server-routes`); await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); - const httpServerFetchSpan = await transactionPromise; - const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + const spans = await spansPromise; + + const httpServerSpan = spans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch', + ); + const httpClientSpan = spans.find( + span => span.trace_id === httpServerSpan?.trace_id && span.attributes['url.full']?.value === 'https://example.com/', + ); - expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); - expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + expect(getSpanOp(httpServerSpan!)).toEqual('http.server'); - expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); - expect(httpClientSpan.op).toEqual('http.client'); + expect(httpClientSpan?.parent_span_id).toEqual(httpServerSpan?.span_id); + expect(getSpanOp(httpClientSpan!)).toEqual('http.client'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts index 392ae9e46793..9476ea84df14 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts @@ -1,22 +1,23 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.describe('distributed tracing', () => { const PARAM = 's0me-param'; + const API_PATH = `/api/user/${PARAM}`; test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction === '/test-param/:param()'; + const clientSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /test-param/') ?? false; + const serverSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && span.name.includes('GET /test-param/'); }); - const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + const [_, clientSpan, serverSpan] = await Promise.all([ page.goto(`/test-param/${PARAM}`), - clientTxnEventPromise, - serverTxnEventPromise, + clientSpanPromise, + serverSpanPromise, expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); @@ -24,7 +25,7 @@ test.describe('distributed tracing', () => { // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); - expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverSpan.trace_id}`); expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -33,125 +34,115 @@ test.describe('distributed tracing', () => { expect(metaSampled).toBe('1'); - expect(clientTxnEvent).toMatchObject({ - transaction: '/test-param/:param()', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.vue', - trace_id: metaTraceId, - parent_span_id: metaParentSpanId, - }, - }, + expect(clientSpan).toMatchObject({ + name: '/test-param/:param()', + is_segment: true, + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), }); - expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/:param()`, // parametrized route - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.http_server', - }, - }, + expect(serverSpan).toMatchObject({ + name: 'GET /test-param/:param()', // parametrized + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), }); // connected trace - expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); - - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); - expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); - expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + expect(clientSpan.trace_id).toBe(serverSpan.trace_id); + expect(clientSpan.parent_span_id).toBe(serverSpan.span_id); + expect(serverSpan.trace_id).toBe(metaTraceId); }); test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction === '/test-param/user/:userId()'; + // The `http.client` span ends after the pageload segment, so it can be flushed in a later + // envelope. Accumulate until both spans have arrived. + const clientSpansPromise = collectStreamedSpans('nuxt-5', spans => { + return ( + spans.some(span => span.name === '/test-param/user/:userId()' && span.is_segment) && + spans.some( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), + ) + ); }); - const ssrTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + const ssrSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && span.name.includes('GET /test-param/user'); }); - const serverReqTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/user/') ?? false; + const serverReqSpanPromise = waitForStreamedSpan('nuxt-5', span => { + return span.is_segment && span.name.includes('GET /api/user/'); }); // Navigate to the page which will trigger an API call from the client-side await page.goto(`/test-param/user/${PARAM}`); - const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ - clientTxnEventPromise, - ssrTxnEventPromise, - serverReqTxnEventPromise, + const [clientSpans, ssrSpan, serverReqSpan] = await Promise.all([ + clientSpansPromise, + ssrSpanPromise, + serverReqSpanPromise, ]); - const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); - - expect(clientTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: '/test-param/user/:userId()', // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'pageload', - origin: 'auto.pageload.vue', - }), - }), - }), + const pageloadSpan = clientSpans.find(span => span.name === '/test-param/user/:userId()' && span.is_segment); + const httpClientSpan = clientSpans.find( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), ); + expect(pageloadSpan).toMatchObject({ + name: '/test-param/user/:userId()', + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), + }); + expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan).toEqual( - expect.objectContaining({ - description: `GET /api/user/${PARAM}`, // fixme: parametrize - parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent - data: expect.objectContaining({ - 'url.full': expect.stringContaining(`/api/user/${PARAM}`), - type: 'fetch', - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.browser', - 'http.request.method': 'GET', - }), + expect(httpClientSpan).toMatchObject({ + // A relative fetch has no domain of its own, so it resolves against the page origin. + name: 'GET localhost', + parent_span_id: pageloadSpan?.span_id, // pageload span is parent + attributes: expect.objectContaining({ + type: { type: 'string', value: 'fetch' }, + 'sentry.op': { type: 'string', value: 'http.client' }, + 'sentry.origin': { type: 'string', value: 'auto.http.browser' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: expect.stringContaining(API_PATH) }, + 'url.domain': { type: 'string', value: 'localhost' }, }), - ); + }); - expect(ssrTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: `GET /test-param/user/:userId()`, // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.http_server', - }), - }), + expect(ssrSpan).toMatchObject({ + name: 'GET /test-param/user/:userId()', // parametrized route + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, }), - ); + }); - expect(serverReqTxnEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: `GET /api/user/:userId`, // parametrized route - transaction_info: { source: 'route' }, - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.http_server', - parent_span_id: httpClientSpan?.span_id, // http.client span is parent - }), - }), + expect(serverReqSpan).toMatchObject({ + name: 'GET /api/user/:userId', // parametrized route + is_segment: true, + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, }), - ); + }); - // All 3 transactions and the http.client span should share the same trace_id - expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); - expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + // All 3 root spans and the http.client span should share the same trace_id + expect(pageloadSpan?.trace_id).toBeDefined(); + expect(pageloadSpan?.trace_id).toBe(httpClientSpan?.trace_id); + expect(pageloadSpan?.trace_id).toBe(ssrSpan.trace_id); + expect(pageloadSpan?.trace_id).toBe(serverReqSpan.trace_id); }); }); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts index b128a147ff52..46dfc4036b9c 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts @@ -1,5 +1,5 @@ import { vitePlugin as remix } from '@remix-run/dev'; -import { sentryRemixVitePlugin } from '@sentry/remix'; +import { sentryRemixVitePlugin } from '@sentry/remix/vite'; import { hydrogen } from '@shopify/hydrogen/vite'; import { oxygen } from '@shopify/mini-oxygen/vite'; import { defineConfig } from 'vite'; diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts index d4d7f23895c1..bd4060f4a14b 100644 --- a/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts @@ -1,5 +1,5 @@ import { vitePlugin as remix } from '@remix-run/dev'; -import { sentryRemixVitePlugin } from '@sentry/remix'; +import { sentryRemixVitePlugin } from '@sentry/remix/vite'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send-throws/scenario.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send-throws/scenario.ts new file mode 100644 index 000000000000..299630f41cce --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send-throws/scenario.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + beforeSend() { + throw new Error('beforeSend failed'); + }, +}); + +Sentry.captureException(new Error('this should get dropped because beforeSend throws')); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send-throws/test.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send-throws/test.ts new file mode 100644 index 000000000000..e30038efc57b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send-throws/test.ts @@ -0,0 +1,24 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('records a client report and no extra error event when beforeSend throws', async () => { + await createRunner(__dirname, 'scenario.ts') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'before_send', + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/scenario-async.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/scenario-async.ts new file mode 100644 index 000000000000..bade2d046447 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/scenario-async.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, +}); + +Sentry.addEventProcessor(async () => { + throw new Error('async event processor failed'); +}); + +Sentry.captureException(new Error('this should get dropped because the async event processor rejects')); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/scenario.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/scenario.ts new file mode 100644 index 000000000000..31d53fa48621 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/scenario.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, +}); + +Sentry.addEventProcessor(() => { + throw new Error('event processor failed'); +}); + +Sentry.captureException(new Error('this should get dropped because the event processor throws')); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/test.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/test.ts new file mode 100644 index 000000000000..370cccc35410 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/test.ts @@ -0,0 +1,42 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('records a client report and no extra error event when an event processor throws', async () => { + await createRunner(__dirname, 'scenario.ts') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'event_processor', + }, + ], + }, + }) + .start() + .completed(); +}); + +test('records a client report and no extra error event when an async event processor rejects', async () => { + await createRunner(__dirname, 'scenario-async.ts') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'event_processor', + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/scenario-fallback.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/scenario-fallback.ts new file mode 100644 index 000000000000..9d5524afe3ab --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/scenario-fallback.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + tracesSampleRate: 1, + tracesSampler: () => { + throw new Error('tracesSampler failed'); + }, +}); + +Sentry.startSpan({ name: 'sampled via tracesSampleRate fallback' }, () => { + // no-op +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/scenario.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/scenario.ts new file mode 100644 index 000000000000..fac664dbde15 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/scenario.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + tracesSampler: () => { + throw new Error('tracesSampler failed'); + }, +}); + +Sentry.startSpan({ name: 'this should not be sampled because tracesSampler throws' }, () => { + // no-op +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts new file mode 100644 index 000000000000..df6cbb3195b2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts @@ -0,0 +1,35 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('records a client report and no error event when tracesSampler throws', async () => { + await createRunner(__dirname, 'scenario.ts') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 1, + reason: 'sample_rate', + }, + ], + }, + }) + .start() + .completed(); +}); + +test('sends the span when tracesSampler throws but tracesSampleRate is 1', async () => { + await createRunner(__dirname, 'scenario-fallback.ts') + .expect({ + transaction: { + transaction: 'sampled via tracesSampleRate fallback', + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs index c0a1998369a5..d4f8cc7801fc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/instrument.mjs @@ -8,4 +8,7 @@ Sentry.init({ tracesSampleRate: 1.0, transport: loggingTransport, integrations: [Sentry.redisIntegration({ cachePrefixes: ['dc-cache:'] })], + ignoreSpans: process.env.IGNORE_CACHE_GET === 'true' ? [{ op: 'cache.get' }] : undefined, + // so the `ignored` span outcomes flush while the scenario is still running + clientReportFlushInterval: 1_000, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs index b98a66cdd273..661b37ef9c6d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs @@ -21,6 +21,10 @@ async function run() { await redisClient.get('dc-cache:unavailable-data'); await redisClient.mGet(['dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data']); + + // a failing command on a cache key (GET on a list rejects with WRONGTYPE) + await redisClient.lPush('dc-cache:list-key', 'value'); + await redisClient.get('dc-cache:list-key').catch(() => {}); } finally { await redisClient.disconnect(); } diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts index a02326b0a1cf..db965e173046 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -24,7 +24,7 @@ describeWithDockerCompose( 'db.query.text': 'SET dc-test-key ?', }), }), - // cache SET: span name updated to key by cacheResponseHook + // cache SET: starts as a cache span, named by its key in the static lifecycle expect.objectContaining({ description: 'dc-cache:test-key', op: 'cache.put', @@ -95,6 +95,18 @@ describeWithDockerCompose( 'db.query.text': 'MGET ? ? ?', }), }), + // a failing command on a cache key reports as an errored cache span: + // the span starts as a cache span, so the classification survives the error + expect.objectContaining({ + description: 'dc-cache:list-key', + op: 'cache.get', + status: 'internal_error', + origin: 'auto.db.redis.diagnostic_channel', + data: expect.objectContaining({ + 'cache.operation': 'get', + 'cache.key': ['dc-cache:list-key'], + }), + }), ]), }; @@ -112,6 +124,34 @@ describeWithDockerCompose( .start() .completed(); }); + + // `ignoreSpans` is evaluated at span start under streaming, so this only passes because the + // span starts as a cache span — a db span renamed at response time would slip through. + test('drops cache spans matching an ignoreSpans op filter at span start', { timeout: 60_000 }, async () => { + await createTestRunner() + .withEnv({ STREAMED: 'true', IGNORE_CACHE_GET: 'true' }) + .unignore('client_report') + // The span container and the client report flush on independent timers, so they can + // arrive in either order. + .unordered() + .expect({ + span: (container: SerializedStreamedSpanContainer) => { + const names = container.items.map(item => item.name); + expect(names).toContain('cache.put'); + expect(names).not.toContain('cache.get'); + }, + }) + .expect({ + client_report: { + discarded_events: [ + // the two GETs on cache keys plus the failing GET, which is also decided at start + { category: 'span', quantity: 3, reason: 'ignored' }, + ], + }, + }) + .start() + .completed(); + }); }); // The same commands as above, asserted on the streamed span container. With span streaming the @@ -165,8 +205,8 @@ describeWithDockerCompose( const PEER = { 'network.peer.address': HOST, 'network.peer.port': PORT }; - // A cache span is a db span the cache hook took over: it is renamed to its cache operation - // and reports the connection it inherited as peer attributes too. + // A cache span is a db span whose key matched a cache prefix: it starts named after its + // cache operation and reports the connection as peer attributes too. const cacheSpan = ( op: 'cache.get' | 'cache.put' | 'cache.remove', attributes: Record, @@ -194,7 +234,7 @@ describeWithDockerCompose( 'db.operation.name': 'SET', 'db.query.text': 'SET dc-test-key ?', }), - // cache SET: turned into a cache span, and renamed by the cache hook + // cache SET: starts as a cache span cacheSpan('cache.put', { 'db.operation.name': 'SET', 'db.query.text': 'SET dc-cache:test-key ?', @@ -233,6 +273,22 @@ describeWithDockerCompose( 'db.operation.name': 'MGET', 'db.query.text': 'MGET ? ? ?', }), + streamedSpan(`LPUSH ${HOST}:${PORT}`, 'db.query', { + 'db.operation.name': 'LPUSH', + 'db.query.text': 'LPUSH dc-cache:list-key ?', + }), + // a failing command on a cache key reports as an errored cache span: + // the span starts as a cache span, so the classification survives the error + { + ...(cacheSpan('cache.get', { + 'db.operation.name': 'GET', + 'db.query.text': 'GET dc-cache:list-key', + 'cache.key': ['dc-cache:list-key'], + 'error.type': 'Error', + 'sentry.status.message': 'WRONGTYPE Operation against a key holding the wrong kind of value', + }) as Record), + status: 'error', + }, ]); }, }) diff --git a/packages/core/src/breadcrumbs.ts b/packages/core/src/breadcrumbs.ts index d511c6f5801f..84a38fccae99 100644 --- a/packages/core/src/breadcrumbs.ts +++ b/packages/core/src/breadcrumbs.ts @@ -1,6 +1,8 @@ import { getClient, getIsolationScope } from './currentScopes'; +import { DEBUG_BUILD } from './debug-build'; import type { Breadcrumb, BreadcrumbHint } from './types/breadcrumb'; import { consoleSandbox } from './utils/debug-logger'; +import { safeCallback } from './utils/safeCallback'; import { dateTimestampInSeconds } from './utils/time'; /** @@ -28,7 +30,11 @@ export function addBreadcrumb(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): vo const timestamp = dateTimestampInSeconds(); const mergedBreadcrumb = { timestamp, ...breadcrumb }; const finalBreadcrumb = beforeBreadcrumb - ? consoleSandbox(() => beforeBreadcrumb(mergedBreadcrumb, hint)) + ? safeCallback( + DEBUG_BUILD ? 'The `beforeBreadcrumb` callback threw an error, dropping the breadcrumb:' : '', + () => consoleSandbox(() => beforeBreadcrumb(mergedBreadcrumb, hint)), + () => null, + ) : mergedBreadcrumb; if (finalBreadcrumb === null) return; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5a14b13c07fa..2de21ba37b41 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -50,6 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; import { safeMathRandom } from './utils/randomSafeContext'; +import { safeCallback } from './utils/safeCallback'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { safeUnref } from './utils/timer'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; @@ -1738,7 +1739,12 @@ function processBeforeSend( let processedEvent = event; if (isErrorEvent(processedEvent) && beforeSend) { - return beforeSend(processedEvent, hint); + const errorEvent = processedEvent; + return safeCallback( + DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '', + () => beforeSend(errorEvent, hint), + () => null, + ); } if (isTransactionEvent(processedEvent)) { @@ -1809,7 +1815,11 @@ function processBeforeSend( spanCountBeforeProcessing: spanCountBefore, }; } - return beforeSendTransaction(processedEvent as TransactionEvent, hint); + return safeCallback( + DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '', + () => beforeSendTransaction(processedEvent as TransactionEvent, hint), + () => null, + ); } } diff --git a/packages/core/src/eventProcessors.ts b/packages/core/src/eventProcessors.ts index 99a15781e06c..ef25375d7716 100644 --- a/packages/core/src/eventProcessors.ts +++ b/packages/core/src/eventProcessors.ts @@ -3,6 +3,7 @@ import type { Event, EventHint } from './types/event'; import type { EventProcessor } from './types/eventprocessor'; import { debug } from './utils/debug-logger'; import { isThenable } from './utils/is'; +import { safeCallback } from './utils/safeCallback'; import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise'; /** @@ -34,9 +35,15 @@ function _notifyEventProcessors( return event; } - const result = processor({ ...event }, hint); + const processorName = `Event processor "${processor.id || '?'}"`; - DEBUG_BUILD && result === null && debug.log(`Event processor "${processor.id || '?'}" dropped event`); + const result = safeCallback( + DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '', + () => processor({ ...event }, hint), + () => null, + ); + + DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`); if (isThenable(result)) { return result.then(final => _notifyEventProcessors(final, hint, processors, index + 1)); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index afdeac10921a..dc01c23fda8f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -94,6 +94,7 @@ export { export { safeSetSpanJSONAttributes } from './tracing/spans/captureSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; +export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; export { handleTunnelRequest } from './utils/tunnel'; diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 4b610b288ca8..3ad106dfbb32 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -8,6 +8,7 @@ import type { Integration } from '../types/integration'; import type { Log, SerializedLog } from '../types/log'; import { consoleSandbox, debug } from '../utils/debug-logger'; import { isParameterizedString } from '../utils/is'; +import { safeCallback } from '../utils/safeCallback'; import { getCombinedScopeData } from '../utils/scopeData'; import { getActiveSpan } from '../utils/spanUtils'; import { timestampInSeconds } from '../utils/time'; @@ -142,8 +143,14 @@ export function _INTERNAL_captureLog( client.emit('beforeCaptureLog', processedLog); - // We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog` - const log = beforeSendLog ? consoleSandbox(() => beforeSendLog(processedLog)) : processedLog; + const log = beforeSendLog + ? safeCallback( + DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '', + // We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog` + () => consoleSandbox(() => beforeSendLog(processedLog)), + () => null, + ) + : processedLog; if (!log) { client.recordDroppedEvent('before_send', 'log_item', 1); DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.'); diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index c884399624b8..621992b2ed70 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -8,6 +8,7 @@ import type { Integration } from '../types/integration'; import type { Metric, SerializedMetric } from '../types/metric'; import type { User } from '../types/user'; import { debug } from '../utils/debug-logger'; +import { safeCallback } from '../utils/safeCallback'; import { getCombinedScopeData } from '../utils/scopeData'; import { getActiveSpan } from '../utils/spanUtils'; import { timestampInSeconds } from '../utils/time'; @@ -181,9 +182,16 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal client.emit('processMetric', enrichedMetric); - const processedMetric = beforeSendMetric ? beforeSendMetric(enrichedMetric) : enrichedMetric; + const processedMetric = beforeSendMetric + ? safeCallback( + DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '', + () => beforeSendMetric(enrichedMetric), + () => null, + ) + : enrichedMetric; if (!processedMetric) { + client.recordDroppedEvent('before_send', 'metric', 1); DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.'); return; } diff --git a/packages/core/src/tracing/sampling.ts b/packages/core/src/tracing/sampling.ts index efc743238107..2b9e696fc3fe 100644 --- a/packages/core/src/tracing/sampling.ts +++ b/packages/core/src/tracing/sampling.ts @@ -4,6 +4,7 @@ import type { SamplingContext } from '../types/samplingcontext'; import { debug } from '../utils/debug-logger'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; +import { safeCallback } from '../utils/safeCallback'; /** * Makes a sampling decision for the given options. @@ -21,37 +22,11 @@ export function sampleSpan( return [false]; } - let localSampleRateWasApplied = undefined; - - // we would have bailed already if neither `tracesSampler` nor `tracesSampleRate` were defined, so one of these should - // work; prefer the hook if so - let sampleRate; - if (typeof options.tracesSampler === 'function') { - sampleRate = options.tracesSampler({ - ...samplingContext, - inheritOrSampleWith: fallbackSampleRate => { - // If we have an incoming parent sample rate, we'll just use that one. - // The sampling decision will be inherited because of the sample_rand that was generated when the trace reached the incoming boundaries of the SDK. - if (typeof samplingContext.parentSampleRate === 'number') { - return samplingContext.parentSampleRate; - } - - // Fallback if parent sample rate is not on the incoming trace (e.g. if there is no baggage) - // This is to provide backwards compatibility if there are incoming traces from older SDKs that don't send a parent sample rate or a sample rand. In these cases we just want to force either a sampling decision on the downstream traces via the sample rate. - if (typeof samplingContext.parentSampled === 'boolean') { - return Number(samplingContext.parentSampled); - } - - return fallbackSampleRate; - }, - }); - localSampleRateWasApplied = true; - } else if (samplingContext.parentSampled !== undefined) { - sampleRate = samplingContext.parentSampled; - } else if (typeof options.tracesSampleRate !== 'undefined') { - sampleRate = options.tracesSampleRate; - localSampleRateWasApplied = true; + const resolved = resolveSampleRate(options, samplingContext); + if (!resolved) { + return [false]; } + const [sampleRate, localSampleRateWasApplied] = resolved; // Since this is coming from the user (or from a function provided by the user), who knows what we might get. // (The only valid values are booleans or numbers between 0 and 1.) @@ -96,3 +71,57 @@ export function sampleSpan( return [shouldSample, parsedSampleRate, localSampleRateWasApplied]; } + +/** + * Prefers `tracesSampler`. If it throws, falls back to the parent decision, then `tracesSampleRate`. + * Returns `undefined` when there is nothing to fall back to. + */ +function resolveSampleRate( + options: Pick, + samplingContext: SamplingContext, +): [sampleRate: unknown, localSampleRateWasApplied?: boolean] | undefined { + const { tracesSampler, tracesSampleRate } = options; + + if (typeof tracesSampler === 'function') { + const samplerResult = safeCallback( + DEBUG_BUILD + ? 'The `tracesSampler` callback threw an error, falling back to the parent sampling decision or `tracesSampleRate`:' + : '', + (): [unknown, boolean] => [ + tracesSampler({ + ...samplingContext, + inheritOrSampleWith: fallbackSampleRate => { + // If we have an incoming parent sample rate, we'll just use that one. + // The sampling decision will be inherited because of the sample_rand that was generated when the trace reached the incoming boundaries of the SDK. + if (typeof samplingContext.parentSampleRate === 'number') { + return samplingContext.parentSampleRate; + } + + // Fallback if parent sample rate is not on the incoming trace (e.g. if there is no baggage) + // This is to provide backwards compatibility if there are incoming traces from older SDKs that don't send a parent sample rate or a sample rand. In these cases we just want to force either a sampling decision on the downstream traces via the sample rate. + if (typeof samplingContext.parentSampled === 'boolean') { + return Number(samplingContext.parentSampled); + } + + return fallbackSampleRate; + }, + }), + true, + ], + () => undefined, + ); + if (samplerResult) { + return samplerResult; + } + } + + if (samplingContext.parentSampled !== undefined) { + return [samplingContext.parentSampled]; + } + + if (typeof tracesSampleRate !== 'undefined') { + return [tracesSampleRate, true]; + } + + return undefined; +} diff --git a/packages/core/src/tracing/spans/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts index 1d1126d7f883..b2fcd8261079 100644 --- a/packages/core/src/tracing/spans/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -2,7 +2,8 @@ import { DEBUG_BUILD } from '../../debug-build'; import type { BeforeSendStaticSpanCallback, BeforeSendStreamedSpanCallback } from '../../types/options'; import type { SpanJSON, StreamedSpanJSON } from '../../types/span'; import { addNonEnumerableProperty } from '../../utils/object'; -import { consoleSandbox, debug } from '../../utils/debug-logger'; +import { consoleSandbox } from '../../utils/debug-logger'; +import { safeCallback } from '../../utils/safeCallback'; /** * A wrapper to use the static, transaction-based span format in your `beforeSendSpan` callback. @@ -64,25 +65,25 @@ export function applyBeforeSendSpanCallback T, ): T { - try { - const modifedSpan = beforeSendSpan(span); - if (!modifedSpan) { - if (!hasShownSpanDropWarning) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', - ); - }); - hasShownSpanDropWarning = true; - } - return span; - } - return modifedSpan; - } catch (error) { - // Spans are captured synchronously when they end, so a throwing callback would otherwise - // propagate into whatever user code ended the span. - DEBUG_BUILD && debug.error('The `beforeSendSpan` callback threw an error, sending the span unmodified:', error); - return span; + // Spans are captured synchronously when they end, so a throwing callback would otherwise + // propagate into whatever user code ended the span. + const modifiedSpan = safeCallback( + DEBUG_BUILD ? 'The `beforeSendSpan` callback threw an error, sending the span unmodified:' : '', + () => beforeSendSpan(span), + () => span, + ); + if (modifiedSpan) { + return modifiedSpan; } + + if (!hasShownSpanDropWarning) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.', + ); + }); + hasShownSpanDropWarning = true; + } + return span; } diff --git a/packages/core/src/utils/safeCallback.ts b/packages/core/src/utils/safeCallback.ts new file mode 100644 index 000000000000..5b9079ca7c5d --- /dev/null +++ b/packages/core/src/utils/safeCallback.ts @@ -0,0 +1,35 @@ +import { DEBUG_BUILD } from '../debug-build'; +import { debug } from './debug-logger'; +import { isThenable } from './is'; + +/** + * Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that + * neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is + * logged and `fallback(error)` supplies the result instead. + * + * Not for `startSpan` bodies: those must re-throw and are handled by `handleCallbackErrors`. + * + * @param message - Logged via `debug.error` together with the error. Pass it as `DEBUG_BUILD ? '...' : ''` + * so the string is tree-shaken from non-debug bundles. + * @param fn - Invokes the callback. + * @param fallback - Produces the result to use when the callback throws or rejects. + */ +export function safeCallback(message: string, fn: () => T, fallback: (error: unknown) => T): T { + let result: T; + try { + result = fn(); + } catch (error) { + return recover(message, error, fallback); + } + + if (isThenable(result)) { + return result.then(undefined, (error: unknown) => recover(message, error, fallback)) as T; + } + + return result; +} + +function recover(message: string, error: unknown, fallback: (error: unknown) => T): T { + DEBUG_BUILD && debug.error(message, error); + return fallback(error); +} diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index e3ee176bf638..e43c35d2fe63 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -412,6 +412,27 @@ describe('Client', () => { expect(isolationScopeBreadcrumbs).toEqual([]); }); + test('calls `beforeBreadcrumb` and discards the breadcrumb when it throws', () => { + const exception = new Error('beforeBreadcrumb failed'); + const beforeBreadcrumb = vi.fn(() => { + throw exception; + }); + const options = getDefaultTestClientOptions({ beforeBreadcrumb }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + + expect(() => addBreadcrumb({ message: 'hello' })).not.toThrow(); + + const isolationScopeBreadcrumbs = getIsolationScope().getScopeData().breadcrumbs; + expect(isolationScopeBreadcrumbs).toEqual([]); + expect(debugErrorSpy).toHaveBeenCalledWith( + 'The `beforeBreadcrumb` callback threw an error, dropping the breadcrumb:', + exception, + ); + }); + test('`beforeBreadcrumb` gets an access to a hint as a second argument', () => { const beforeBreadcrumb = vi.fn((breadcrumb, hint) => ({ ...breadcrumb, data: hint.data })); const options = getDefaultTestClientOptions({ beforeBreadcrumb }); @@ -2185,11 +2206,12 @@ describe('Client', () => { }); }); - test('event processor sends an event and logs when it crashes synchronously', () => { + test('drops the event and records a client report when an event processor throws synchronously', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const captureExceptionSpy = vi.spyOn(client, 'captureException'); - const loggerWarnSpy = vi.spyOn(debugLoggerModule.debug, 'warn'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); const scope = new Scope(); const exception = new Error('sorry 1'); scope.addEventProcessor(() => { @@ -2198,71 +2220,46 @@ describe('Client', () => { client.captureEvent({ message: 'hello' }, {}, scope); - expect(TestClient.instance!.event!.exception!.values![0]).toStrictEqual({ - type: 'Error', - value: 'sorry 1', - mechanism: { type: 'internal', handled: false }, - }); - expect(captureExceptionSpy).toBeCalledWith(exception, { - data: { - __sentry__: true, - }, - originalException: exception, - mechanism: { type: 'internal', handled: false }, - }); - expect(loggerWarnSpy).toBeCalledWith( - `Event processing pipeline threw an error, original event will not be sent. Details have been sent as a new event.\nReason: ${exception}`, - ); + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error'); + expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception); }); - test('event processor sends an event and logs when it crashes asynchronously', async () => { + test('drops the event and records a client report when an event processor rejects', async () => { vi.useFakeTimers(); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const captureExceptionSpy = vi.spyOn(client, 'captureException'); - const loggerWarnSpy = vi.spyOn(debugLoggerModule.debug, 'warn'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); const scope = new Scope(); const exception = new Error('sorry 2'); - scope.addEventProcessor(() => { - return new Promise((_resolve, reject) => { - reject(exception); - }); - }); + scope.addEventProcessor(() => Promise.reject(exception)); client.captureEvent({ message: 'hello' }, {}, scope); await vi.runOnlyPendingTimersAsync(); - expect(TestClient.instance!.event!.exception!.values![0]).toStrictEqual({ - type: 'Error', - value: 'sorry 2', - mechanism: { type: 'internal', handled: false }, - }); - expect(captureExceptionSpy).toBeCalledWith(exception, { - data: { - __sentry__: true, - }, - originalException: exception, - mechanism: { type: 'internal', handled: false }, - }); - expect(loggerWarnSpy).toBeCalledWith( - `Event processing pipeline threw an error, original event will not be sent. Details have been sent as a new event.\nReason: ${exception}`, - ); + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error'); + expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception); }); - test('event processor sends an event and logs when it crashes synchronously in processor chain', () => { + test('a synchronously throwing event processor stops the processor chain', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const captureExceptionSpy = vi.spyOn(client, 'captureException'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); const scope = new Scope(); - const exception = new Error('sorry 3'); const processor1 = vi.fn(event => { return event; }); const processor2 = vi.fn(() => { - throw exception; + throw new Error('sorry 3'); }); const processor3 = vi.fn(event => { return event; @@ -2278,29 +2275,25 @@ describe('Client', () => { expect(processor2).toHaveBeenCalledTimes(1); expect(processor3).toHaveBeenCalledTimes(0); - expect(captureExceptionSpy).toBeCalledWith(exception, { - data: { - __sentry__: true, - }, - originalException: exception, - mechanism: { type: 'internal', handled: false }, - }); + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error'); }); - test('event processor sends an event and logs when it crashes asynchronously in processor chain', async () => { + test('a rejecting event processor stops the processor chain', async () => { vi.useFakeTimers(); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const captureExceptionSpy = vi.spyOn(client, 'captureException'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); const scope = new Scope(); - const exception = new Error('sorry 4'); const processor1 = vi.fn(async event => { return event; }); const processor2 = vi.fn(async () => { - throw exception; + throw new Error('sorry 4'); }); const processor3 = vi.fn(event => { return event; @@ -2317,38 +2310,143 @@ describe('Client', () => { expect(processor2).toHaveBeenCalledTimes(1); expect(processor3).toHaveBeenCalledTimes(0); - expect(captureExceptionSpy).toBeCalledWith(exception, { - data: { - __sentry__: true, - }, - originalException: exception, - mechanism: { type: 'internal', handled: false }, - }); + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error'); }); - test('client-level event processor that throws on all events does not cause infinite recursion', () => { + test('client-level event processor that throws on all events does not capture a new event', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); + const captureExceptionSpy = vi.spyOn(client, 'captureException'); - let processorCallCount = 0; - // Add processor at client level - this runs on ALL events including internal exceptions - client.addEventProcessor(() => { - processorCallCount++; + const processor = vi.fn(() => { throw new Error('Processor always throws'); }); + client.addEventProcessor(processor); client.captureMessage('test message'); - // Should be called once for the original message - // internal exception events skips event processors entirely. - expect(processorCallCount).toBe(1); + expect(processor).toHaveBeenCalledTimes(1); + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + test('drops the event and records a client report when `beforeSend` throws', () => { + const exception = new Error('beforeSend failed'); + const beforeSend = vi.fn(() => { + throw exception; + }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + const captureExceptionSpy = vi.spyOn(client, 'captureException'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + + client.captureEvent({ message: 'hello' }); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error'); + expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1); + expect(debugErrorSpy).toHaveBeenCalledWith( + 'The `beforeSend` callback threw an error, dropping the event:', + exception, + ); + }); + + test('drops the event and records a client report when `beforeSend` rejects', async () => { + vi.useFakeTimers(); + + const exception = new Error('beforeSend failed'); + const beforeSend = vi.fn(() => Promise.reject(exception)); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + const captureExceptionSpy = vi.spyOn(client, 'captureException'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + + client.captureEvent({ message: 'hello' }); + await vi.runOnlyPendingTimersAsync(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error'); + expect(debugErrorSpy).toHaveBeenCalledWith( + 'The `beforeSend` callback threw an error, dropping the event:', + exception, + ); + }); + + test('drops the transaction and its spans when `beforeSendTransaction` throws', () => { + const exception = new Error('beforeSendTransaction failed'); + const beforeSendTransaction = vi.fn(() => { + throw exception; + }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendTransaction }); + const client = new TestClient(options); + const captureExceptionSpy = vi.spyOn(client, 'captureException'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + + client.captureEvent({ + transaction: '/dogs/are/great', + type: 'transaction', + spans: [ + { + description: 'first span', + span_id: '9e15bf99fbe4bc80', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + description: 'second span', + span_id: 'aa554c1f506b0783', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + ], + }); + + expect(TestClient.instance!.event).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction'); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3); + expect(debugErrorSpy).toHaveBeenCalledWith( + 'The `beforeSendTransaction` callback threw an error, dropping the event:', + exception, + ); + }); + + test('captures an internal event when the event processing pipeline itself throws', async () => { + vi.useFakeTimers(); + + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const captureExceptionSpy = vi.spyOn(client, 'captureException'); + const loggerWarnSpy = vi.spyOn(debugLoggerModule.debug, 'warn'); + const exception = new Error('sdk bug'); + vi.spyOn(client as any, '_prepareEvent').mockImplementation(() => Promise.reject(exception)); - // Verify the processor error was captured and sent - expect(TestClient.instance!.event!.exception!.values![0]).toStrictEqual({ - type: 'Error', - value: 'Processor always throws', + client.captureEvent({ message: 'hello' }); + await vi.runOnlyPendingTimersAsync(); + + expect(captureExceptionSpy).toBeCalledWith(exception, { + data: { + __sentry__: true, + }, + originalException: exception, mechanism: { type: 'internal', handled: false }, }); + expect(loggerWarnSpy).toBeCalledWith( + `Event processing pipeline threw an error, original event will not be sent. Details have been sent as a new event.\nReason: ${exception}`, + ); }); test('records events dropped due to `sampleRate` option', () => { diff --git a/packages/core/test/lib/eventProcessors.test.ts b/packages/core/test/lib/eventProcessors.test.ts new file mode 100644 index 000000000000..5570788cdcaf --- /dev/null +++ b/packages/core/test/lib/eventProcessors.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { notifyEventProcessors } from '../../src/eventProcessors'; +import type { EventProcessor } from '../../src/types/eventprocessor'; +import * as debugLoggerModule from '../../src/utils/debug-logger'; + +describe('notifyEventProcessors', () => { + it('passes the event through all processors', async () => { + const processors: EventProcessor[] = [ + event => ({ ...event, tags: { first: 'yes' } }), + async event => ({ ...event, tags: { ...event.tags, second: 'yes' } }), + ]; + + const result = await notifyEventProcessors(processors, { message: 'hello' }, {}); + + expect(result).toEqual({ message: 'hello', tags: { first: 'yes', second: 'yes' } }); + }); + + it('stops when a processor returns null', async () => { + const later = vi.fn(event => event); + + const result = await notifyEventProcessors([() => null, later], { message: 'hello' }, {}); + + expect(result).toBeNull(); + expect(later).not.toHaveBeenCalled(); + }); + + it('drops the event when a processor throws synchronously', async () => { + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + const error = new Error('boom'); + const throwing: EventProcessor = () => { + throw error; + }; + throwing.id = 'Throwing'; + const later = vi.fn(event => event); + + const result = await notifyEventProcessors([throwing, later], { message: 'hello' }, {}); + + expect(result).toBeNull(); + expect(later).not.toHaveBeenCalled(); + expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "Throwing" threw an error, dropping event:', error); + }); + + it('drops the event when a processor rejects', async () => { + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + const error = new Error('boom'); + const later = vi.fn(event => event); + + const result = await notifyEventProcessors([() => Promise.reject(error), later], { message: 'hello' }, {}); + + expect(result).toBeNull(); + expect(later).not.toHaveBeenCalled(); + expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', error); + }); +}); diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 61de9ef2a7a8..d34df4ba16e6 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -370,6 +370,35 @@ describe('_INTERNAL_captureLog', () => { ); }); + it('drops logs when beforeSendLog throws', () => { + const exception = new Error('beforeSendLog failed'); + const beforeSendLog = vi.fn(() => { + throw exception; + }); + const recordDroppedEventSpy = vi.spyOn(TestClient.prototype, 'recordDroppedEvent'); + const debugErrorSpy = vi.spyOn(loggerModule.debug, 'error'); + + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + beforeSendLog, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + expect(() => _INTERNAL_captureLog({ level: 'info', message: 'test message' }, scope)).not.toThrow(); + + expect(beforeSendLog).toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'log_item', 1); + expect(debugErrorSpy).toHaveBeenCalledWith( + 'The `beforeSendLog` callback threw an error, dropping the log:', + exception, + ); + expect(_INTERNAL_getLogBuffer(client)).toBeUndefined(); + + recordDroppedEventSpy.mockRestore(); + }); + it('drops logs when beforeSendLog returns null', () => { const beforeSendLog = vi.fn().mockReturnValue(null); const recordDroppedEventSpy = vi.spyOn(TestClient.prototype, 'recordDroppedEvent'); diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index 2f9b46606857..95e2a4ccea97 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -337,6 +337,7 @@ describe('_INTERNAL_captureMetric', () => { it('drops metrics when beforeSendMetric returns null', () => { const beforeSendMetric = vi.fn().mockReturnValue(null); + const recordDroppedEventSpy = vi.spyOn(TestClient.prototype, 'recordDroppedEvent'); const loggerWarnSpy = vi.spyOn(loggerModule.debug, 'log').mockImplementation(() => undefined); const options = getDefaultTestClientOptions({ @@ -357,12 +358,43 @@ describe('_INTERNAL_captureMetric', () => { ); expect(beforeSendMetric).toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'metric', 1); expect(loggerWarnSpy).toHaveBeenCalledWith('`beforeSendMetric` returned `null`, will not send metric.'); expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); + recordDroppedEventSpy.mockRestore(); loggerWarnSpy.mockRestore(); }); + it('drops metrics when beforeSendMetric throws', () => { + const exception = new Error('beforeSendMetric failed'); + const beforeSendMetric = vi.fn(() => { + throw exception; + }); + const recordDroppedEventSpy = vi.spyOn(TestClient.prototype, 'recordDroppedEvent'); + const debugErrorSpy = vi.spyOn(loggerModule.debug, 'error'); + + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + beforeSendMetric, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + expect(() => _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope })).not.toThrow(); + + expect(beforeSendMetric).toHaveBeenCalled(); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'metric', 1); + expect(debugErrorSpy).toHaveBeenCalledWith( + 'The `beforeSendMetric` callback threw an error, dropping the metric:', + exception, + ); + expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); + + recordDroppedEventSpy.mockRestore(); + }); + it('emits afterCaptureMetric event', () => { const afterCaptureMetricSpy = vi.spyOn(TestClient.prototype, 'emit'); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); diff --git a/packages/core/test/lib/tracing/sampling.test.ts b/packages/core/test/lib/tracing/sampling.test.ts new file mode 100644 index 000000000000..5caa3ea35470 --- /dev/null +++ b/packages/core/test/lib/tracing/sampling.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { sampleSpan } from '../../../src/tracing/sampling'; +import * as debugLoggerModule from '../../../src/utils/debug-logger'; + +describe('sampleSpan', () => { + describe('when `tracesSampler` throws', () => { + const exception = new Error('tracesSampler failed'); + const tracesSampler = vi.fn(() => { + throw exception; + }); + const expectedMessage = + 'The `tracesSampler` callback threw an error, falling back to the parent sampling decision or `tracesSampleRate`:'; + + it('inherits the parent sampling decision', () => { + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + + expect(sampleSpan({ tracesSampler }, { name: 'test', attributes: {}, parentSampled: true }, 0.5)).toEqual([ + true, + 1, + undefined, + ]); + expect(sampleSpan({ tracesSampler }, { name: 'test', attributes: {}, parentSampled: false }, 0.5)).toEqual([ + false, + 0, + undefined, + ]); + expect(debugErrorSpy).toHaveBeenCalledWith(expectedMessage, exception); + }); + + it('falls back to `tracesSampleRate` without a parent decision', () => { + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + + expect(sampleSpan({ tracesSampler, tracesSampleRate: 0.6 }, { name: 'test', attributes: {} }, 0.5)).toEqual([ + true, + 0.6, + true, + ]); + expect(debugErrorSpy).toHaveBeenCalledWith(expectedMessage, exception); + }); + + it('does not sample when there is nothing to fall back to', () => { + const debugErrorSpy = vi.spyOn(debugLoggerModule.debug, 'error'); + const debugWarnSpy = vi.spyOn(debugLoggerModule.debug, 'warn'); + + expect(sampleSpan({ tracesSampler }, { name: 'test', attributes: {} }, 0.5)).toEqual([false]); + expect(debugErrorSpy).toHaveBeenCalledWith(expectedMessage, exception); + expect(debugWarnSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/lib/utils/safeCallback.test.ts b/packages/core/test/lib/utils/safeCallback.test.ts new file mode 100644 index 000000000000..dc5c4552d6ae --- /dev/null +++ b/packages/core/test/lib/utils/safeCallback.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { debug } from '../../../src/utils/debug-logger'; +import { safeCallback } from '../../../src/utils/safeCallback'; + +describe('safeCallback', () => { + const debugErrorSpy = vi.spyOn(debug, 'error').mockImplementation(() => undefined); + + afterEach(() => { + debugErrorSpy.mockClear(); + }); + + it('returns the result of a sync callback', () => { + const fallback = vi.fn(() => 'fallback'); + + expect(safeCallback('callback threw:', () => 'value', fallback)).toBe('value'); + expect(fallback).not.toHaveBeenCalled(); + expect(debugErrorSpy).not.toHaveBeenCalled(); + }); + + it('returns the fallback and logs when a sync callback throws', () => { + const error = new Error('boom'); + const fallback = vi.fn(() => 'fallback'); + + expect( + safeCallback( + 'callback threw:', + () => { + throw error; + }, + fallback, + ), + ).toBe('fallback'); + expect(fallback).toHaveBeenCalledWith(error); + expect(debugErrorSpy).toHaveBeenCalledWith('callback threw:', error); + }); + + it('resolves to the result of an async callback', async () => { + const fallback = vi.fn(async () => 'fallback'); + + const result = safeCallback('callback threw:', async () => 'value', fallback); + + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toBe('value'); + expect(fallback).not.toHaveBeenCalled(); + expect(debugErrorSpy).not.toHaveBeenCalled(); + }); + + it('resolves to the fallback and logs when an async callback rejects', async () => { + const error = new Error('boom'); + const fallback = vi.fn(async () => 'fallback'); + + const result = safeCallback('callback threw:', () => Promise.reject(error), fallback); + + await expect(result).resolves.toBe('fallback'); + expect(fallback).toHaveBeenCalledWith(error); + expect(debugErrorSpy).toHaveBeenCalledWith('callback threw:', error); + }); + + it('does not treat non-thenable objects as promises', () => { + const value = { then: 'not a function' }; + + expect( + safeCallback( + 'callback threw:', + () => value, + () => ({ then: 'fallback' }), + ), + ).toBe(value); + }); +}); diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 527bb1f062fe..156fb9fd8559 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -29,6 +29,11 @@ export type DenoServeIntegrationOptions = { * produce transaction events, so the filter does not run. * * @default `[[401, 404], [301, 303], [305, 399]]` + * + * @deprecated This option only has an effect if `traceLifecycle` is set to `'static'`. With span streaming + * (`traceLifecycle: 'stream'`, the default), the SDK ignores it: child spans are sent as they end, before the + * response status code is known, so a request's spans cannot be dropped retroactively. `ignoreStatusCodes` will be + * removed in v12 of the SDK. */ ignoreStatusCodes?: (number | [number, number])[]; }; @@ -88,6 +93,7 @@ const instrumentedDenoServe = (serve: typeof Deno.serve): typeof Deno.serve => }); const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => { + // oxlint-disable-next-line typescript/no-deprecated const ignoreStatusCodes = options.ignoreStatusCodes ?? DEFAULT_IGNORE_STATUS_CODES; return { diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index f696186b9261..f4cfeee81d17 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -107,6 +107,11 @@ export interface DenoHttpIntegrationOptions { * limitation. * * @default `[[401, 404], [301, 303], [305, 399]]` + * + * @deprecated This option only has an effect if `traceLifecycle` is set to `'static'`. With span streaming + * (`traceLifecycle: 'stream'`, the default), the SDK ignores it: child spans are sent as they end, before the + * response status code is known, so a request's spans cannot be dropped retroactively. `ignoreStatusCodes` will be + * removed in v12 of the SDK. */ ignoreStatusCodes?: (number | [number, number])[]; @@ -146,6 +151,7 @@ export interface DenoHttpIntegrationOptions { const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => { const breadcrumbs = options.breadcrumbs ?? true; const tracePropagation = options.tracePropagation ?? true; + // oxlint-disable-next-line typescript/no-deprecated const ignoreStatusCodes = options.ignoreStatusCodes ?? DEFAULT_IGNORE_STATUS_CODES; return { diff --git a/packages/nextjs/test/serverEntryBundlerGraph.test.ts b/packages/nextjs/test/serverEntryBundlerGraph.test.ts new file mode 100644 index 000000000000..17b5447a66fc --- /dev/null +++ b/packages/nextjs/test/serverEntryBundlerGraph.test.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Importing the SDK server entry must not load the orchestrion bundler plugins. They are + * build-time-only, and their module-scope side effects break runtimes the build never sees, + * like jsdom/happy-dom test runs (issue #23789) and Cloudflare Workers cold starts (issue #22794). + * Runs in a child process for a clean module cache and real Node resolution. + */ +describe('built CJS server entry', () => { + const serverEntry = resolve(__dirname, '../build/cjs/index.server.js'); + + it('loads under a DOM test environment without pulling in the orchestrion bundler graph', () => { + const script = ` + globalThis.document = { baseURI: 'http://localhost:3000/' }; + require(${JSON.stringify(serverEntry)}); + const toPosix = modulePath => modulePath.split(require('path').sep).join('/'); + const bundlerModules = Object.keys(require.cache).map(toPosix).filter( + modulePath => modulePath.includes('code-transformer-bundler-plugins') || modulePath.includes('orchestrion/bundler'), + ); + if (bundlerModules.length > 0) { + console.error('Bundler-plugin modules loaded at import time:\\n' + bundlerModules.join('\\n')); + process.exit(1); + } + `; + + // On failure, stderr carries either the leaked module list or the import crash itself. + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + }); +}); diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index cb9de82677b0..a00d048591a0 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -84,7 +84,14 @@ export interface HttpServerSpansIntegrationOptions { * By default, spans with some 3xx and 4xx status codes are ignored (see @default). * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. * + * Important: This option is ignored by default! It only has an effect if `traceLifecycle` is set to `'static'`. + * * @default `[[401, 404], [301, 303], [305, 399]]` + * + * @deprecated This option only has an effect if `traceLifecycle` is set to `'static'`. With span streaming + * (`traceLifecycle: 'stream'`, the default), the SDK ignores it: child spans are sent as they end, before the + * response status code is known, so a request's spans cannot be dropped retroactively. `ignoreStatusCodes` will be + * removed in v12 of the SDK, without replacement. */ ignoreStatusCodes?: (number | [number, number])[]; @@ -98,6 +105,7 @@ export interface HttpServerSpansIntegrationOptions { const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => { const ignoreStaticAssets = options.ignoreStaticAssets ?? true; const ignoreIncomingRequests = options.ignoreIncomingRequests; + // oxlint-disable-next-line typescript/no-deprecated const ignoreStatusCodes = options.ignoreStatusCodes ?? DEFAULT_IGNORE_STATUS_CODES; const { onSpanCreated } = options; diff --git a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts index 31ef1da962e6..b2989e6c6bdf 100644 --- a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts +++ b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts @@ -30,6 +30,7 @@ import { isTracingSuppressed, LRUMap, parseUrl, + safeCallback, SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -113,16 +114,6 @@ export function instrumentUndici(config: NodeFetchOptions = {}): void { subscribeToChannel('undici:request:error', message => onError(message as RequestErrorMessage)); } -/** Replaces OTel's `safeExecuteInTheMiddle`: run `fn`, route any error to `onError`, and swallow it. */ -function safeExecute(fn: () => T, onError: (error: unknown) => void): T | undefined { - try { - return fn(); - } catch (error) { - onError(error); - return undefined; - } -} - function subscribeToChannel( diagnosticChannel: string, onMessage: (message: unknown, name: string | symbol) => void, @@ -180,9 +171,10 @@ function parseRequestHeaders(request: UndiciRequest): Map !!config.ignoreOutgoingRequests?.(url), - e => e && DEBUG_BUILD && debug.error('caught ignoreOutgoingRequests error: ', e), + () => false, ); // Breadcrumbs & span-less trace propagation are additionally skipped when tracing is suppressed. @@ -288,9 +280,10 @@ function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage) }); // Execute the request hook if defined - safeExecute( + safeCallback( + DEBUG_BUILD ? 'The `requestHook` callback threw an error:' : '', () => config.requestHook?.(span, request), - e => e && DEBUG_BUILD && debug.error('caught requestHook error: ', e), + () => undefined, ); // Context propagation goes last so no hook can tamper the propagation headers. @@ -356,9 +349,10 @@ function onResponseHeaders(config: NodeFetchOptions, { request, response }: Resp }; // Execute the response hook if defined - safeExecute( + safeCallback( + DEBUG_BUILD ? 'The `responseHook` callback threw an error:' : '', () => config.responseHook?.(span, { request, response }), - e => e && DEBUG_BUILD && debug.error('caught responseHook error: ', e), + () => undefined, ); if (config.headersToSpanAttributes?.responseHeaders) { diff --git a/packages/nuxt/rollup.module.config.mjs b/packages/nuxt/rollup.module.config.mjs index f53676433cef..991ea9c58fae 100644 --- a/packages/nuxt/rollup.module.config.mjs +++ b/packages/nuxt/rollup.module.config.mjs @@ -1,5 +1,5 @@ import { readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { isAbsolute, join } from 'node:path'; import esbuild from 'rollup-plugin-esbuild'; // The Nuxt module ships two kinds of output that live side by side in `build/module`: @@ -11,7 +11,7 @@ import esbuild from 'rollup-plugin-esbuild'; // Anything that isn't a relative path is provided by the consuming app or Node at runtime // (this covers `@sentry/*`, `nuxt/app`, `#imports`, node builtins), so it stays external. -const isExternal = id => !id.startsWith('.') && !id.startsWith('/') && !id.startsWith('\0'); +const isExternal = id => !id.startsWith('.') && !isAbsolute(id) && !id.startsWith('\0'); const transpile = esbuild({ target: 'es2020', diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index e88e2ff514f7..754bdcd8592f 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs'; +import { basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { addTemplate, createResolver } from '@nuxt/kit'; import type { Nuxt } from '@nuxt/schema'; @@ -20,6 +21,7 @@ import { SENTRY_WRAPPED_FUNCTIONS, SERVER_CONFIG_FILENAME, toImportSpecifier, + toResolvablePath, } from './utils'; /** Path of the generated dev-mode config file, relative to the Nuxt build directory. */ @@ -31,7 +33,7 @@ export const DEV_SERVER_CONFIG_PATH = `dev/${SERVER_CONFIG_FILENAME}.mjs`; * In dev-mode, Nitro v3 has no server bundle to emit into, so Node loads the server config file as it is written. */ export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): void { - const configPath = createResolver(nuxt.options.rootDir).resolve(`/${serverConfigFile}`); + const configPath = createResolver(nuxt.options.rootDir).resolve(serverConfigFile); const importSpecifier = toImportSpecifier( nuxt.options.rootDir, path.join(nuxt.options.buildDir, DEV_SERVER_CONFIG_PATH), @@ -59,6 +61,15 @@ export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): vo ].join('\n'), }); } +const CONFIG_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']; + +function isServerConfigFile(sourcePath: string, resolvedPath: string): boolean { + if (sourcePath === resolvedPath) { + return true; + } + const name = basename(sourcePath); + return name === SERVER_CONFIG_FILENAME || CONFIG_EXTENSIONS.some(ext => name === `${SERVER_CONFIG_FILENAME}${ext}`); +} /** * Adds the `sentry.server.config.ts` file as `sentry.server.config.mjs` to the `.output` directory to be able to reference this file in the node --import option. @@ -157,7 +168,7 @@ export function addDynamicImportEntryFileWrapper( nitro.options.rollupConfig.plugins.push( wrapEntryWithDynamicImport({ - resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`), + resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(serverConfigFile), experimental_entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions, }), ); @@ -173,7 +184,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu name: 'rollup-plugin-inject-sentry-server-config', buildStart() { - const configPath = createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`); + const configPath = createResolver(nitro.options.rootDir).resolve(serverConfigFile); if (!existsSync(configPath)) { if (isDebug) { @@ -193,7 +204,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu resolveId(source) { if (source.startsWith(filePrefix)) { const originalFilePath = source.replace(filePrefix, ''); - const configPath = createResolver(nitro.options.rootDir).resolve(`/${originalFilePath}`); + const configPath = createResolver(nitro.options.rootDir).resolve(originalFilePath); return { id: configPath }; } @@ -206,8 +217,10 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu * A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first * by using a regular `import` and load the server after that. * This also works with serverless `handler` functions, as it re-exports the `handler`. + * + * Only exported for testing. */ -function wrapEntryWithDynamicImport({ +export function wrapEntryWithDynamicImport({ resolvedSentryConfigPath, experimental_entrypointWrappedFunctions, debug, @@ -225,12 +238,24 @@ function wrapEntryWithDynamicImport({ return { name: 'sentry-wrap-entry-with-dynamic-import', async resolveId(source, importer, options) { - if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) { - return { id: source, moduleSideEffects: true }; + // `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows paths, + // but Rollup's resolver only understands filesystem paths. + const resolvable = toResolvablePath(source); + if (!resolvable) { + return null; } + const { path: normalizedSource, wasFileUrl } = resolvable; - if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { - const resolution = await this.resolve(source, importer, options); + if (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) { + return { id: normalizedSource, moduleSideEffects: true }; + } + + if ( + options.isEntry && + normalizedSource.includes('.mjs') && + !normalizedSource.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`) + ) { + const resolution = await this.resolve(normalizedSource, importer, options); // If it cannot be resolved or is external, just return it so that Rollup can display an error if (!resolution || resolution?.external) return resolution; @@ -254,24 +279,36 @@ function wrapEntryWithDynamicImport({ ) .concat(QUERY_END_INDICATOR)}`; } + + // Pass isEntry:false to avoid re-entering the isEntry branch and double-wrapping + // (normalizedSource strips the SENTRY_WRAPPED_ENTRY query suffix). + if (wasFileUrl) { + const resolved = await this.resolve(normalizedSource, importer, { ...options, isEntry: false }); + if (resolved) return resolved; + return { id: normalizedSource }; + } + return null; }, load(id: string) { if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length); + const entryIdUrl = pathToFileURL(entryId).href; + const configUrl = pathToFileURL(resolvedSentryConfigPath).href; + // Use entryIdUrl so Node's runtime ESM loader receives file:// on Windows; Rollup normalizes it in resolveId. // Mostly useful for serverless `handler` functions const reExportedFunctions = id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS) - ? constructFunctionReExport(id, entryId) + ? constructFunctionReExport(id, entryIdUrl) : ''; return ( // Regular `import` of the Sentry config - `import ${JSON.stringify(resolvedSentryConfigPath)};\n` + + `import ${JSON.stringify(configUrl)};\n` + // Dynamic `import()` for the previous, actual entry point. // `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling) - `import(${JSON.stringify(entryId)});\n` + + `import(${JSON.stringify(entryIdUrl)});\n` + `${reExportedFunctions}\n` ); } diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 976320676236..d1f0e95843b4 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -2,6 +2,7 @@ import type { Nuxt } from '@nuxt/schema'; import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; +import { fileURLToPath } from 'node:url'; import type { SentryNuxtModuleOptions } from '../common/types'; import { resolvePath } from '@nuxt/kit'; @@ -199,6 +200,31 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string ); } +/** + * `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows + * paths (`ERR_UNSUPPORTED_ESM_URL_SCHEME`), but Rollup's resolver only understands + * filesystem paths. Returns `undefined` for a malformed `file://` URL. + * + * Only exported for testing. + */ +export function toResolvablePath(source: string): { path: string; wasFileUrl: boolean } | undefined { + if (!source.startsWith('file://')) { + return { path: source, wasFileUrl: false }; + } + if (source === 'file://' || source === 'file:///') { + return undefined; + } + try { + const filePath = fileURLToPath(source); + if (!filePath || filePath === '/' || filePath === '\\') { + return undefined; + } + return { path: filePath, wasFileUrl: true }; + } catch { + return undefined; + } +} + /** * Sets up alias to work around OpenTelemetry's incomplete ESM imports. * https://github.com/getsentry/sentry-javascript/issues/15204 diff --git a/packages/nuxt/test/vite/addServerConfig.test.ts b/packages/nuxt/test/vite/addServerConfig.test.ts index c82ffdebbde1..54fdc0e1a33d 100644 --- a/packages/nuxt/test/vite/addServerConfig.test.ts +++ b/packages/nuxt/test/vite/addServerConfig.test.ts @@ -1,7 +1,116 @@ import type { Nuxt } from '@nuxt/schema'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import * as path from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { addDevServerConfigFile, DEV_SERVER_CONFIG_PATH } from '../../src/vite/addServerConfig'; +import { + addDevServerConfigFile, + DEV_SERVER_CONFIG_PATH, + wrapEntryWithDynamicImport, +} from '../../src/vite/addServerConfig'; +import { + QUERY_END_INDICATOR, + SENTRY_REEXPORTED_FUNCTIONS, + SENTRY_WRAPPED_ENTRY, + toResolvablePath, +} from '../../src/vite/utils'; + +const configPath = '/project/sentry.server.config.ts'; +const entryPath = '/project/.nuxt/entry.mjs'; + +describe('toResolvablePath', () => { + it('passes through non-file specifiers', () => { + expect(toResolvablePath('./module')).toEqual({ path: './module', wasFileUrl: false }); + expect(toResolvablePath(configPath)).toEqual({ path: configPath, wasFileUrl: false }); + }); + + it('converts file:// URLs to filesystem paths', () => { + const url = pathToFileURL(entryPath).href; + expect(toResolvablePath(url)).toEqual({ path: fileURLToPath(url), wasFileUrl: true }); + }); + + it('returns undefined for malformed file:// URLs', () => { + expect(toResolvablePath('file://')).toBeUndefined(); + expect(toResolvablePath('file:///')).toBeUndefined(); + expect(toResolvablePath('file://%E0%A4%A')).toBeUndefined(); + }); +}); + +describe('wrapEntryWithDynamicImport', () => { + const plugin = wrapEntryWithDynamicImport({ + resolvedSentryConfigPath: configPath, + experimental_entrypointWrappedFunctions: ['handler'], + }) as unknown as { + resolveId: (source: string, importer: string | undefined, options: { isEntry?: boolean }) => Promise; + load: (id: string) => string | null; + }; + const { resolveId, load } = plugin; + + it('emits file:// URLs from load() so Node resolves them on Windows', () => { + const code = load.call({}, `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${QUERY_END_INDICATOR}`); + + expect(code).toContain(`import ${JSON.stringify(pathToFileURL(configPath).href)}`); + expect(code).toContain(`import(${JSON.stringify(pathToFileURL(entryPath).href)})`); + expect(code).not.toContain(`import ${JSON.stringify(configPath)}`); + }); + + it('uses file:// URLs for re-exported functions', () => { + const id = `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${SENTRY_REEXPORTED_FUNCTIONS}handler${QUERY_END_INDICATOR}`; + const code = load.call({}, id); + + expect(code).toContain(`export { handler } from ${JSON.stringify(pathToFileURL(entryPath).href)}`); + }); + + it('resolves a file:// config URL to a filesystem path with moduleSideEffects', async () => { + const source = pathToFileURL(configPath).href; + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, source, undefined, { isEntry: false }); + + expect(result).toEqual({ id: fileURLToPath(source), moduleSideEffects: true }); + }); + + it('resolves a plain config path without converting it', async () => { + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, configPath, undefined, { isEntry: false }); + + expect(result).toEqual({ id: configPath, moduleSideEffects: true }); + }); + + it('does not mark backup or test config files as the Sentry server config', async () => { + const backupPath = '/project/sentry.server.config.backup.ts'; + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, backupPath, undefined, { isEntry: false }); + + expect(result).toBeNull(); + }); + + it('resolves file:// entry specifiers without re-entering the entry branch', async () => { + const source = pathToFileURL(entryPath).href; + const fakeResolve = vi.fn(async () => ({ id: 'resolved-id', external: false })); + const result = await resolveId.call({ resolve: fakeResolve, load: vi.fn() }, source, undefined, { isEntry: false }); + + expect(fakeResolve).toHaveBeenCalledWith( + fileURLToPath(source), + undefined, + expect.objectContaining({ isEntry: false }), + ); + expect(result).toEqual({ id: 'resolved-id', external: false }); + }); + + it('returns null for malformed file:// URLs', async () => { + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, 'file://', undefined, { isEntry: false }); + + expect(result).toBeNull(); + }); + + it('wraps the entry with the dynamic-import query suffix', async () => { + const fakeResolve = vi.fn(async () => ({ id: entryPath, external: false })); + const fakeLoad = vi.fn(async () => ({ exportedBindings: { '.': ['handler'] }, moduleSideEffects: false })); + const result = await resolveId.call({ resolve: fakeResolve, load: fakeLoad }, entryPath, undefined, { + isEntry: true, + }); + + expect(result).toContain(SENTRY_WRAPPED_ENTRY); + expect(result).toContain('?sentry-query-wrapped-functions=handler'); + expect(result?.startsWith('\0raw')).toBe(true); + }); +}); const addTemplateMock = vi.hoisted(() => vi.fn()); @@ -40,7 +149,7 @@ describe('addDevServerConfigFile', () => { }); it('imports the user config as a file URL so Node can load it directly', () => { - expect(generate(APP_CONFIG)).toContain(`await import("file://${APP_CONFIG}")`); + expect(generate(APP_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(APP_CONFIG).href)})`); }); it('sets the dev flag before importing the config', () => { @@ -64,7 +173,7 @@ describe('addDevServerConfigFile', () => { describe('when the config comes from a layer outside the project root', () => { it('imports the config from the layer it belongs to', () => { - expect(generate(LAYER_CONFIG)).toContain(`await import("file://${LAYER_CONFIG}")`); + expect(generate(LAYER_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(LAYER_CONFIG).href)})`); }); it('keeps the preload path relative to the project root', () => { diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index fc8147197f70..3d135a489b6f 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -38,7 +38,7 @@ describe('findDefaultSdkInitFile', () => { }); const result = await findDefaultSdkInitFile('server'); - expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`); + expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`)); }, ); @@ -50,7 +50,7 @@ describe('findDefaultSdkInitFile', () => { }); const result = await findDefaultSdkInitFile('client'); - expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`); + expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`)); }, ); @@ -69,7 +69,7 @@ describe('findDefaultSdkInitFile', () => { configDir: '~/config', }); - expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`); + expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`)); expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' }); }, ); @@ -89,7 +89,7 @@ describe('findDefaultSdkInitFile', () => { configDir: '~/config', }); - expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`); + expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`)); expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' }); }, ); @@ -136,7 +136,7 @@ describe('findDefaultSdkInitFile', () => { } as unknown as Nuxt; const result = await findDefaultSdkInitFile('client', nuxtMock); - expect(result).toMatch('packages/nuxt/sentry.client.config.ts'); + expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts')); }); it('should return the latest layer config file path if server config exists', async () => { @@ -158,12 +158,15 @@ describe('findDefaultSdkInitFile', () => { } as unknown as Nuxt; const result = await findDefaultSdkInitFile('server', nuxtMock); - expect(result).toMatch('packages/nuxt/sentry.server.config.ts'); + expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts')); }); it('should return the latest layer config file path if client config exists in former layer', async () => { vi.spyOn(fs, 'existsSync').mockImplementation(filePath => { - return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts'); + return ( + !(filePath instanceof URL) && + filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts')) + ); }); const nuxtMock = { @@ -180,7 +183,7 @@ describe('findDefaultSdkInitFile', () => { } as unknown as Nuxt; const result = await findDefaultSdkInitFile('client', nuxtMock); - expect(result).toMatch('packages/nuxt/sentry.client.config.ts'); + expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts')); }); }); diff --git a/packages/remix/package.json b/packages/remix/package.json index 27c0162445d7..9eda02e22519 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -42,6 +42,11 @@ "types": "./build/types/cloudflare/index.d.ts", "default": "./build/esm/cloudflare/index.js" }, + "./vite": { + "types": "./build/types/vite/index.d.ts", + "import": "./build/esm/vite/index.js", + "require": "./build/cjs/vite/index.js" + }, "./import": { "import": { "default": "./build/import-hook.mjs" diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index 380ec3c1bd7e..9bf3f04f3ee3 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -13,6 +13,7 @@ export default [ 'src/client/index.ts', 'src/server/index.ts', 'src/cloudflare/index.ts', + 'src/vite/index.ts', ], packageSpecificConfig: { external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime'], diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index e4eb8ad6236e..e40dd4978bb3 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -1,7 +1,6 @@ export * from './server'; export { captureRemixErrorBoundaryError, withSentry, ErrorBoundary, browserTracingIntegration } from './client'; -export { sentryRemixVitePlugin } from './config/vite'; export { createRemixRouteManifest } from './config/createRemixRouteManifest'; export type { CreateRemixRouteManifestOptions } from './config/createRemixRouteManifest'; diff --git a/packages/remix/src/vite/index.ts b/packages/remix/src/vite/index.ts new file mode 100644 index 000000000000..3689266f2f44 --- /dev/null +++ b/packages/remix/src/vite/index.ts @@ -0,0 +1,34 @@ +import type { Plugin } from 'vite'; +import { makeOrchestrionPlugin } from './orchestrionPlugin'; +import { makeRouteManifestPlugin } from './routeManifestPlugin'; +import type { SentryRemixVitePluginOptions } from './types'; + +export type { SentryRemixVitePluginOptions }; + +/** + * Sentry Vite plugins for Remix. + * + * Add these to your Vite configuration to + * - inject the Remix route manifest, so client-side transactions are parameterized, and + * - build-time instrument supported server-side dependencies (such as database clients). + * + * @example + * ```typescript + * // vite.config.ts + * import { vitePlugin as remix } from '@remix-run/dev'; + * import { sentryRemixVitePlugin } from '@sentry/remix/vite'; + * import { defineConfig } from 'vite'; + * + * export default defineConfig({ + * plugins: [ + * remix(), + * sentryRemixVitePlugin({ + * appDirPath: './app', + * }), + * ], + * }); + * ``` + */ +export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}): Plugin[] { + return [makeRouteManifestPlugin(options), makeOrchestrionPlugin(options)]; +} diff --git a/packages/remix/src/vite/orchestrionPlugin.ts b/packages/remix/src/vite/orchestrionPlugin.ts new file mode 100644 index 000000000000..26df048242d9 --- /dev/null +++ b/packages/remix/src/vite/orchestrionPlugin.ts @@ -0,0 +1,81 @@ +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; +import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { SentryRemixVitePluginOptions } from './types'; + +type AnyHook = (this: unknown, ...args: never[]) => unknown; +type ObjectHook = T | { order?: 'pre' | 'post' | null; handler: T }; +type ConfigHook = (this: unknown, config: UserConfig, env: ConfigEnv) => unknown; + +const WORKER_RESOLVE_CONDITIONS = ['workerd', 'worker']; + +/** + * Cloudflare Pages and Hydrogen/Oxygen builds instrument through `instrumentBuild()` from + * `@sentry/remix/cloudflare`, which wraps the build instead of subscribing to diagnostics + * channels. Transforming there would add a `node:diagnostics_channel` import and subscriber code + * that nothing reads. + * + * Oxygen sets `ssr.target`, but Remix's own Vite plugin never does — a Cloudflare app is marked by + * workerd resolve conditions instead (`cloudflareDevProxyVitePlugin` sets `externalConditions`, + * the Cloudflare template sets `conditions`), so all three signals have to be checked. + */ +function isWorkerTarget(config: UserConfig | ResolvedConfig | undefined): boolean { + const ssr = config?.ssr; + if (ssr?.target === 'webworker') { + return true; + } + + return [ssr?.resolve?.conditions, ssr?.resolve?.externalConditions].some(conditions => + conditions?.some(condition => WORKER_RESOLVE_CONDITIONS.includes(condition)), + ); +} + +function hookHandler(hook: ObjectHook | undefined): T | undefined { + return typeof hook === 'function' ? hook : hook?.handler; +} + +/** No-ops a hook while `isDisabled()` holds, keeping its declared hook shape. */ +function gateHook( + hook: ObjectHook | undefined, + isDisabled: () => boolean, +): ObjectHook | undefined { + if (!hook) { + return hook; + } + + const handler = hookHandler(hook) as AnyHook; + const gated = function (this: unknown, ...args: never[]): unknown { + return isDisabled() ? null : handler.apply(this, args); + } as T; + + return typeof hook === 'function' ? gated : { ...hook, handler: gated }; +} + +/** The orchestrion bundler plugin, wired to stay out of workerd builds. */ +export function makeOrchestrionPlugin(options: Pick): Plugin { + const orchestrion = sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation }); + const { renderChunk } = orchestrion as Plugin & { renderChunk?: ObjectHook }; + const config = hookHandler(orchestrion.config as ObjectHook | undefined); + const configResolved = hookHandler(orchestrion.configResolved); + + let isWorkerBuild = false; + + return { + ...orchestrion, + // Upstream ships the plugin with `enforce: 'pre'`, which would run this hook before the + // framework plugins that set `ssr.target`. `order: 'post'` moves it after them. + config: { + order: 'post', + handler(userConfig: UserConfig, env: ConfigEnv) { + return isWorkerTarget(userConfig) ? null : (config?.(userConfig, env) ?? null); + }, + }, + // The authoritative check: the resolved config reflects every plugin regardless of ordering, + // and this always runs before the first `transform`. + configResolved(resolvedConfig: ResolvedConfig) { + isWorkerBuild = isWorkerTarget(resolvedConfig); + return isWorkerBuild ? undefined : configResolved?.(resolvedConfig); + }, + transform: gateHook(orchestrion.transform as ObjectHook | undefined, () => isWorkerBuild), + renderChunk: gateHook(renderChunk, () => isWorkerBuild), + } as Plugin; +} diff --git a/packages/remix/src/config/vite.ts b/packages/remix/src/vite/routeManifestPlugin.ts similarity index 83% rename from packages/remix/src/config/vite.ts rename to packages/remix/src/vite/routeManifestPlugin.ts index 83b033104530..8328af5de060 100644 --- a/packages/remix/src/config/vite.ts +++ b/packages/remix/src/vite/routeManifestPlugin.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import type { Plugin } from 'vite'; -import { createRemixRouteManifest } from './createRemixRouteManifest'; +import { createRemixRouteManifest } from '../config/createRemixRouteManifest'; +import type { SentryRemixVitePluginOptions } from './types'; /** * Escapes a JSON string for safe embedding in HTML script tags. @@ -12,45 +13,13 @@ function escapeJsonForHtml(jsonString: string): string { .replace(/