Skip to content

Commit 61bcf73

Browse files
authored
feat(astro): Automatically add Sentry middleware in Astro integration (#9532)
This change adds automatic registration of our Astro middleware. This is possible since Astro 3.5.2 by [adding middleware](https://docs.astro.build/en/reference/integrations-reference/#addmiddleware-option) entry points in the astro integration's setup hook. This is backwards compatible with previous Astro versions because we can simply check if the `addMiddleware` function exists and only make use of it if it does.
1 parent 739d904 commit 61bcf73

File tree

11 files changed

+507
-83
lines changed

11 files changed

+507
-83
lines changed

packages/astro/README.md

+29-2
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ 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:
59+
### Server Instrumentation
60+
61+
For Astro apps configured for (hybrid) Server Side Rendering (SSR), the Sentry integration will automatically add middleware to your server to instrument incoming requests **if you're using Astro 3.5.0 or newer**.
62+
63+
If you're using Astro <3.5.0, complete the setup by adding the Sentry middleware to your `src/middleware.js` file:
6064

6165
```javascript
6266
// src/middleware.js
@@ -69,7 +73,30 @@ export const onRequest = sequence(
6973
);
7074
```
7175

72-
This middleware creates server-side spans to monitor performance on the server for page load and endpoint requests.
76+
The Sentry middleware enhances the data collected by Sentry on the server side by:
77+
- Enabeling distributed tracing between client and server
78+
- Collecting performance spans for incoming requests
79+
- Enhancing captured errors with additional information
80+
81+
#### Disable Automatic Server Instrumentation
82+
83+
You can opt out of using the automatic sentry server instrumentation in your `astro.config.mjs` file:
84+
85+
```javascript
86+
import { defineConfig } from "astro/config";
87+
import sentry from "@sentry/astro";
88+
89+
export default defineConfig({
90+
integrations: [
91+
sentry({
92+
dsn: "__DSN__",
93+
autoInstrumentation: {
94+
requestHandler: false,
95+
}
96+
}),
97+
],
98+
});
99+
```
73100

74101

75102
## Configuration

packages/astro/package.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
"import": "./build/esm/index.client.js",
2929
"require": "./build/cjs/index.server.js",
3030
"types": "./build/types/index.types.d.ts"
31+
},
32+
"./middleware": {
33+
"node": "./build/esm/integration/middleware/index.js",
34+
"import": "./build/esm/integration/middleware/index.js",
35+
"require": "./build/cjs/integration/middleware/index.js",
36+
"types": "./build/types/integration/middleware/index.types.d.ts"
3137
}
3238
},
3339
"publishConfig": {
@@ -45,7 +51,7 @@
4551
"@sentry/vite-plugin": "^2.8.0"
4652
},
4753
"devDependencies": {
48-
"astro": "^3.2.3",
54+
"astro": "^3.5.0",
4955
"rollup": "^3.20.2",
5056
"vite": "4.0.5"
5157
},

packages/astro/rollup.npm.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js'
22

33
const variants = makeNPMConfigVariants(
44
makeBaseNPMConfig({
5-
entrypoints: ['src/index.server.ts', 'src/index.client.ts'],
5+
entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/integration/middleware/index.ts'],
66
packageSpecificConfig: {
77
output: {
88
dynamicImportInCjs: true,

packages/astro/src/index.server.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// on the top - level namespace.
55

66
import { sentryAstro } from './integration';
7+
import { handleRequest } from './server/middleware';
78

89
// Hence, we export everything from the Node SDK explicitly:
910
export {
@@ -64,6 +65,8 @@ export {
6465
export * from '@sentry/node';
6566

6667
export { init } from './server/sdk';
67-
export { handleRequest } from './server/middleware';
6868

6969
export default sentryAstro;
70+
71+
// This exports the `handleRequest` middleware for manual usage
72+
export { handleRequest };

packages/astro/src/integration/index.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
1414
name: PKG_NAME,
1515
hooks: {
1616
// eslint-disable-next-line complexity
17-
'astro:config:setup': async ({ updateConfig, injectScript, config }) => {
17+
'astro:config:setup': async ({ updateConfig, injectScript, addMiddleware, config }) => {
1818
// The third param here enables loading of all env vars, regardless of prefix
1919
// see: https://main.vitejs.dev/config/#using-environment-variables-in-config
2020

@@ -73,6 +73,20 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
7373
options.debug && console.log('[sentry-astro] Using default server init.');
7474
injectScript('page-ssr', buildServerSnippet(options || {}));
7575
}
76+
77+
const isSSR = config && (config.output === 'server' || config.output === 'hybrid');
78+
const shouldAddMiddleware = options.autoInstrumentation?.requestHandler !== false;
79+
80+
// Guarding calling the addMiddleware function because it was only introduced in astro@3.5.0
81+
// Users on older versions of astro will need to add the middleware manually.
82+
const supportsAddMiddleware = typeof addMiddleware === 'function';
83+
84+
if (supportsAddMiddleware && isSSR && shouldAddMiddleware) {
85+
addMiddleware({
86+
order: 'pre',
87+
entrypoint: '@sentry/astro/middleware',
88+
});
89+
}
7690
},
7791
},
7892
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { MiddlewareResponseHandler } from 'astro';
2+
3+
import { handleRequest } from '../../server/middleware';
4+
5+
/**
6+
* This export is used by our integration to automatically add the middleware
7+
* to astro ^3.5.0 projects.
8+
*
9+
* It's not possible to pass options at this moment, so we'll call our middleware
10+
* factory function with the default options. Users can deactiveate the automatic
11+
* middleware registration in our integration and manually add it in their own
12+
* `/src/middleware.js` file.
13+
*/
14+
export const onRequest: MiddlewareResponseHandler = (ctx, next) => {
15+
return handleRequest()(ctx, next);
16+
};

packages/astro/src/integration/types.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,29 @@ type SourceMapsOptions = {
7171
};
7272
};
7373

74+
type InstrumentationOptions = {
75+
/**
76+
* Options for automatic instrumentation of your application.
77+
*/
78+
autoInstrumentation?: {
79+
/**
80+
* If this flag is `true` and your application is configured for SSR (or hybrid) mode,
81+
* the Sentry integration will automatically add middleware to:
82+
*
83+
* - capture server performance data and spans for incoming server requests
84+
* - enable distributed tracing between server and client
85+
* - annotate server errors with more information
86+
*
87+
* This middleware will only be added automatically in Astro 3.5.0 and newer.
88+
* For older versions, add the `Sentry.handleRequest` middleware manually
89+
* in your `src/middleware.js` file.
90+
*
91+
* @default true in SSR/hybrid mode, false in SSG/static mode
92+
*/
93+
requestHandler?: boolean;
94+
};
95+
};
96+
7497
/**
7598
* A subset of Sentry SDK options that can be set via the `sentryAstro` integration.
7699
* Some options (e.g. integrations) are set by default and cannot be changed here.
@@ -83,4 +106,5 @@ type SourceMapsOptions = {
83106
export type SentryOptions = SdkInitPaths &
84107
Pick<Options, 'dsn' | 'release' | 'environment' | 'sampleRate' | 'tracesSampleRate' | 'debug'> &
85108
Pick<BrowserOptions, 'replaysSessionSampleRate' | 'replaysOnErrorSampleRate'> &
86-
SourceMapsOptions;
109+
SourceMapsOptions &
110+
InstrumentationOptions;

packages/astro/src/server/middleware.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { captureException, configureScope, getCurrentHub, startSpan } from '@sentry/node';
22
import type { Hub, Span } from '@sentry/types';
3-
import { objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils';
3+
import {
4+
addNonEnumerableProperty,
5+
objectify,
6+
stripUrlQueryAndFragment,
7+
tracingContextFromHeaders,
8+
} from '@sentry/utils';
49
import type { APIContext, MiddlewareResponseHandler } from 'astro';
510

611
import { getTracingMetaTags } from './meta';
@@ -47,10 +52,21 @@ function sendErrorToSentry(e: unknown): unknown {
4752
return objectifiedErr;
4853
}
4954

55+
type AstroLocalsWithSentry = Record<string, unknown> & {
56+
__sentry_wrapped__?: boolean;
57+
};
58+
5059
export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseHandler = (
5160
options = { trackClientIp: false, trackHeaders: false },
5261
) => {
5362
return async (ctx, next) => {
63+
// Make sure we don't accidentally double wrap (e.g. user added middleware and integration auto added it)
64+
const locals = ctx.locals as AstroLocalsWithSentry;
65+
if (locals && locals.__sentry_wrapped__) {
66+
return next();
67+
}
68+
addNonEnumerableProperty(locals, '__sentry_wrapped__', true);
69+
5470
const method = ctx.request.method;
5571
const headers = ctx.request.headers;
5672

packages/astro/test/integration/index.test.ts

+84
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,88 @@ describe('sentryAstro integration', () => {
122122
expect(injectScript).toHaveBeenCalledWith('page', expect.stringContaining('my-client-init-path.js'));
123123
expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('my-server-init-path.js'));
124124
});
125+
126+
it.each(['server', 'hybrid'])(
127+
'adds middleware by default if in %s mode and `addMiddleware` is available',
128+
async mode => {
129+
const integration = sentryAstro({});
130+
const addMiddleware = vi.fn();
131+
const updateConfig = vi.fn();
132+
const injectScript = vi.fn();
133+
134+
expect(integration.hooks['astro:config:setup']).toBeDefined();
135+
// @ts-expect-error - the hook exists and we only need to pass what we actually use
136+
await integration.hooks['astro:config:setup']({
137+
// @ts-expect-error - we only need to pass what we actually use
138+
config: { output: mode },
139+
addMiddleware,
140+
updateConfig,
141+
injectScript,
142+
});
143+
144+
expect(addMiddleware).toHaveBeenCalledTimes(1);
145+
expect(addMiddleware).toHaveBeenCalledWith({
146+
order: 'pre',
147+
entrypoint: '@sentry/astro/middleware',
148+
});
149+
},
150+
);
151+
152+
it.each([{ output: 'static' }, { output: undefined }])(
153+
"doesn't add middleware if in static mode (config %s)",
154+
async config => {
155+
const integration = sentryAstro({});
156+
const addMiddleware = vi.fn();
157+
const updateConfig = vi.fn();
158+
const injectScript = vi.fn();
159+
160+
expect(integration.hooks['astro:config:setup']).toBeDefined();
161+
// @ts-expect-error - the hook exists and we only need to pass what we actually use
162+
await integration.hooks['astro:config:setup']({
163+
config,
164+
addMiddleware,
165+
updateConfig,
166+
injectScript,
167+
});
168+
169+
expect(addMiddleware).toHaveBeenCalledTimes(0);
170+
},
171+
);
172+
173+
it("doesn't add middleware if disabled by users", async () => {
174+
const integration = sentryAstro({ autoInstrumentation: { requestHandler: false } });
175+
const addMiddleware = vi.fn();
176+
const updateConfig = vi.fn();
177+
const injectScript = vi.fn();
178+
179+
expect(integration.hooks['astro:config:setup']).toBeDefined();
180+
// @ts-expect-error - the hook exists and we only need to pass what we actually use
181+
await integration.hooks['astro:config:setup']({
182+
// @ts-expect-error - we only need to pass what we actually use
183+
config: { output: 'server' },
184+
addMiddleware,
185+
updateConfig,
186+
injectScript,
187+
});
188+
189+
expect(addMiddleware).toHaveBeenCalledTimes(0);
190+
});
191+
192+
it("doesn't add middleware (i.e. crash) if `addMiddleware` is N/A", async () => {
193+
const integration = sentryAstro({ autoInstrumentation: { requestHandler: false } });
194+
const updateConfig = vi.fn();
195+
const injectScript = vi.fn();
196+
197+
expect(integration.hooks['astro:config:setup']).toBeDefined();
198+
// @ts-expect-error - the hook exists and we only need to pass what we actually use
199+
await integration.hooks['astro:config:setup']({
200+
// @ts-expect-error - we only need to pass what we actually use
201+
config: { output: 'server' },
202+
updateConfig,
203+
injectScript,
204+
});
205+
206+
expect(updateConfig).toHaveBeenCalledTimes(1);
207+
expect(injectScript).toHaveBeenCalledTimes(2);
208+
});
125209
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { vi } from 'vitest';
2+
3+
import { onRequest } from '../../../src/integration/middleware';
4+
5+
vi.mock('../../../src/server/meta', () => ({
6+
getTracingMetaTags: () => ({
7+
sentryTrace: '<meta name="sentry-trace" content="123">',
8+
baggage: '<meta name="baggage" content="abc">',
9+
}),
10+
}));
11+
12+
describe('Integration middleware', () => {
13+
it('exports an onRequest middleware request handler', async () => {
14+
expect(typeof onRequest).toBe('function');
15+
16+
const next = vi.fn().mockReturnValue(Promise.resolve(new Response(null, { status: 200, headers: new Headers() })));
17+
const ctx = {
18+
request: {
19+
method: 'GET',
20+
url: '/users/123/details',
21+
headers: new Headers(),
22+
},
23+
url: new URL('https://myDomain.io/users/123/details'),
24+
params: {
25+
id: '123',
26+
},
27+
};
28+
// @ts-expect-error - a partial ctx object is fine here
29+
const res = await onRequest(ctx, next);
30+
31+
expect(res).toBeDefined();
32+
});
33+
});

0 commit comments

Comments
 (0)