Skip to content

Commit 9a92e6b

Browse files
authored
feat(astro): Add Sentry middleware (#9445)
Add a Sentry middleware handler for Astro [middleware](https://docs.astro.build/en/guides/middleware/). This handler * Creates a new span/transaction via `startSpan` * To create a parameterized transaction name, we interpolate the route name from the raw URL and the `params` object. * Continues a trace from tracing headers * Captures an error with Astro SDK-specific `handled` data * Optionally allows: * setting the client IP from the client request (only in SSR mode) * attaching request headers to `event.metadata.request`
1 parent 47b6730 commit 9a92e6b

File tree

4 files changed

+351
-1
lines changed

4 files changed

+351
-1
lines changed

packages/astro/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Install the Sentry Astro SDK with the `astro` CLI:
3131
npx astro add @sentry/astro
3232
```
3333

34-
Complete the setup by adding your DSN and source maps upload configuration:
34+
Add your DSN and source maps upload configuration:
3535

3636
```javascript
3737
import { defineConfig } from "astro/config";
@@ -56,6 +56,22 @@ Follow [this guide](https://docs.sentry.io/product/accounts/auth-tokens/#organiz
5656
SENTRY_AUTH_TOKEN="your-token"
5757
```
5858

59+
Complete the setup by adding the Sentry middleware to your `src/middleware.js` file:
60+
61+
```javascript
62+
// src/middleware.js
63+
import { sequence } from "astro:middleware";
64+
import * as Sentry from "@sentry/astro";
65+
66+
export const onRequest = sequence(
67+
Sentry.sentryMiddleware(),
68+
// Add your other handlers after sentryMiddleware
69+
);
70+
```
71+
72+
This middleware creates server-side spans to monitor performance on the server for page load and endpoint requests.
73+
74+
5975
## Configuration
6076

6177
Check out our docs for configuring your SDK setup:

packages/astro/src/index.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,6 @@ export {
6262
export * from '@sentry/node';
6363

6464
export { init } from './server/sdk';
65+
export { handleRequest } from './server/middleware';
6566

6667
export default sentryAstro;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { captureException, configureScope, startSpan } from '@sentry/node';
2+
import { addExceptionMechanism, objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils';
3+
import type { APIContext, MiddlewareResponseHandler } from 'astro';
4+
5+
type MiddlewareOptions = {
6+
/**
7+
* If true, the client IP will be attached to the event by calling `setUser`.
8+
* Only set this to `true` if you're fine with collecting potentially personally identifiable information (PII).
9+
*
10+
* This will only work if your app is configured for SSR
11+
*
12+
* @default false (recommended)
13+
*/
14+
trackClientIp?: boolean;
15+
16+
/**
17+
* If true, the headers from the request will be attached to the event by calling `setExtra`.
18+
* Only set this to `true` if you're fine with collecting potentially personally identifiable information (PII).
19+
*
20+
* @default false (recommended)
21+
*/
22+
trackHeaders?: boolean;
23+
};
24+
25+
function sendErrorToSentry(e: unknown): unknown {
26+
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
27+
// store a seen flag on it.
28+
const objectifiedErr = objectify(e);
29+
30+
captureException(objectifiedErr, scope => {
31+
scope.addEventProcessor(event => {
32+
addExceptionMechanism(event, {
33+
type: 'astro',
34+
handled: false,
35+
data: {
36+
function: 'astroMiddleware',
37+
},
38+
});
39+
return event;
40+
});
41+
42+
return scope;
43+
});
44+
45+
return objectifiedErr;
46+
}
47+
48+
export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseHandler = (
49+
options = { trackClientIp: false, trackHeaders: false },
50+
) => {
51+
return async (ctx, next) => {
52+
const method = ctx.request.method;
53+
const headers = ctx.request.headers;
54+
55+
const { dynamicSamplingContext, traceparentData, propagationContext } = tracingContextFromHeaders(
56+
headers.get('sentry-trace') || undefined,
57+
headers.get('baggage'),
58+
);
59+
60+
const allHeaders: Record<string, string> = {};
61+
headers.forEach((value, key) => {
62+
allHeaders[key] = value;
63+
});
64+
65+
configureScope(scope => {
66+
scope.setPropagationContext(propagationContext);
67+
68+
if (options.trackClientIp) {
69+
scope.setUser({ ip_address: ctx.clientAddress });
70+
}
71+
});
72+
73+
try {
74+
// storing res in a variable instead of directly returning is necessary to
75+
// invoke the catch block if next() throws
76+
const res = await startSpan(
77+
{
78+
name: `${method} ${interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params)}`,
79+
op: `http.server.${method.toLowerCase()}`,
80+
origin: 'auto.http.astro',
81+
status: 'ok',
82+
...traceparentData,
83+
metadata: {
84+
source: 'route',
85+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
86+
},
87+
data: {
88+
method,
89+
url: stripUrlQueryAndFragment(ctx.url.href),
90+
...(ctx.url.search && { 'http.query': ctx.url.search }),
91+
...(ctx.url.hash && { 'http.fragment': ctx.url.hash }),
92+
...(options.trackHeaders && { headers: allHeaders }),
93+
},
94+
},
95+
async span => {
96+
const res = await next();
97+
if (span && res.status) {
98+
span.setHttpStatus(res.status);
99+
}
100+
return res;
101+
},
102+
);
103+
return res;
104+
} catch (e) {
105+
sendErrorToSentry(e);
106+
throw e;
107+
}
108+
// TODO: flush if serveless (first extract function)
109+
};
110+
};
111+
112+
/**
113+
* Interpolates the route from the URL and the passed params.
114+
* Best we can do to get a route name instead of a raw URL.
115+
*
116+
* exported for testing
117+
*/
118+
export function interpolateRouteFromUrlAndParams(rawUrl: string, params: APIContext['params']): string {
119+
return Object.entries(params).reduce((interpolateRoute, value) => {
120+
const [paramId, paramValue] = value;
121+
return interpolateRoute.replace(new RegExp(`(/|-)${paramValue}(/|-|$)`), `$1[${paramId}]$2`);
122+
}, rawUrl);
123+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import * as SentryNode from '@sentry/node';
2+
import * as SentryUtils from '@sentry/utils';
3+
import { vi } from 'vitest';
4+
5+
import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware';
6+
7+
describe('sentryMiddleware', () => {
8+
const startSpanSpy = vi.spyOn(SentryNode, 'startSpan');
9+
10+
afterEach(() => {
11+
vi.clearAllMocks();
12+
});
13+
14+
it('creates a span for an incoming request', async () => {
15+
const middleware = handleRequest();
16+
const ctx = {
17+
request: {
18+
method: 'GET',
19+
url: '/users/123/details',
20+
headers: new Headers(),
21+
},
22+
url: new URL('https://myDomain.io/users/123/details'),
23+
params: {
24+
id: '123',
25+
},
26+
};
27+
const nextResult = Promise.resolve({ status: 200 });
28+
const next = vi.fn(() => nextResult);
29+
30+
// @ts-expect-error, a partial ctx object is fine here
31+
const resultFromNext = middleware(ctx, next);
32+
33+
expect(startSpanSpy).toHaveBeenCalledWith(
34+
{
35+
data: {
36+
method: 'GET',
37+
url: 'https://mydomain.io/users/123/details',
38+
},
39+
metadata: {
40+
source: 'route',
41+
},
42+
name: 'GET /users/[id]/details',
43+
op: 'http.server.get',
44+
origin: 'auto.http.astro',
45+
status: 'ok',
46+
},
47+
expect.any(Function), // the `next` function
48+
);
49+
50+
expect(next).toHaveBeenCalled();
51+
expect(resultFromNext).toStrictEqual(nextResult);
52+
});
53+
54+
it('throws and sends an error to sentry if `next()` throws', async () => {
55+
const scope = {
56+
addEventProcessor: vi.fn().mockImplementation(cb => cb({})),
57+
};
58+
// @ts-expect-error, just testing the callback, this is okay for this test
59+
const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException').mockImplementation((ex, cb) => cb(scope));
60+
const addExMechanismSpy = vi.spyOn(SentryUtils, 'addExceptionMechanism');
61+
62+
const middleware = handleRequest();
63+
const ctx = {
64+
request: {
65+
method: 'GET',
66+
url: '/users',
67+
headers: new Headers(),
68+
},
69+
url: new URL('https://myDomain.io/users/'),
70+
params: {},
71+
};
72+
73+
const error = new Error('Something went wrong');
74+
75+
const next = vi.fn(() => {
76+
throw error;
77+
});
78+
79+
// @ts-expect-error, a partial ctx object is fine here
80+
await expect(async () => middleware(ctx, next)).rejects.toThrowError();
81+
82+
expect(captureExceptionSpy).toHaveBeenCalledWith(error, expect.any(Function));
83+
expect(scope.addEventProcessor).toHaveBeenCalledTimes(1);
84+
expect(addExMechanismSpy).toHaveBeenCalledWith(
85+
{}, // the mocked event
86+
{
87+
handled: false,
88+
type: 'astro',
89+
data: { function: 'astroMiddleware' },
90+
},
91+
);
92+
});
93+
94+
it('attaches tracing headers', async () => {
95+
const scope = { setUser: vi.fn(), setPropagationContext: vi.fn() };
96+
// @ts-expect-error, only passing a partial Scope object
97+
const configureScopeSpy = vi.spyOn(SentryNode, 'configureScope').mockImplementation(cb => cb(scope));
98+
99+
const middleware = handleRequest();
100+
const ctx = {
101+
request: {
102+
method: 'GET',
103+
url: '/users',
104+
headers: new Headers({
105+
'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
106+
baggage: 'sentry-release=1.0.0',
107+
}),
108+
},
109+
params: {},
110+
url: new URL('https://myDomain.io/users/'),
111+
};
112+
const next = vi.fn();
113+
114+
// @ts-expect-error, a partial ctx object is fine here
115+
await middleware(ctx, next);
116+
117+
expect(configureScopeSpy).toHaveBeenCalledTimes(1);
118+
expect(scope.setPropagationContext).toHaveBeenCalledWith({
119+
dsc: {
120+
release: '1.0.0',
121+
},
122+
parentSpanId: '1234567890123456',
123+
sampled: true,
124+
spanId: expect.any(String),
125+
traceId: '12345678901234567890123456789012',
126+
});
127+
128+
expect(startSpanSpy).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
metadata: {
131+
source: 'route',
132+
dynamicSamplingContext: {
133+
release: '1.0.0',
134+
},
135+
},
136+
parentSampled: true,
137+
parentSpanId: '1234567890123456',
138+
traceId: '12345678901234567890123456789012',
139+
}),
140+
expect.any(Function), // the `next` function
141+
);
142+
});
143+
144+
it('attaches client IP and request headers if options are set', async () => {
145+
const scope = { setUser: vi.fn(), setPropagationContext: vi.fn() };
146+
// @ts-expect-error, only passing a partial Scope object
147+
const configureScopeSpy = vi.spyOn(SentryNode, 'configureScope').mockImplementation(cb => cb(scope));
148+
149+
const middleware = handleRequest({ trackClientIp: true, trackHeaders: true });
150+
const ctx = {
151+
request: {
152+
method: 'GET',
153+
url: '/users',
154+
headers: new Headers({
155+
'some-header': 'some-value',
156+
}),
157+
},
158+
clientAddress: '192.168.0.1',
159+
params: {},
160+
url: new URL('https://myDomain.io/users/'),
161+
};
162+
const next = vi.fn();
163+
164+
// @ts-expect-error, a partial ctx object is fine here
165+
await middleware(ctx, next);
166+
167+
expect(configureScopeSpy).toHaveBeenCalledTimes(1);
168+
expect(scope.setUser).toHaveBeenCalledWith({ ip_address: '192.168.0.1' });
169+
170+
expect(startSpanSpy).toHaveBeenCalledWith(
171+
expect.objectContaining({
172+
data: expect.objectContaining({
173+
headers: {
174+
'some-header': 'some-value',
175+
},
176+
}),
177+
}),
178+
expect.any(Function), // the `next` function
179+
);
180+
});
181+
});
182+
183+
describe('interpolateRouteFromUrlAndParams', () => {
184+
it.each([
185+
['/foo/bar', {}, '/foo/bar'],
186+
['/users/123', { id: '123' }, '/users/[id]'],
187+
['/users/123', { id: '123', foo: 'bar' }, '/users/[id]'],
188+
['/lang/en-US', { lang: 'en', region: 'US' }, '/lang/[lang]-[region]'],
189+
['/lang/en-US/posts', { lang: 'en', region: 'US' }, '/lang/[lang]-[region]/posts'],
190+
])('interpolates route from URL and params %s', (rawUrl, params, expectedRoute) => {
191+
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
192+
});
193+
194+
it('handles params across multiple URL segments in catchall routes', () => {
195+
// Ideally, Astro would let us know that this is a catchall route so we can make the param [...catchall] but it doesn't
196+
expect(
197+
interpolateRouteFromUrlAndParams('/someroute/catchall-123/params/foo/bar', {
198+
catchall: 'catchall-123/params/foo',
199+
params: 'foo',
200+
}),
201+
).toEqual('/someroute/[catchall]/bar');
202+
});
203+
204+
it("doesn't replace partially matching route segments", () => {
205+
const rawUrl = '/usernames/username';
206+
const params = { name: 'username' };
207+
const expectedRoute = '/usernames/[name]';
208+
expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute);
209+
});
210+
});

0 commit comments

Comments
 (0)