Skip to content

Commit 15a36bc

Browse files
authored
feat(sveltekit): Add performance monitoring to Sveltekit server handle (#7532)
1 parent 8b8bb47 commit 15a36bc

File tree

4 files changed

+367
-0
lines changed

4 files changed

+367
-0
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/* eslint-disable @sentry-internal/sdk/no-optional-chaining */
2+
import { captureException, getCurrentHub, startTransaction } from '@sentry/node';
3+
import type { Transaction } from '@sentry/types';
4+
import {
5+
addExceptionMechanism,
6+
baggageHeaderToDynamicSamplingContext,
7+
extractTraceparentData,
8+
isThenable,
9+
objectify,
10+
} from '@sentry/utils';
11+
import type { Handle } from '@sveltejs/kit';
12+
import * as domain from 'domain';
13+
14+
function sendErrorToSentry(e: unknown): unknown {
15+
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
16+
// store a seen flag on it.
17+
const objectifiedErr = objectify(e);
18+
19+
captureException(objectifiedErr, scope => {
20+
scope.addEventProcessor(event => {
21+
addExceptionMechanism(event, {
22+
type: 'sveltekit',
23+
handled: false,
24+
data: {
25+
function: 'handle',
26+
},
27+
});
28+
return event;
29+
});
30+
31+
return scope;
32+
});
33+
34+
return objectifiedErr;
35+
}
36+
37+
/**
38+
* A SvelteKit handle function that wraps the request for Sentry error and
39+
* performance monitoring.
40+
*
41+
* Usage:
42+
* ```
43+
* // src/hooks.server.ts
44+
* import { sentryHandle } from '@sentry/sveltekit';
45+
*
46+
* export const handle = sentryHandle;
47+
*
48+
* // Optionally use the sequence function to add additional handlers.
49+
* // export const handle = sequence(sentryHandle, yourCustomHandle);
50+
* ```
51+
*/
52+
export const sentryHandle: Handle = ({ event, resolve }) => {
53+
return domain.create().bind(() => {
54+
let maybePromiseResult;
55+
56+
const sentryTraceHeader = event.request.headers.get('sentry-trace');
57+
const baggageHeader = event.request.headers.get('baggage');
58+
const traceparentData = sentryTraceHeader ? extractTraceparentData(sentryTraceHeader) : undefined;
59+
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggageHeader);
60+
61+
// transaction could be undefined if hub extensions were not added.
62+
const transaction: Transaction | undefined = startTransaction({
63+
op: 'http.server',
64+
name: `${event.request.method} ${event.route.id}`,
65+
status: 'ok',
66+
...traceparentData,
67+
metadata: {
68+
source: 'route',
69+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
70+
},
71+
});
72+
73+
getCurrentHub().getScope()?.setSpan(transaction);
74+
75+
try {
76+
maybePromiseResult = resolve(event);
77+
} catch (e) {
78+
transaction?.setStatus('internal_error');
79+
const sentryError = sendErrorToSentry(e);
80+
transaction?.finish();
81+
throw sentryError;
82+
}
83+
84+
if (isThenable(maybePromiseResult)) {
85+
Promise.resolve(maybePromiseResult).then(
86+
response => {
87+
transaction?.setHttpStatus(response.status);
88+
transaction?.finish();
89+
},
90+
e => {
91+
transaction?.setStatus('internal_error');
92+
sendErrorToSentry(e);
93+
transaction?.finish();
94+
},
95+
);
96+
} else {
97+
transaction?.setHttpStatus(maybePromiseResult.status);
98+
transaction?.finish();
99+
}
100+
101+
return maybePromiseResult;
102+
})();
103+
};

packages/sveltekit/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from '@sentry/node';
33
export { init } from './sdk';
44
export { handleErrorWithSentry } from './handleError';
55
export { wrapLoadWithSentry } from './load';
6+
export { sentryHandle } from './handle';
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { addTracingExtensions, Hub, makeMain, Scope } from '@sentry/core';
2+
import { NodeClient } from '@sentry/node';
3+
import type { Transaction } from '@sentry/types';
4+
import type { Handle } from '@sveltejs/kit';
5+
import { vi } from 'vitest';
6+
7+
import { sentryHandle } from '../../src/server/handle';
8+
import { getDefaultNodeClientOptions } from '../utils';
9+
10+
const mockCaptureException = vi.fn();
11+
let mockScope = new Scope();
12+
13+
vi.mock('@sentry/node', async () => {
14+
const original = (await vi.importActual('@sentry/node')) as any;
15+
return {
16+
...original,
17+
captureException: (err: unknown, cb: (arg0: unknown) => unknown) => {
18+
cb(mockScope);
19+
mockCaptureException(err, cb);
20+
return original.captureException(err, cb);
21+
},
22+
};
23+
});
24+
25+
const mockAddExceptionMechanism = vi.fn();
26+
27+
vi.mock('@sentry/utils', async () => {
28+
const original = (await vi.importActual('@sentry/utils')) as any;
29+
return {
30+
...original,
31+
addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args),
32+
};
33+
});
34+
35+
function mockEvent(override: Record<string, unknown> = {}): Parameters<Handle>[0]['event'] {
36+
const event: Parameters<Handle>[0]['event'] = {
37+
cookies: {} as any,
38+
fetch: () => Promise.resolve({} as any),
39+
getClientAddress: () => '',
40+
locals: {},
41+
params: { id: '123' },
42+
platform: {},
43+
request: {
44+
method: 'GET',
45+
headers: {
46+
get: () => null,
47+
append: () => {},
48+
delete: () => {},
49+
forEach: () => {},
50+
has: () => false,
51+
set: () => {},
52+
},
53+
} as any,
54+
route: { id: '/users/[id]' },
55+
setHeaders: () => {},
56+
url: new URL('http://localhost:3000/users/123'),
57+
isDataRequest: false,
58+
59+
...override,
60+
};
61+
62+
return event;
63+
}
64+
65+
const mockResponse = { status: 200, headers: {}, body: '' } as any;
66+
67+
const enum Type {
68+
Sync = 'sync',
69+
Async = 'async',
70+
}
71+
72+
function resolve(type: Type, isError: boolean): Parameters<Handle>[0]['resolve'] {
73+
if (type === Type.Sync) {
74+
return (..._args: unknown[]) => {
75+
if (isError) {
76+
throw new Error(type);
77+
}
78+
79+
return mockResponse;
80+
};
81+
}
82+
83+
return (..._args: unknown[]) => {
84+
return new Promise((resolve, reject) => {
85+
if (isError) {
86+
reject(new Error(type));
87+
} else {
88+
resolve(mockResponse);
89+
}
90+
});
91+
};
92+
}
93+
94+
let hub: Hub;
95+
let client: NodeClient;
96+
97+
describe('handleSentry', () => {
98+
beforeAll(() => {
99+
addTracingExtensions();
100+
});
101+
102+
beforeEach(() => {
103+
mockScope = new Scope();
104+
const options = getDefaultNodeClientOptions({ tracesSampleRate: 1.0 });
105+
client = new NodeClient(options);
106+
hub = new Hub(client);
107+
makeMain(hub);
108+
109+
mockCaptureException.mockClear();
110+
mockAddExceptionMechanism.mockClear();
111+
});
112+
113+
describe.each([
114+
// isSync, isError, expectedResponse
115+
[Type.Sync, true, undefined],
116+
[Type.Sync, false, mockResponse],
117+
[Type.Async, true, undefined],
118+
[Type.Async, false, mockResponse],
119+
])('%s resolve with error %s', (type, isError, mockResponse) => {
120+
it('should return a response', async () => {
121+
let response: any = undefined;
122+
try {
123+
response = await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) });
124+
} catch (e) {
125+
expect(e).toBeInstanceOf(Error);
126+
expect(e.message).toEqual(type);
127+
}
128+
129+
expect(response).toEqual(mockResponse);
130+
});
131+
132+
it('creates a transaction', async () => {
133+
let ref: any = undefined;
134+
client.on('finishTransaction', (transaction: Transaction) => {
135+
ref = transaction;
136+
});
137+
138+
try {
139+
await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) });
140+
} catch (e) {
141+
//
142+
}
143+
144+
expect(ref).toBeDefined();
145+
146+
expect(ref.name).toEqual('GET /users/[id]');
147+
expect(ref.op).toEqual('http.server');
148+
expect(ref.status).toEqual(isError ? 'internal_error' : 'ok');
149+
expect(ref.metadata.source).toEqual('route');
150+
151+
expect(ref.endTimestamp).toBeDefined();
152+
});
153+
154+
it('creates a transaction from sentry-trace header', async () => {
155+
const event = mockEvent({
156+
request: {
157+
headers: {
158+
get: (key: string) => {
159+
if (key === 'sentry-trace') {
160+
return '1234567890abcdef1234567890abcdef-1234567890abcdef-1';
161+
}
162+
163+
return null;
164+
},
165+
},
166+
},
167+
});
168+
169+
let ref: any = undefined;
170+
client.on('finishTransaction', (transaction: Transaction) => {
171+
ref = transaction;
172+
});
173+
174+
try {
175+
await sentryHandle({ event, resolve: resolve(type, isError) });
176+
} catch (e) {
177+
//
178+
}
179+
180+
expect(ref).toBeDefined();
181+
expect(ref.traceId).toEqual('1234567890abcdef1234567890abcdef');
182+
expect(ref.parentSpanId).toEqual('1234567890abcdef');
183+
expect(ref.sampled).toEqual(true);
184+
});
185+
186+
it('creates a transaction with dynamic sampling context from baggage header', async () => {
187+
const event = mockEvent({
188+
request: {
189+
headers: {
190+
get: (key: string) => {
191+
if (key === 'sentry-trace') {
192+
return '1234567890abcdef1234567890abcdef-1234567890abcdef-1';
193+
}
194+
195+
if (key === 'baggage') {
196+
return (
197+
'sentry-environment=production,sentry-release=1.0.0,sentry-transaction=dogpark,' +
198+
'sentry-user_segment=segmentA,sentry-public_key=dogsarebadatkeepingsecrets,' +
199+
'sentry-trace_id=1234567890abcdef1234567890abcdef,sentry-sample_rate=1'
200+
);
201+
}
202+
203+
return null;
204+
},
205+
},
206+
},
207+
});
208+
209+
let ref: any = undefined;
210+
client.on('finishTransaction', (transaction: Transaction) => {
211+
ref = transaction;
212+
});
213+
214+
try {
215+
await sentryHandle({ event, resolve: resolve(type, isError) });
216+
} catch (e) {
217+
//
218+
}
219+
220+
expect(ref).toBeDefined();
221+
expect(ref.metadata.dynamicSamplingContext).toEqual({
222+
environment: 'production',
223+
release: '1.0.0',
224+
public_key: 'dogsarebadatkeepingsecrets',
225+
sample_rate: '1',
226+
trace_id: '1234567890abcdef1234567890abcdef',
227+
transaction: 'dogpark',
228+
user_segment: 'segmentA',
229+
});
230+
});
231+
232+
it('send errors to Sentry', async () => {
233+
const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => {
234+
void callback({}, { event_id: 'fake-event-id' });
235+
return mockScope;
236+
});
237+
238+
try {
239+
await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) });
240+
} catch (e) {
241+
expect(mockCaptureException).toBeCalledTimes(1);
242+
expect(addEventProcessorSpy).toBeCalledTimes(1);
243+
expect(mockAddExceptionMechanism).toBeCalledTimes(1);
244+
expect(mockAddExceptionMechanism).toBeCalledWith(
245+
{},
246+
{ handled: false, type: 'sveltekit', data: { function: 'handle' } },
247+
);
248+
}
249+
});
250+
});
251+
});

packages/sveltekit/test/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createTransport } from '@sentry/core';
2+
import type { ClientOptions } from '@sentry/types';
3+
import { resolvedSyncPromise } from '@sentry/utils';
4+
5+
export function getDefaultNodeClientOptions(options: Partial<ClientOptions> = {}): ClientOptions {
6+
return {
7+
integrations: [],
8+
transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})),
9+
stackParser: () => [],
10+
...options,
11+
};
12+
}

0 commit comments

Comments
 (0)