From 7268d2e8f7c066f0b030a0ef14a8bf9fc0f94d2c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:41:23 +0200 Subject: [PATCH 1/4] feat(nuxt)!: Emit low cardinality db span names With span streaming, `db0` query spans are named after their `db.query.summary` (`SELECT users`) instead of the full SQL statement, and report that summary as a new `db.query.summary` attribute. The statement is sanitized before it is summarized, so a string literal containing `from`/`join` cannot leak a value into the name. db0 reports no db system, so a statement that cannot be summarized falls back to `db.namespace` and then to the static `Database operation`. `traceLifecycle: 'static'` keeps the existing names. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/runtime/utils/instrumentDatabase.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts index 9f7d320fe390..859739e208ba 100644 --- a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -1,8 +1,13 @@ import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, addBreadcrumb, captureException, + DB_SPAN_NAME_FALLBACK, debug, flushIfServerless, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, type Span, @@ -220,10 +225,23 @@ function createBreadcrumb(query: string): void { * Creates a start span options object. */ function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + const client = getClient(); + // The statement is sanitized before it is summarized, so that a string literal containing + // `from`/`join` can't leak a value into the summary. + const querySummary = query ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(query)) : undefined; + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `{db.namespace}` when there is no statement to + // summarize. + const streamedName = + client && hasSpanStreamingEnabled(client) + ? querySummary || (data['db.namespace'] as string | undefined) || DB_SPAN_NAME_FALLBACK + : undefined; + return { - name: query, + name: streamedName ?? query, attributes: { 'db.query.text': query, + 'db.query.summary': querySummary, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', ...data, From d1237b55aaff176babcc19faa0a85916210de8a6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 27 Aug 2026 18:28:31 +0200 Subject: [PATCH 2/4] convert nuxt-3 e2e test to streaming --- .../nuxt-3/sentry.client.config.ts | 2 +- .../nuxt-3/sentry.server.config.ts | 1 - .../nuxt-3/server/api/db-test.ts | 5 + .../nuxt-3/tests/cache.test.ts | 125 +++++----- .../nuxt-3/tests/database-multi.test.ts | 187 +++++++-------- .../nuxt-3/tests/database.test.ts | 190 +++++++-------- .../nuxt-3/tests/middleware.test.ts | 221 +++++++++--------- .../nuxt-3/tests/storage-aliases.test.ts | 132 +++++------ .../nuxt-3/tests/storage.test.ts | 183 +++++++-------- .../nuxt-3/tests/tracing.client.test.ts | 71 +++--- .../nuxt-3/tests/tracing.server.test.ts | 60 +++-- .../nuxt-3/tests/tracing.test.ts | 193 +++++++-------- .../src/runtime/utils/instrumentDatabase.ts | 34 +-- 13 files changed, 659 insertions(+), 745 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts index e14d25bfc0c1..a29dce08a4c3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts @@ -2,12 +2,12 @@ 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 tracesSampleRate: 1.0, integrations: [ + Sentry.spanStreamingIntegration(), Sentry.vueIntegration({ tracingOptions: { trackComponents: true, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.server.config.ts index 44d9dd623666..e04331934f99 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/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/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts index 2241afdee14d..5e1e68f7fd85 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts @@ -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-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts index 135ce9a71e42..b863716733c6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts @@ -1,33 +1,34 @@ 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'; + // Streamed spans are flushed across multiple envelopes as they end, so the cache child spans can + // arrive in a different envelope than the `is_segment` root span. Accumulate until the root is seen. + function collectCacheSpans() { + return collectStreamedSpans('nuxt-3', spans => + spans.some(span => span.name === 'GET /api/cache-test' && span.is_segment), + ).then(spans => spans.filter(span => 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-3', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; - }); + const cacheSpansPromise = collectCacheSpans(); const response = await request.get('/api/cache-test?user=123&data=test-key'); 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 keyOf = (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,35 +37,35 @@ 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 keyOf(span) === 'string' && + (keyOf(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' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: false }, + 'db.operation.name': { type: 'string', value: 'getItem' }, }); + expect(cacheMissSpan.attributes['db.collection.name']?.value).toMatch(/^(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 keyOf(span) === 'string' && + (keyOf(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' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, }); + expect(cacheHitSpan.attributes['db.collection.name']?.value).toMatch(/^(cache)?$/); } // Test setItem spans for cachedFunction - when cache miss occurs, value is set @@ -72,42 +73,33 @@ 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 keyOf(span) === 'string' && (keyOf(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' }, + 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, + 'db.operation.name': { type: 'string', value: 'setItem' }, }); + expect(cacheSetSpan.attributes['db.collection.name']?.value).toMatch(/^(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 keyOf(span) === 'string' && (keyOf(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 keyOf(span) === 'string' && (keyOf(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 +109,38 @@ test.describe('Cache Instrumentation', () => { const uniqueUser = `test-${Date.now()}`; const uniqueData = `data-${Date.now()}`; - const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; - }); + 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-3/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts index a229b4db34cb..2a374162230a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts @@ -1,156 +1,149 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +// Streamed spans are flushed across multiple envelopes as they end, so the db child spans can arrive +// in a different envelope than the `is_segment` root span. Accumulate until the root span is seen. +function collectDbSpans() { + return collectStreamedSpans('nuxt-3', spans => + spans.some(span => span.name === 'GET /api/db-multi-test' && span.is_segment), + ).then(spans => spans.filter(span => getSpanOp(span) === 'db.query')); +} test.describe('multiple database instances', () => { test('instruments default database instance', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-multi-test'; - }); + 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'); - - expect(dbSpans).toBeDefined(); - expect(dbSpans!.length).toBeGreaterThan(0); + const dbSpans = await dbSpansPromise; + 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-3', 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-3', 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-3', 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.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.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.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-3', 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.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.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-3', 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-3/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts index f7635e6e06c9..ee697910df60 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts @@ -1,129 +1,117 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; + +// Streamed spans are flushed across multiple envelopes as they end, so the db child spans can arrive +// in a different envelope than the `is_segment` root span. Accumulate until the root span is seen. +function collectDbSpans() { + return collectStreamedSpans('nuxt-3', spans => + spans.some(span => span.name === 'GET /api/db-test' && span.is_segment), + ).then(spans => spans.filter(span => getSpanOp(span) === 'db.query')); +} test.describe('database integration', () => { test('captures db.prepare().get() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; - }); + 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-3', 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-3', 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-3', 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-3', 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.system.name': { type: 'string', value: 'sqlite' }, + 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, + }); + // The `.sql` tag only exposes the first template chunk, so the statement is truncated at the first value + expect(dbSpan?.attributes['db.query.text']?.value).toContain('INSERT INTO messages'); }); test('captures db.exec() span', async ({ request }) => { - const transactionPromise = waitForTransaction('nuxt-3', 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 + expect(dbSpans.map(span => span.name)).toEqual(expect.arrayContaining(['DROP TABLE logs', 'CREATE TABLE logs'])); }); test('captures database error and marks span as failed', async ({ request }) => { @@ -133,15 +121,13 @@ test.describe('database integration', () => { ); }); - const transactionPromise = waitForTransaction('nuxt-3', 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 +138,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-3', transactionEvent => { - return transactionEvent.transaction === 'GET /api/db-test'; + const errorPromise = waitForError('nuxt-3', 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-3', 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-3/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts index d28984152184..faa7e3d72a33 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts @@ -1,11 +1,17 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; + +// Streamed spans are flushed across multiple envelopes as they end, so the middleware child spans can +// arrive in a different envelope than the `is_segment` root span. Accumulate until the root span is seen. +function collectRequestSpans() { + return collectStreamedSpans('nuxt-3', spans => + spans.some(span => span.name === 'GET /api/middleware-test' && span.is_segment), + ); +} test.describe('Server Middleware Instrumentation', () => { test('should create separate spans for each server middleware', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-3', 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 +20,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) 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 findByName = (name: string) => + middlewareSpans.find(span => span.attributes['nuxt.middleware.name']?.value === name); + + const firstMiddlewareSpan = findByName('01.first'); + const secondMiddlewareSpan = findByName('02.second'); + const authMiddlewareSpan = findByName('03.auth'); + const hooksOnRequestSpan = findByName('04.hooks'); + const arrayHooksHandlerSpan = findByName('05.array-hooks'); expect(firstMiddlewareSpan).toBeDefined(); expect(secondMiddlewareSpan).toBeDefined(); @@ -41,12 +48,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 +75,22 @@ test.describe('Server Middleware Instrumentation', () => { }); test('middleware spans should have proper parent-child relationship', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-3', 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 rootSpan = spans.find(span => span.name === 'GET /api/middleware-test' && span.is_segment); + 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 root span middlewareSpans.forEach(span => { - expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(span.parent_span_id).toBe(rootSpan?.span_id); }); }); test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-3', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; @@ -98,19 +102,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'); const exception = errorEvent.exception?.values?.[0]; @@ -131,115 +135,113 @@ test.describe('Server Middleware Instrumentation', () => { }); test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-3', 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 findByHook = (hook: string) => + hooksSpans.find(span => span.attributes['nuxt.middleware.hook.name']?.value === hook); + + const onRequestSpan = findByHook('onRequest'); + const handlerSpan = findByHook('handler'); + const onBeforeResponseSpan = findByHook('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'); - // 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-3', 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-3', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-3', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; @@ -249,25 +251,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-3', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-3', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; @@ -277,25 +277,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-3', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); + const spansPromise = collectRequestSpans(); const errorEventPromise = waitForError('nuxt-3', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; @@ -305,31 +303,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-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts index 41ae29fd3560..7514e95d4964 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -1,6 +1,5 @@ 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('Storage Instrumentation - Aliases', () => { const prefixKey = (key: string) => `test-storage:${key}`; @@ -8,100 +7,97 @@ 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-3', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; - }); + // Streamed spans are flushed across multiple envelopes as they end, so the storage child spans can + // arrive in a different envelope than the `is_segment` root span. Accumulate until the root is seen. + const storageSpansPromise = collectStreamedSpans('nuxt-3', spans => + spans.some(span => span.name === 'GET /api/storage-aliases-test' && span.is_segment), + ).then(spans => spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nuxt')); 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' }, + '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(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(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' }, + '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(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(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' }, + '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' }, + '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(prefixKey('alias:temp1')); - 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' }, + '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(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(allStorageSpans.length).toBeGreaterThan(0); + allStorageSpans.forEach(span => { expect(span.status).toBe('ok'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts index 07cee795e50f..99ccf24597a0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -1,6 +1,5 @@ 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('Storage Instrumentation', () => { const prefixKey = (key: string) => `test-storage:${key}`; @@ -8,146 +7,134 @@ 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-3', transactionEvent => { - return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; - }); + // Streamed spans are flushed across multiple envelopes as they end, so the storage child spans can + // arrive in a different envelope than the `is_segment` root span. Accumulate until the root is seen. + const storageSpansPromise = collectStreamedSpans('nuxt-3', spans => + spans.some(span => span.name === 'GET /api/storage-test' && span.is_segment), + ).then(spans => spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nuxt')); 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 findByKey = (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 = findByKey('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' }, + '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(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(findSpansByMethod('setItemRaw').length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = findByKey('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' }, + '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 = findByKey('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' }, + '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 = findByKey('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' }, + '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(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(findSpansByMethod('getItemRaw').length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = findByKey('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' }, + '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' }, + '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 = findByKey('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' }, + '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' }, + '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-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts index 13994172dc32..45cc043c6cdd 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/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', async transactionEvent => { - return transactionEvent.transaction === '/test-param/:param()'; + const pageloadSpanPromise = waitForStreamedSpan('nuxt-3', 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' }, }); + expect(pageloadSpan.attributes['url.full']?.value).toMatch(/^https?:\/\/localhost:\d+\/test-param\/1234$/); }); test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { - const transactionPromise = waitForTransaction('nuxt-3', async transactionEvent => { - return transactionEvent.transaction === '/client-error'; - }); + const spansPromise = collectStreamedSpans('nuxt-3', 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/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts index edd186c2b971..0c68bd63a607 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts @@ -1,52 +1,46 @@ 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', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); +test('sends a server root span on pageload', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nuxt-3', 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', transactionEvent => { - if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + waitForStreamedSpan('nuxt-3', 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', transactionEvent => { - return transactionEvent.transaction.includes('GET /test-param/'); + const serverSpanPromise = waitForStreamedSpan('nuxt-3', 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('extracts HTTP request headers as span attributes', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { - return transactionEvent.transaction.includes('GET /api/test-param/'); + const serverSpanPromise = waitForStreamedSpan('nuxt-3', span => { + return span.is_segment && span.name.includes('GET /api/test-param/'); }); await fetch(`${baseURL}/api/test-param/headers-test`, { @@ -60,16 +54,14 @@ test('extracts HTTP request headers as span attributes', async ({ baseURL }) => }, }); - const transaction = await transactionPromise; + const serverSpan = await serverSpanPromise; - expect(transaction.contexts?.trace?.data).toEqual( - expect.objectContaining({ - 'http.request.header.user_agent': 'Custom-Nuxt-Agent/3.0', - 'http.request.header.content_type': 'application/json', - 'http.request.header.x_nuxt_test': 'nuxt-header-value', - 'http.request.header.accept': 'application/json, text/html', - 'http.request.header.x_framework': 'Nuxt', - 'http.request.header.x_request_id': 'nuxt-456', - }), - ); + expect(serverSpan.attributes).toMatchObject({ + 'http.request.header.user_agent': { type: 'string', value: 'Custom-Nuxt-Agent/3.0' }, + 'http.request.header.content_type': { type: 'string', value: 'application/json' }, + 'http.request.header.x_nuxt_test': { type: 'string', value: 'nuxt-header-value' }, + 'http.request.header.accept': { type: 'string', value: 'application/json, text/html' }, + 'http.request.header.x_framework': { type: 'string', value: 'Nuxt' }, + 'http.request.header.x_request_id': { type: 'string', value: 'nuxt-456' }, + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts index 7602f7ed606a..da27138484c2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts @@ -1,22 +1,22 @@ 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'; test('capture a distributed pageload trace', async ({ page }) => { - const clientTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { - return txnEvent.transaction === '/test-param/:param()'; + const clientSpanPromise = waitForStreamedSpan('nuxt-3', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const serverTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { - return txnEvent.transaction.includes('GET /test-param/'); + const serverSpanPromise = waitForStreamedSpan('nuxt-3', 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 +24,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 +33,108 @@ 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', 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', spans => { + return ( + spans.some(span => span.name === '/test-param/user/:userId()' && span.is_segment) && + spans.some(span => span.name === `GET /api/user/${PARAM}` && getSpanOp(span) === 'http.client') + ); }); - const ssrTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { - return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + const ssrSpanPromise = waitForStreamedSpan('nuxt-3', span => { + return span.is_segment && span.name.includes('GET /test-param/user'); }); - const serverReqTxnEventPromise = waitForTransaction('nuxt-3', txnEvent => { - return txnEvent.transaction?.includes('GET /api/user/') ?? false; + const serverReqSpanPromise = waitForStreamedSpan('nuxt-3', 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 => span.name === `GET /api/user/${PARAM}`); + + expect(pageloadSpan).toMatchObject({ + 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({ + name: `GET /api/user/${PARAM}`, // fixme: parametrize + 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' }, }), - ); - - 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(httpClientSpan?.attributes['url.full']?.value).toEqual(expect.stringContaining(`/api/user/${PARAM}`)); + + 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/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts index 859739e208ba..63b09d3f177d 100644 --- a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -3,7 +3,6 @@ import { _INTERNAL_sanitizeSqlQuery, addBreadcrumb, captureException, - DB_SPAN_NAME_FALLBACK, debug, flushIfServerless, getClient, @@ -17,6 +16,8 @@ import { } from '@sentry/core'; import type { Database, PreparedStatement } from 'db0'; import { type DatabaseConnectionConfig, type DatabaseSpanData, getDatabaseSpanData } from './database-span-data'; +import { DB_NAMESPACE, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME } from '@sentry/conventions/attributes'; +import { DB_QUERY } from '@sentry/conventions/op'; type MaybeInstrumentedDatabase = Database & { __sentry_instrumented__?: boolean; @@ -32,6 +33,13 @@ const patchedStatement = new WeakSet(); */ const SENTRY_ORIGIN = 'auto.db.nuxt'; +/** + * db0 exposes no db system we could name the span after, so unsummarizable statements fall back to + * this static name. + * @see https://getsentry.github.io/sentry-conventions/names/#db-queries + */ +const DB_SPAN_NAME_FALLBACK = 'Database operation'; + /** * Creates the Nitro database plugin setup by instrumenting the configured database instances. * @@ -74,7 +82,7 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConn } const metadata: DatabaseSpanData = { - 'db.system.name': config?.connector ?? db.dialect, + [DB_SYSTEM_NAME]: config?.connector ?? db.dialect, ...getDatabaseSpanData(config), }; @@ -225,25 +233,21 @@ function createBreadcrumb(query: string): void { * Creates a start span options object. */ function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { - const client = getClient(); - // The statement is sanitized before it is summarized, so that a string literal containing - // `from`/`join` can't leak a value into the summary. const querySummary = query ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(query)) : undefined; - // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used - // instead of the full statement, falling back to `{db.namespace}` when there is no statement to - // summarize. - const streamedName = + + const client = getClient(); + const name = client && hasSpanStreamingEnabled(client) - ? querySummary || (data['db.namespace'] as string | undefined) || DB_SPAN_NAME_FALLBACK - : undefined; + ? querySummary || (data[DB_NAMESPACE] as string | undefined) || DB_SPAN_NAME_FALLBACK + : query; return { - name: streamedName ?? query, + name, attributes: { - 'db.query.text': query, - 'db.query.summary': querySummary, + [DB_QUERY_TEXT]: query, + [DB_QUERY_SUMMARY]: querySummary, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: DB_QUERY, ...data, }, }; From 3a46f488e7f0e64649ccfd12e6fe47c309253844 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 14:28:20 +0200 Subject: [PATCH 3/4] fix tests and bring back fallback constant --- .../nuxt-3/sentry.client.config.ts | 1 - .../nuxt-3/tests/cache.test.ts | 37 ++++++++++--------- .../nuxt-3/tests/database-multi.test.ts | 18 ++++++--- .../nuxt-3/tests/database.test.ts | 37 ++++++++++++++----- .../nuxt-3/tests/middleware.test.ts | 21 ++++++++--- .../nuxt-3/tests/storage-aliases.test.ts | 23 +++++++++--- .../nuxt-3/tests/storage.test.ts | 23 +++++++++--- packages/core/src/tracing/spans/spanNames.ts | 7 ++++ .../src/runtime/utils/instrumentDatabase.ts | 34 +++++++++++++---- 9 files changed, 143 insertions(+), 58 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts index a29dce08a4c3..9a9566051452 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts @@ -7,7 +7,6 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ - Sentry.spanStreamingIntegration(), Sentry.vueIntegration({ tracingOptions: { trackComponents: true, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts index b863716733c6..b1d0cd7c003b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts @@ -5,12 +5,15 @@ test.describe('Cache Instrumentation', () => { const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; - // Streamed spans are flushed across multiple envelopes as they end, so the cache child spans can - // arrive in a different envelope than the `is_segment` root span. Accumulate until the root is seen. - function collectCacheSpans() { - return collectStreamedSpans('nuxt-3', spans => - spans.some(span => span.name === 'GET /api/cache-test' && span.is_segment), - ).then(spans => spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nuxt')); + async function collectCacheSpans() { + const spans = await collectStreamedSpans('nuxt-3', 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 ({ @@ -28,7 +31,7 @@ test.describe('Cache Instrumentation', () => { const findSpansByMethod = (method: string) => allCacheSpans.filter(span => span.attributes['db.operation.name']?.value === method); - const keyOf = (span: (typeof allCacheSpans)[number]) => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value; + 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'); @@ -37,8 +40,8 @@ test.describe('Cache Instrumentation', () => { // Find cache miss (first call to getCachedUser('123')) const cacheMissSpan = getItemSpans.find( span => - typeof keyOf(span) === 'string' && - (keyOf(span) as string).includes('user:123') && + typeof getCacheKey(span) === 'string' && + (getCacheKey(span) as string).includes('user:123') && !span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value, ); if (cacheMissSpan) { @@ -47,15 +50,15 @@ test.describe('Cache Instrumentation', () => { '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)?$/) }, }); - expect(cacheMissSpan.attributes['db.collection.name']?.value).toMatch(/^(cache)?$/); } // Find cache hit (second call to getCachedUser('123')) const cacheHitSpan = getItemSpans.find( span => - typeof keyOf(span) === 'string' && - (keyOf(span) as string).includes('user:123') && + typeof getCacheKey(span) === 'string' && + (getCacheKey(span) as string).includes('user:123') && span.attributes[SEMANTIC_ATTRIBUTE_CACHE_HIT]?.value, ); if (cacheHitSpan) { @@ -64,8 +67,8 @@ test.describe('Cache Instrumentation', () => { '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)?$/) }, }); - expect(cacheHitSpan.attributes['db.collection.name']?.value).toMatch(/^(cache)?$/); } // Test setItem spans for cachedFunction - when cache miss occurs, value is set @@ -73,26 +76,26 @@ test.describe('Cache Instrumentation', () => { expect(setItemSpans.length).toBeGreaterThan(0); const cacheSetSpan = setItemSpans.find( - span => typeof keyOf(span) === 'string' && (keyOf(span) as string).includes('user:123'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).includes('user:123'), ); if (cacheSetSpan) { expect(cacheSetSpan.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.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)?$/) }, }); - expect(cacheSetSpan.attributes['db.collection.name']?.value).toMatch(/^(cache)?$/); } // Test that we have spans for different cached functions const dataKeySpans = getItemSpans.filter( - span => typeof keyOf(span) === 'string' && (keyOf(span) as string).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 keyOf(span) === 'string' && (keyOf(span) as string).includes('cachedHandler'), + span => typeof getCacheKey(span) === 'string' && (getCacheKey(span) as string).includes('cachedHandler'), ); expect(cachedHandlerSpans.length).toBeGreaterThan(0); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts index 2a374162230a..fa9e0159fb9f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts @@ -1,12 +1,13 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -// Streamed spans are flushed across multiple envelopes as they end, so the db child spans can arrive -// in a different envelope than the `is_segment` root span. Accumulate until the root span is seen. -function collectDbSpans() { - return collectStreamedSpans('nuxt-3', spans => - spans.some(span => span.name === 'GET /api/db-multi-test' && span.is_segment), - ).then(spans => spans.filter(span => getSpanOp(span) === 'db.query')); +async function collectDbSpans() { + const spans = await collectStreamedSpans('nuxt-3', 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', () => { @@ -82,18 +83,21 @@ test.describe('multiple database instances', () => { // 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' }, @@ -118,6 +122,7 @@ test.describe('multiple database instances', () => { expect(logsInsertSpan).toBeDefined(); 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' }, @@ -126,6 +131,7 @@ test.describe('multiple database instances', () => { expect(auditLogsInsertSpan).toBeDefined(); 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' }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts index ee697910df60..420c6014756f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts @@ -1,12 +1,19 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; -// Streamed spans are flushed across multiple envelopes as they end, so the db child spans can arrive -// in a different envelope than the `is_segment` root span. Accumulate until the root span is seen. -function collectDbSpans() { - return collectStreamedSpans('nuxt-3', spans => - spans.some(span => span.name === 'GET /api/db-test' && span.is_segment), - ).then(spans => spans.filter(span => getSpanOp(span) === 'db.query')); +// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive +// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. +// Accumulate until the request's root span is seen, then keep only the spans of its trace. +// +// The root span is matched on `url.path`: with span streaming its name is only parameterized once the +// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. +async function collectDbSpans() { + const spans = await collectStreamedSpans('nuxt-3', 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', () => { @@ -87,11 +94,13 @@ test.describe('database integration', () => { expect(dbSpan).toBeDefined(); 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' }, }); - // The `.sql` tag only exposes the first template chunk, so the statement is truncated at the first value - expect(dbSpan?.attributes['db.query.text']?.value).toContain('INSERT INTO messages'); }); test('captures db.exec() span', async ({ request }) => { @@ -110,8 +119,16 @@ test.describe('database integration', () => { 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); - // DDL statements are summarized as `{operation} {table}` as well - expect(dbSpans.map(span => span.name)).toEqual(expect.arrayContaining(['DROP TABLE logs', 'CREATE TABLE logs'])); + // 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 }) => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts index faa7e3d72a33..3295f79ef4c4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts @@ -1,12 +1,21 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; -// Streamed spans are flushed across multiple envelopes as they end, so the middleware child spans can -// arrive in a different envelope than the `is_segment` root span. Accumulate until the root span is seen. -function collectRequestSpans() { - return collectStreamedSpans('nuxt-3', spans => - spans.some(span => span.name === 'GET /api/middleware-test' && span.is_segment), +const ROUTE = '/api/middleware-test'; + +// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive +// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. +// Accumulate until the request's root span is seen, then keep only the spans of its trace. +// +// The root span is matched on `url.path`: with span streaming its name is only parameterized once the +// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. +async function collectRequestSpans() { + const spans = await collectStreamedSpans('nuxt-3', 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); } test.describe('Server Middleware Instrumentation', () => { @@ -80,7 +89,7 @@ test.describe('Server Middleware Instrumentation', () => { await request.get('/api/middleware-test'); const spans = await spansPromise; - const rootSpan = spans.find(span => span.name === 'GET /api/middleware-test' && span.is_segment); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === ROUTE); const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); // All middleware spans should be children of the request's root span diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts index 7514e95d4964..2128620f9269 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -1,17 +1,30 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans } from '@sentry-internal/test-utils'; +// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive +// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. +// Accumulate until the request's root span is seen, then keep only the spans of its trace. +// +// The root span is matched on `url.path`: with span streaming its name is only parameterized once the +// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. +async function collectStorageSpans(route: string) { + const spans = await collectStreamedSpans('nuxt-3', 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}`; 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 }) => { - // Streamed spans are flushed across multiple envelopes as they end, so the storage child spans can - // arrive in a different envelope than the `is_segment` root span. Accumulate until the root is seen. - const storageSpansPromise = collectStreamedSpans('nuxt-3', spans => - spans.some(span => span.name === 'GET /api/storage-aliases-test' && span.is_segment), - ).then(spans => spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nuxt')); + const storageSpansPromise = collectStorageSpans('/api/storage-aliases-test'); const response = await request.get('/api/storage-aliases-test'); expect(response.status()).toBe(200); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts index 99ccf24597a0..7d2a84bb6f82 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -1,17 +1,30 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans } from '@sentry-internal/test-utils'; +// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive +// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. +// Accumulate until the request's root span is seen, then keep only the spans of its trace. +// +// The root span is matched on `url.path`: with span streaming its name is only parameterized once the +// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. +async function collectStorageSpans(route: string) { + const spans = await collectStreamedSpans('nuxt-3', 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}`; 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 }) => { - // Streamed spans are flushed across multiple envelopes as they end, so the storage child spans can - // arrive in a different envelope than the `is_segment` root span. Accumulate until the root is seen. - const storageSpansPromise = collectStreamedSpans('nuxt-3', spans => - spans.some(span => span.name === 'GET /api/storage-test' && span.is_segment), - ).then(spans => spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nuxt')); + const storageSpansPromise = collectStorageSpans('/api/storage-test'); const response = await request.get('/api/storage-test'); expect(response.status()).toBe(200); diff --git a/packages/core/src/tracing/spans/spanNames.ts b/packages/core/src/tracing/spans/spanNames.ts index 88eb200ae973..6d3862a9856b 100644 --- a/packages/core/src/tracing/spans/spanNames.ts +++ b/packages/core/src/tracing/spans/spanNames.ts @@ -14,6 +14,13 @@ export const PAGELOAD_SPAN_NAME_FALLBACK = 'Pageload'; */ export const NAVIGATION_SPAN_NAME_FALLBACK = 'Navigation'; +/** + * db0 exposes no db system we could name the span after, so unsummarizable statements fall back to + * this static name. + * @see https://getsentry.github.io/sentry-conventions/names/#db-queries + */ +export const DB_SPAN_NAME_FALLBACK = 'Database operation'; + /** * Fallback name for gen_ai agent spans when no better-suited span name is available. * @see https://getsentry.github.io/sentry-conventions/names/#gen_ai-agent diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts index 63b09d3f177d..376f19b861d3 100644 --- a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -3,6 +3,7 @@ import { _INTERNAL_sanitizeSqlQuery, addBreadcrumb, captureException, + DB_SPAN_NAME_FALLBACK, debug, flushIfServerless, getClient, @@ -33,13 +34,6 @@ const patchedStatement = new WeakSet(); */ const SENTRY_ORIGIN = 'auto.db.nuxt'; -/** - * db0 exposes no db system we could name the span after, so unsummarizable statements fall back to - * this static name. - * @see https://getsentry.github.io/sentry-conventions/names/#db-queries - */ -const DB_SPAN_NAME_FALLBACK = 'Database operation'; - /** * Creates the Nitro database plugin setup by instrumenting the configured database instances. * @@ -99,7 +93,8 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConn // https://github.com/unjs/db0/blob/main/src/database.ts#L64 db.sql = new Proxy(db.sql, { apply(target, thisArg, args: Parameters) { - const query = args[0]?.[0] ?? ''; + const [strings, ...values] = args; + const query = strings ? buildSqlTemplateQuery(strings, values) : ''; const opts = createStartSpanOptions(query, metadata); return startSpan( @@ -121,6 +116,29 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConn db.__sentry_instrumented__ = true; } +/** + * Rebuilds the parameterized statement that db0 hands to the connector from the `.sql` template + * tag's arguments. Mirrors db0's own template handling: interpolated values become `?` placeholders, + * except values wrapped in `{}`, which db0 inlines into the statement (e.g. table names). + * + * @see https://github.com/unjs/db0/blob/main/src/template.ts + */ +function buildSqlTemplateQuery(strings: TemplateStringsArray, values: unknown[]): string { + let query = strings[0] || ''; + + for (let i = 1; i < strings.length; i++) { + const chunk = strings[i] ?? ''; + + if (query.endsWith('{') && chunk.startsWith('}')) { + query = `${query.slice(0, -1)}${values[i - 1]}${chunk.slice(1)}`; + } else { + query += `?${chunk}`; + } + } + + return query.trim(); +} + /** * Instruments a DB prepared statement with Sentry. * From 5ae518dd49bac814d9b17beef81db47aa6673754 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 15:33:00 +0200 Subject: [PATCH 4/4] deslop converted tests --- .../nuxt-3/tests/database.test.ts | 6 --- .../nuxt-3/tests/middleware.test.ts | 45 +++++++++---------- .../nuxt-3/tests/storage-aliases.test.ts | 6 --- .../nuxt-3/tests/storage.test.ts | 20 +++------ .../nuxt-3/tests/tracing.client.test.ts | 2 +- .../nuxt-3/tests/tracing.test.ts | 3 +- 6 files changed, 32 insertions(+), 50 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts index 420c6014756f..0ede8db39345 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts @@ -1,12 +1,6 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; -// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive -// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. -// Accumulate until the request's root span is seen, then keep only the spans of its trace. -// -// The root span is matched on `url.path`: with span streaming its name is only parameterized once the -// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. async function collectDbSpans() { const spans = await collectStreamedSpans('nuxt-3', spans => spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-test'), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts index 3295f79ef4c4..8c3266994886 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/middleware.test.ts @@ -1,19 +1,11 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; -const ROUTE = '/api/middleware-test'; - -// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive -// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. -// Accumulate until the request's root span is seen, then keep only the spans of its trace. -// -// The root span is matched on `url.path`: with span streaming its name is only parameterized once the -// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. async function collectRequestSpans() { const spans = await collectStreamedSpans('nuxt-3', spans => - spans.some(span => span.is_segment && span.attributes['url.path']?.value === ROUTE), + 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 === ROUTE); + 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); } @@ -38,14 +30,14 @@ test.describe('Server Middleware Instrumentation', () => { expect(middlewareSpans).toHaveLength(11); // Check for specific middleware spans - const findByName = (name: string) => + const findSpanByName = (name: string) => middlewareSpans.find(span => span.attributes['nuxt.middleware.name']?.value === name); - const firstMiddlewareSpan = findByName('01.first'); - const secondMiddlewareSpan = findByName('02.second'); - const authMiddlewareSpan = findByName('03.auth'); - const hooksOnRequestSpan = findByName('04.hooks'); - const arrayHooksHandlerSpan = findByName('05.array-hooks'); + 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(); @@ -89,12 +81,14 @@ test.describe('Server Middleware Instrumentation', () => { await request.get('/api/middleware-test'); const spans = await spansPromise; - const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === ROUTE); + 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 request's root span + // All middleware spans should be children of the request's segment span middlewareSpans.forEach(span => { - expect(span.parent_span_id).toBe(rootSpan?.span_id); + expect(span.parent_span_id).toBe(segmentSpan?.span_id); }); }); @@ -160,12 +154,12 @@ test.describe('Server Middleware Instrumentation', () => { expect(hooksSpans).toHaveLength(3); // Find specific hook spans - const findByHook = (hook: string) => + const findSpanByHook = (hook: string) => hooksSpans.find(span => span.attributes['nuxt.middleware.hook.name']?.value === hook); - const onRequestSpan = findByHook('onRequest'); - const handlerSpan = findByHook('handler'); - const onBeforeResponseSpan = findByHook('onBeforeResponse'); + const onRequestSpan = findSpanByHook('onRequest'); + const handlerSpan = findSpanByHook('handler'); + const onBeforeResponseSpan = findSpanByHook('onBeforeResponse'); expect(onRequestSpan).toBeDefined(); expect(handlerSpan).toBeDefined(); @@ -181,6 +175,11 @@ test.describe('Server Middleware Instrumentation', () => { expect(span?.attributes['nuxt.middleware.name']?.value).toBe('04.hooks'); }); + // Verify hook-specific attributes + 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?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); expect(handlerSpan?.attributes['nuxt.middleware.hook.index']).toBeUndefined(); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts index 2128620f9269..562eb3d3c1b5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -1,12 +1,6 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans } from '@sentry-internal/test-utils'; -// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive -// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. -// Accumulate until the request's root span is seen, then keep only the spans of its trace. -// -// The root span is matched on `url.path`: with span streaming its name is only parameterized once the -// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. async function collectStorageSpans(route: string) { const spans = await collectStreamedSpans('nuxt-3', spans => spans.some(span => span.is_segment && span.attributes['url.path']?.value === route), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts index 7d2a84bb6f82..324069c1430b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -1,12 +1,6 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans } from '@sentry-internal/test-utils'; -// Streamed spans are flushed across multiple envelopes as they end, so spans of one request arrive -// spread over several envelopes, interleaved with spans of earlier requests that are still buffered. -// Accumulate until the request's root span is seen, then keep only the spans of its trace. -// -// The root span is matched on `url.path`: with span streaming its name is only parameterized once the -// route resolves, which doesn't happen for un-parameterized routes or requests that end in an error. async function collectStorageSpans(route: string) { const spans = await collectStreamedSpans('nuxt-3', spans => spans.some(span => span.is_segment && span.attributes['url.path']?.value === route), @@ -35,12 +29,12 @@ test.describe('Storage Instrumentation', () => { const findSpansByMethod = (method: string) => allStorageSpans.filter(span => span.attributes['db.operation.name']?.value === method); - const findByKey = (method: string, key: string) => + const findSpanByCacheKey = (method: string, key: string) => findSpansByMethod(method).find(span => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value === key); // Test setItem spans expect(findSpansByMethod('setItem').length).toBeGreaterThanOrEqual(1); - const setItemSpan = findByKey('setItem', prefixKey('user:123')); + const setItemSpan = findSpanByCacheKey('setItem', prefixKey('user:123')); expect(setItemSpan).toBeDefined(); expect(setItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.put' }, @@ -55,7 +49,7 @@ test.describe('Storage Instrumentation', () => { // Test setItemRaw spans expect(findSpansByMethod('setItemRaw').length).toBeGreaterThanOrEqual(1); - const setItemRawSpan = findByKey('setItemRaw', prefixKey('raw:data')); + const setItemRawSpan = findSpanByCacheKey('setItemRaw', prefixKey('raw:data')); expect(setItemRawSpan).toBeDefined(); expect(setItemRawSpan?.attributes).toMatchObject({ @@ -69,7 +63,7 @@ test.describe('Storage Instrumentation', () => { // Test hasItem spans - should have cache hit attribute expect(findSpansByMethod('hasItem').length).toBeGreaterThanOrEqual(1); - const hasItemSpan = findByKey('hasItem', prefixKey('user:123')); + const hasItemSpan = findSpanByCacheKey('hasItem', prefixKey('user:123')); expect(hasItemSpan).toBeDefined(); expect(hasItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, @@ -83,7 +77,7 @@ test.describe('Storage Instrumentation', () => { // Test getItem spans - should have cache hit attribute expect(findSpansByMethod('getItem').length).toBeGreaterThanOrEqual(1); - const getItemSpan = findByKey('getItem', prefixKey('user:123')); + const getItemSpan = findSpanByCacheKey('getItem', prefixKey('user:123')); expect(getItemSpan).toBeDefined(); expect(getItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, @@ -98,7 +92,7 @@ test.describe('Storage Instrumentation', () => { // Test getItemRaw spans - should have cache hit attribute expect(findSpansByMethod('getItemRaw').length).toBeGreaterThanOrEqual(1); - const getItemRawSpan = findByKey('getItemRaw', prefixKey('raw:data')); + const getItemRawSpan = findSpanByCacheKey('getItemRaw', prefixKey('raw:data')); expect(getItemRawSpan).toBeDefined(); expect(getItemRawSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.get' }, @@ -123,7 +117,7 @@ test.describe('Storage Instrumentation', () => { // Test removeItem spans expect(findSpansByMethod('removeItem').length).toBeGreaterThanOrEqual(1); - const removeItemSpan = findByKey('removeItem', prefixKey('batch:1')); + const removeItemSpan = findSpanByCacheKey('removeItem', prefixKey('batch:1')); expect(removeItemSpan).toBeDefined(); expect(removeItemSpan?.attributes).toMatchObject({ 'sentry.op': { type: 'string', value: 'cache.remove' }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts index 45cc043c6cdd..cb7c7d29bef0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts @@ -19,8 +19,8 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => '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$/) }, }); - expect(pageloadSpan.attributes['url.full']?.value).toMatch(/^https?:\/\/localhost:\d+\/test-param\/1234$/); }); test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts index da27138484c2..6299b8ba0258 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts @@ -90,6 +90,7 @@ test.describe('distributed tracing', () => { const httpClientSpan = clientSpans.find(span => span.name === `GET /api/user/${PARAM}`); expect(pageloadSpan).toMatchObject({ + name: '/test-param/user/:userId()', is_segment: true, attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'pageload' }, @@ -107,9 +108,9 @@ test.describe('distributed tracing', () => { '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/user/${PARAM}`) }, }), }); - expect(httpClientSpan?.attributes['url.full']?.value).toEqual(expect.stringContaining(`/api/user/${PARAM}`)); expect(ssrSpan).toMatchObject({ name: 'GET /test-param/user/:userId()', // parametrized route