Skip to content

Commit 8e3afe2

Browse files
authored
feat(nuxt): Instrument storage API (#17858)
## What This PR adds automatic instrumentation for Nuxt's storage layer (powered by [unstorage](https://unstorage.unjs.io/)), enabling performance monitoring for cache and key-value storage operations in Nuxt/Nitro applications. Storage operations will now automatically create performance spans with detailed attributes for observability in Sentry. ### What's New - **Automatic Storage Instrumentation**: Instruments all storage drivers configured in `nuxt.config.ts` via `nitro.storage` - **Comprehensive Coverage**: Tracks all storage operations including: - `getItem`, `setItem`, `hasItem`, `removeItem` - Raw variants: `getItemRaw`, `setItemRaw` - Batch operations: `getItems`, `setItems` - Utility methods: `getKeys`, `clear` - Aliases: `get`, `set`, `has`, `del`, `remove` ### Implementation Details **Span Attributes:** - `sentry.op`: `cache.{operation}` (e.g., `cache.get_item`, `cache.set_item`) - `sentry.origin`: `auto.cache.nuxt` - `cache.key`: Full key including mount prefix - `cache.hit`: `true` for successful get/has operations - `db.operation.name`: Original method name - `db.collection.name`: Storage mount point - `db.system.name`: Driver name (e.g., `memory`, `fs`, `redis`) **Files Changed:** - `packages/nuxt/src/runtime/plugins/storage.server.ts` - Runtime instrumentation plugin - `packages/nuxt/src/vite/storageConfig.ts` - Build-time configuration - `packages/nuxt/src/module.ts` - Module integration - E2E tests for Nuxt 3 & 4
1 parent 4dc6c7b commit 8e3afe2

File tree

14 files changed

+1011
-0
lines changed

14 files changed

+1011
-0
lines changed

dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,11 @@ export default defineNuxtConfig({
1111
},
1212
},
1313
},
14+
nitro: {
15+
storage: {
16+
'test-storage': {
17+
driver: 'memory',
18+
},
19+
},
20+
},
1421
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useStorage } from '#imports';
2+
import { defineEventHandler } from 'h3';
3+
4+
export default defineEventHandler(async _event => {
5+
const storage = useStorage('test-storage');
6+
7+
// Test all alias methods (get, set, del, remove)
8+
const results: Record<string, unknown> = {};
9+
10+
// Test set (alias for setItem)
11+
await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' });
12+
results.set = 'success';
13+
14+
// Test get (alias for getItem)
15+
const user = await storage.get('alias:user');
16+
results.get = user;
17+
18+
// Test has (alias for hasItem)
19+
const hasUser = await storage.has('alias:user');
20+
results.has = hasUser;
21+
22+
// Setup for delete tests
23+
await storage.set('alias:temp1', 'temp1');
24+
await storage.set('alias:temp2', 'temp2');
25+
26+
// Test del (alias for removeItem)
27+
await storage.del('alias:temp1');
28+
results.del = 'success';
29+
30+
// Test remove (alias for removeItem)
31+
await storage.remove('alias:temp2');
32+
results.remove = 'success';
33+
34+
// Verify deletions worked
35+
const hasTemp1 = await storage.has('alias:temp1');
36+
const hasTemp2 = await storage.has('alias:temp2');
37+
results.verifyDeletions = !hasTemp1 && !hasTemp2;
38+
39+
// Clean up
40+
await storage.clear();
41+
42+
return {
43+
success: true,
44+
results,
45+
};
46+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useStorage } from '#imports';
2+
import { defineEventHandler } from 'h3';
3+
4+
export default defineEventHandler(async _event => {
5+
const storage = useStorage('test-storage');
6+
7+
// Test all instrumented methods
8+
const results: Record<string, unknown> = {};
9+
10+
// Test setItem
11+
await storage.setItem('user:123', { name: 'John Doe', email: '[email protected]' });
12+
results.setItem = 'success';
13+
14+
// Test setItemRaw
15+
await storage.setItemRaw('raw:data', Buffer.from('raw data'));
16+
results.setItemRaw = 'success';
17+
18+
// Manually set batch items (setItems not supported by memory driver)
19+
await storage.setItem('batch:1', 'value1');
20+
await storage.setItem('batch:2', 'value2');
21+
22+
// Test hasItem
23+
const hasUser = await storage.hasItem('user:123');
24+
results.hasItem = hasUser;
25+
26+
// Test getItem
27+
const user = await storage.getItem('user:123');
28+
results.getItem = user;
29+
30+
// Test getItemRaw
31+
const rawData = await storage.getItemRaw('raw:data');
32+
results.getItemRaw = rawData?.toString();
33+
34+
// Test getKeys
35+
const keys = await storage.getKeys('batch:');
36+
results.getKeys = keys;
37+
38+
// Test removeItem
39+
await storage.removeItem('batch:1');
40+
results.removeItem = 'success';
41+
42+
// Test clear
43+
await storage.clear();
44+
results.clear = 'success';
45+
46+
// Verify clear worked
47+
const keysAfterClear = await storage.getKeys();
48+
results.keysAfterClear = keysAfterClear;
49+
50+
return {
51+
success: true,
52+
results,
53+
};
54+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt';
4+
5+
test.describe('Storage Instrumentation - Aliases', () => {
6+
const prefixKey = (key: string) => `test-storage:${key}`;
7+
const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key';
8+
const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit';
9+
10+
test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => {
11+
const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => {
12+
return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false;
13+
});
14+
15+
const response = await request.get('/api/storage-aliases-test');
16+
expect(response.status()).toBe(200);
17+
18+
const transaction = await transactionPromise;
19+
20+
// Helper to find spans by operation
21+
const findSpansByOp = (op: string) => {
22+
return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || [];
23+
};
24+
25+
// Test set (alias for setItem)
26+
const setSpans = findSpansByOp('cache.set_item');
27+
expect(setSpans.length).toBeGreaterThanOrEqual(1);
28+
const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user'));
29+
expect(setSpan).toBeDefined();
30+
expect(setSpan?.data).toMatchObject({
31+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item',
32+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
33+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'),
34+
'db.operation.name': 'setItem',
35+
'db.collection.name': 'test-storage',
36+
'db.system.name': 'memory',
37+
});
38+
expect(setSpan?.description).toBe(prefixKey('alias:user'));
39+
40+
// Test get (alias for getItem)
41+
const getSpans = findSpansByOp('cache.get_item');
42+
expect(getSpans.length).toBeGreaterThanOrEqual(1);
43+
const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user'));
44+
expect(getSpan).toBeDefined();
45+
expect(getSpan?.data).toMatchObject({
46+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item',
47+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
48+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'),
49+
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: true,
50+
'db.operation.name': 'getItem',
51+
'db.collection.name': 'test-storage',
52+
'db.system.name': 'memory',
53+
});
54+
expect(getSpan?.description).toBe(prefixKey('alias:user'));
55+
56+
// Test has (alias for hasItem)
57+
const hasSpans = findSpansByOp('cache.has_item');
58+
expect(hasSpans.length).toBeGreaterThanOrEqual(1);
59+
const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user'));
60+
expect(hasSpan).toBeDefined();
61+
expect(hasSpan?.data).toMatchObject({
62+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item',
63+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
64+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'),
65+
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: true,
66+
'db.operation.name': 'hasItem',
67+
'db.collection.name': 'test-storage',
68+
'db.system.name': 'memory',
69+
});
70+
71+
// Test del and remove (both aliases for removeItem)
72+
const removeSpans = findSpansByOp('cache.remove_item');
73+
expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls
74+
75+
const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1'));
76+
expect(delSpan).toBeDefined();
77+
expect(delSpan?.data).toMatchObject({
78+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item',
79+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
80+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'),
81+
'db.operation.name': 'removeItem',
82+
'db.collection.name': 'test-storage',
83+
'db.system.name': 'memory',
84+
});
85+
expect(delSpan?.description).toBe(prefixKey('alias:temp1'));
86+
87+
const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2'));
88+
expect(removeSpan).toBeDefined();
89+
expect(removeSpan?.data).toMatchObject({
90+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item',
91+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
92+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'),
93+
'db.operation.name': 'removeItem',
94+
'db.collection.name': 'test-storage',
95+
'db.system.name': 'memory',
96+
});
97+
expect(removeSpan?.description).toBe(prefixKey('alias:temp2'));
98+
99+
// Verify all spans have OK status
100+
const allStorageSpans = transaction.spans?.filter(
101+
span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt',
102+
);
103+
expect(allStorageSpans?.length).toBeGreaterThan(0);
104+
allStorageSpans?.forEach(span => {
105+
expect(span.status).toBe('ok');
106+
});
107+
});
108+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt';
4+
5+
test.describe('Storage Instrumentation', () => {
6+
const prefixKey = (key: string) => `test-storage:${key}`;
7+
const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key';
8+
const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit';
9+
10+
test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => {
11+
const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => {
12+
return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false;
13+
});
14+
15+
const response = await request.get('/api/storage-test');
16+
expect(response.status()).toBe(200);
17+
18+
const transaction = await transactionPromise;
19+
20+
// Helper to find spans by operation
21+
const findSpansByOp = (op: string) => {
22+
return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || [];
23+
};
24+
25+
// Test setItem spans
26+
const setItemSpans = findSpansByOp('cache.set_item');
27+
expect(setItemSpans.length).toBeGreaterThanOrEqual(1);
28+
const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123'));
29+
expect(setItemSpan).toBeDefined();
30+
expect(setItemSpan?.data).toMatchObject({
31+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item',
32+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
33+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'),
34+
'db.operation.name': 'setItem',
35+
'db.collection.name': 'test-storage',
36+
'db.system.name': 'memory',
37+
});
38+
39+
expect(setItemSpan?.description).toBe(prefixKey('user:123'));
40+
41+
// Test setItemRaw spans
42+
const setItemRawSpans = findSpansByOp('cache.set_item_raw');
43+
expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1);
44+
45+
const setItemRawSpan = setItemRawSpans.find(
46+
span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'),
47+
);
48+
49+
expect(setItemRawSpan).toBeDefined();
50+
expect(setItemRawSpan?.data).toMatchObject({
51+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw',
52+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
53+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'),
54+
'db.operation.name': 'setItemRaw',
55+
'db.collection.name': 'test-storage',
56+
'db.system.name': 'memory',
57+
});
58+
59+
// Test hasItem spans - should have cache hit attribute
60+
const hasItemSpans = findSpansByOp('cache.has_item');
61+
expect(hasItemSpans.length).toBeGreaterThanOrEqual(1);
62+
const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123'));
63+
expect(hasItemSpan).toBeDefined();
64+
expect(hasItemSpan?.data).toMatchObject({
65+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item',
66+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
67+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'),
68+
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: true,
69+
'db.operation.name': 'hasItem',
70+
'db.collection.name': 'test-storage',
71+
'db.system.name': 'memory',
72+
});
73+
74+
// Test getItem spans - should have cache hit attribute
75+
const getItemSpans = findSpansByOp('cache.get_item');
76+
expect(getItemSpans.length).toBeGreaterThanOrEqual(1);
77+
const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123'));
78+
expect(getItemSpan).toBeDefined();
79+
expect(getItemSpan?.data).toMatchObject({
80+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item',
81+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
82+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'),
83+
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: true,
84+
'db.operation.name': 'getItem',
85+
'db.collection.name': 'test-storage',
86+
'db.system.name': 'memory',
87+
});
88+
expect(getItemSpan?.description).toBe(prefixKey('user:123'));
89+
90+
// Test getItemRaw spans - should have cache hit attribute
91+
const getItemRawSpans = findSpansByOp('cache.get_item_raw');
92+
expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1);
93+
const getItemRawSpan = getItemRawSpans.find(
94+
span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'),
95+
);
96+
expect(getItemRawSpan).toBeDefined();
97+
expect(getItemRawSpan?.data).toMatchObject({
98+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw',
99+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
100+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'),
101+
[SEMANTIC_ATTRIBUTE_CACHE_HIT]: true,
102+
'db.operation.name': 'getItemRaw',
103+
'db.collection.name': 'test-storage',
104+
'db.system.name': 'memory',
105+
});
106+
107+
// Test getKeys spans
108+
const getKeysSpans = findSpansByOp('cache.get_keys');
109+
expect(getKeysSpans.length).toBeGreaterThanOrEqual(1);
110+
expect(getKeysSpans[0]?.data).toMatchObject({
111+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys',
112+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
113+
'db.operation.name': 'getKeys',
114+
'db.collection.name': 'test-storage',
115+
'db.system.name': 'memory',
116+
});
117+
118+
// Test removeItem spans
119+
const removeItemSpans = findSpansByOp('cache.remove_item');
120+
expect(removeItemSpans.length).toBeGreaterThanOrEqual(1);
121+
const removeItemSpan = removeItemSpans.find(
122+
span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'),
123+
);
124+
expect(removeItemSpan).toBeDefined();
125+
expect(removeItemSpan?.data).toMatchObject({
126+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item',
127+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
128+
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'),
129+
'db.operation.name': 'removeItem',
130+
'db.collection.name': 'test-storage',
131+
'db.system.name': 'memory',
132+
});
133+
134+
// Test clear spans
135+
const clearSpans = findSpansByOp('cache.clear');
136+
expect(clearSpans.length).toBeGreaterThanOrEqual(1);
137+
expect(clearSpans[0]?.data).toMatchObject({
138+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear',
139+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt',
140+
'db.operation.name': 'clear',
141+
'db.collection.name': 'test-storage',
142+
'db.system.name': 'memory',
143+
});
144+
145+
// Verify all spans have OK status
146+
const allStorageSpans = transaction.spans?.filter(
147+
span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt',
148+
);
149+
expect(allStorageSpans?.length).toBeGreaterThan(0);
150+
allStorageSpans?.forEach(span => {
151+
expect(span.status).toBe('ok');
152+
});
153+
});
154+
});

dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,11 @@ export default defineNuxtConfig({
2121
},
2222
},
2323
},
24+
nitro: {
25+
storage: {
26+
'test-storage': {
27+
driver: 'memory',
28+
},
29+
},
30+
},
2431
});

0 commit comments

Comments
 (0)