diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 0fcccd560af9..8ea55702863c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -11,4 +11,11 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..f5da20313c71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'nuxt.storage.op': 'setItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'getItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'hasItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'nuxt.storage.op': 'removeItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'nuxt.storage.op': 'removeItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts new file mode 100644 index 000000000000..cc9d2957d122 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'nuxt.storage.op': 'setItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'nuxt.storage.op': 'setItemRaw', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'hasItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'getItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'getItemRaw', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'nuxt.storage.op': 'getKeys', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'nuxt.storage.op': 'removeItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'nuxt.storage.op': 'clear', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index d0ae045f1e9d..50924877649d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -21,4 +21,11 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..32074b4beccc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'nuxt.storage.op': 'setItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'getItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'hasItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'nuxt.storage.op': 'removeItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'nuxt.storage.op': 'removeItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts new file mode 100644 index 000000000000..103725ea61b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'nuxt.storage.op': 'setItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'nuxt.storage.op': 'setItemRaw', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'hasItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'getItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'nuxt.storage.op': 'getItemRaw', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'nuxt.storage.op': 'getKeys', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'nuxt.storage.op': 'removeItem', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'nuxt.storage.op': 'clear', + 'nuxt.storage.mount': 'test-storage:', + 'nuxt.storage.driver': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/packages/nuxt/src/common/server-template.ts b/packages/nuxt/src/common/server-template.ts new file mode 100644 index 000000000000..afdb46345d5c --- /dev/null +++ b/packages/nuxt/src/common/server-template.ts @@ -0,0 +1,17 @@ +import { useNuxt } from '@nuxt/kit'; +import type { NuxtTemplate } from 'nuxt/schema'; + +/** + * Adds a virtual file that can be used within the Nuxt Nitro server build. + * Available in NuxtKit v4, so we are porting it here. + * https://github.com/nuxt/nuxt/blob/d6df732eec1a3bd442bdb325b0335beb7e10cd64/packages/kit/src/template.ts#L55-L62 + */ +export function addServerTemplate(template: NuxtTemplate): NuxtTemplate { + const nuxt = useNuxt(); + if (template.filename) { + nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}; + nuxt.options.nitro.virtual[template.filename] = template.getContents; + } + + return template; +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 1e806e4dc2eb..947eb2710f4d 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -12,6 +12,7 @@ import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; +import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -126,6 +127,7 @@ export default defineNuxtModule({ // Preps the the middleware instrumentation module. if (serverConfigFile) { addMiddlewareImports(); + addStorageInstrumentation(nuxt); } nuxt.hooks.hook('nitro:init', nitro => { diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts new file mode 100644 index 000000000000..c5b33e54b4e6 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -0,0 +1,223 @@ +import { + type SpanAttributes, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, +} from '@sentry/core'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useStorage } from 'nitropack/runtime'; +import type { Driver, Storage } from 'unstorage'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; + +type MaybeInstrumentedDriver = Driver & { + __sentry_instrumented__?: boolean; +}; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have a key argument. + */ +const KEYED_METHODS = new Set([ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', +]); + +/** + * Methods that should have a attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates a Nitro plugin that instruments the storage driver. + */ +export default defineNitroPlugin(async _nitroApp => { + // This runs at runtime when the Nitro server starts + const storage = useStorage(); + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + try { + instrumentDriver(mount.driver, mount.base); + } catch { + debug.error(`[storage] Failed to unmount mount: "${mount.base}"`); + } + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +}); + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const attributes = getSpanAttributes(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + const spanName = KEYED_METHODS.has(methodName) + ? `${mountBase}${args?.[0]}` + : `storage.${normalizeMethodName(methodName)}`; + + return startSpan( + { + name: spanName, + attributes, + }, + async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isEmptyValue(result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }, + ); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original = storage.mount; + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + return mountWithInstrumentation; +} + +/** + * Gets the span attributes for the storage method. + */ +function getSpanAttributes(methodName: string, driver: Driver, mountBase: string, args: unknown[]): SpanAttributes { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'nuxt.storage.op': methodName, + 'nuxt.storage.mount': mountBase, + 'nuxt.storage.driver': driver.name ?? 'unknown', + }; + + // Add the key if it's a get/set/del call + if (args?.[0] && typeof args[0] === 'string') { + attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY] = `${mountBase}${args[0]}`; + } + + return attributes; +} + +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): boolean { + return value === null || value === undefined; +} diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts new file mode 100644 index 000000000000..2d8e1fe457ef --- /dev/null +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -0,0 +1,21 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import type { Nuxt } from 'nuxt/schema'; +import { addServerTemplate } from '../common/server-template'; + +/** + * Prepares the storage config export to be used in the runtime storage instrumentation. + */ +export function addStorageInstrumentation(nuxt: Nuxt): void { + const moduleDirResolver = createResolver(import.meta.url); + const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/storage-config.mjs', + getContents: () => { + return `export const userStorageMounts = ${JSON.stringify(userStorageMounts)};`; + }, + }); + + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); +}