-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
/
Copy pathmiddleware.ts
328 lines (289 loc) · 10.3 KB
/
middleware.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
import {
addNonEnumerableProperty,
extractQueryParamsFromUrl,
logger,
objectify,
stripUrlQueryAndFragment,
vercelWaitUntil,
winterCGRequestToRequestData,
} from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
captureException,
continueTrace,
flush,
getActiveSpan,
getClient,
getCurrentScope,
getTraceMetaTags,
setHttpStatus,
startSpan,
withIsolationScope,
} from '@sentry/node';
import type { RequestEventData, Scope, SpanAttributes } from '@sentry/types';
import type { APIContext, MiddlewareResponseHandler } from 'astro';
type MiddlewareOptions = {
/**
* If true, the client IP will be attached to the event by calling `setUser`.
*
* Important: Only enable this option if your Astro app is configured for (hybrid) SSR
* via the `output: 'server' | 'hybrid'` option in your `astro.config.mjs` file.
* Otherwise, Astro will throw an error when starting the server.
*
* Only set this to `true` if you're fine with collecting potentially personally identifiable information (PII).
*
* @default false (recommended)
*/
trackClientIp?: boolean;
};
function sendErrorToSentry(e: unknown): unknown {
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
// store a seen flag on it.
const objectifiedErr = objectify(e);
captureException(objectifiedErr, {
mechanism: {
type: 'astro',
handled: false,
data: {
function: 'astroMiddleware',
},
},
});
return objectifiedErr;
}
type AstroLocalsWithSentry = Record<string, unknown> & {
__sentry_wrapped__?: boolean;
};
export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseHandler = options => {
const handlerOptions = {
trackClientIp: false,
...options,
};
return async (ctx, next) => {
// if there is an active span, we know that this handle call is nested and hence
// we don't create a new domain for it. If we created one, nested server calls would
// create new transactions instead of adding a child span to the currently active span.
if (getActiveSpan()) {
return instrumentRequest(ctx, next, handlerOptions);
}
return withIsolationScope(isolationScope => {
return instrumentRequest(ctx, next, handlerOptions, isolationScope);
});
};
};
async function instrumentRequest(
ctx: Parameters<MiddlewareResponseHandler>[0],
next: Parameters<MiddlewareResponseHandler>[1],
options: MiddlewareOptions,
isolationScope?: Scope,
): Promise<Response> {
// Make sure we don't accidentally double wrap (e.g. user added middleware and integration auto added it)
const locals = ctx.locals as AstroLocalsWithSentry;
if (locals && locals.__sentry_wrapped__) {
return next();
}
addNonEnumerableProperty(locals, '__sentry_wrapped__', true);
const isDynamicPageRequest = checkIsDynamicPageRequest(ctx);
const request = ctx.request;
const { method, headers } = isDynamicPageRequest
? request
: // headers can only be accessed in dynamic routes. Accessing `request.headers` in a static route
// will make the server log a warning.
{ method: request.method, headers: undefined };
return continueTrace(
{
sentryTrace: headers?.get('sentry-trace') || undefined,
baggage: headers?.get('baggage'),
},
async () => {
getCurrentScope().setSDKProcessingMetadata({
// We store the request on the current scope, not isolation scope,
// because we may have multiple requests nested inside each other
normalizedRequest: (isDynamicPageRequest
? winterCGRequestToRequestData(request)
: {
method,
url: request.url,
query_string: extractQueryParamsFromUrl(request.url),
}) satisfies RequestEventData,
});
if (options.trackClientIp && isDynamicPageRequest) {
getCurrentScope().setUser({ ip_address: ctx.clientAddress });
}
try {
const interpolatedRoute = interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params);
const source = interpolatedRoute ? 'route' : 'url';
// storing res in a variable instead of directly returning is necessary to
// invoke the catch block if next() throws
const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
method,
url: stripUrlQueryAndFragment(ctx.url.href),
};
if (ctx.url.search) {
attributes['http.query'] = ctx.url.search;
}
if (ctx.url.hash) {
attributes['http.fragment'] = ctx.url.hash;
}
isolationScope?.setTransactionName(`${method} ${interpolatedRoute || ctx.url.pathname}`);
const res = await startSpan(
{
attributes,
name: `${method} ${interpolatedRoute || ctx.url.pathname}`,
op: 'http.server',
},
async span => {
try {
const originalResponse = await next();
if (originalResponse.status) {
setHttpStatus(span, originalResponse.status);
}
const client = getClient();
const contentType = originalResponse.headers.get('content-type');
const isPageloadRequest = contentType && contentType.startsWith('text/html');
if (!isPageloadRequest || !client) {
return originalResponse;
}
// Type case necessary b/c the body's ReadableStream type doesn't include
// the async iterator that is actually available in Node
// We later on use the async iterator to read the body chunks
// see https://github.com/microsoft/TypeScript/issues/39051
const originalBody = originalResponse.body as NodeJS.ReadableStream | null;
if (!originalBody) {
return originalResponse;
}
const decoder = new TextDecoder();
const newResponseStream = new ReadableStream({
start: async controller => {
for await (const chunk of originalBody) {
const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
const modifiedHtml = addMetaTagToHead(html);
controller.enqueue(new TextEncoder().encode(modifiedHtml));
}
controller.close();
},
});
return new Response(newResponseStream, originalResponse);
} catch (e) {
sendErrorToSentry(e);
throw e;
}
},
);
return res;
} finally {
vercelWaitUntil(
(async () => {
// Flushes pending Sentry events with a 2-second timeout and in a way that cannot create unhandled promise rejections.
try {
await flush(2000);
} catch (e) {
logger.log('Error while flushing events:\n', e);
}
})(),
);
}
// TODO: flush if serverless (first extract function)
},
);
}
/**
* This function optimistically assumes that the HTML coming in chunks will not be split
* within the <head> tag. If this still happens, we simply won't replace anything.
*/
function addMetaTagToHead(htmlChunk: string): string {
if (typeof htmlChunk !== 'string') {
return htmlChunk;
}
const metaTags = getTraceMetaTags();
if (!metaTags) {
return htmlChunk;
}
const content = `<head>${metaTags}`;
return htmlChunk.replace('<head>', content);
}
/**
* Interpolates the route from the URL and the passed params.
* Best we can do to get a route name instead of a raw URL.
*
* exported for testing
*
* @param rawUrlPathname - The raw URL pathname, e.g. '/users/123/details'
* @param params - The params object, e.g. `{ userId: '123' }`
*
* @returns The interpolated route, e.g. '/users/[userId]/details'
*/
export function interpolateRouteFromUrlAndParams(
rawUrlPathname: string,
params: APIContext['params'],
): string | undefined {
const decodedUrlPathname = tryDecodeUrl(rawUrlPathname);
if (!decodedUrlPathname) {
return undefined;
}
// Invert params map so that the param values are the keys
// differentiate between rest params spanning multiple url segments
// and normal, single-segment params.
const valuesToMultiSegmentParams: Record<string, string> = {};
const valuesToParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
if (!value) {
return;
}
if (value.includes('/')) {
valuesToMultiSegmentParams[value] = key;
return;
}
valuesToParams[value] = key;
});
function replaceWithParamName(segment: string): string {
const param = valuesToParams[segment];
if (param) {
return `[${param}]`;
}
return segment;
}
// before we match single-segment params, we first replace multi-segment params
const urlWithReplacedMultiSegmentParams = Object.keys(valuesToMultiSegmentParams).reduce((acc, key) => {
return acc.replace(key, `[${valuesToMultiSegmentParams[key]}]`);
}, decodedUrlPathname);
return urlWithReplacedMultiSegmentParams
.split('/')
.map(segment => {
if (!segment) {
return '';
}
if (valuesToParams[segment]) {
return replaceWithParamName(segment);
}
// astro permits multiple params in a single path segment, e.g. /[foo]-[bar]/
const segmentParts = segment.split('-');
if (segmentParts.length > 1) {
return segmentParts.map(part => replaceWithParamName(part)).join('-');
}
return segment;
})
.join('/');
}
function tryDecodeUrl(url: string): string | undefined {
try {
return decodeURI(url);
} catch {
return undefined;
}
}
/**
* Checks if the incoming request is a request for a dynamic (server-side rendered) page.
* We can check this by looking at the middleware's `clientAddress` context property because accessing
* this prop in a static route will throw an error which we can conveniently catch.
*/
function checkIsDynamicPageRequest(context: Parameters<MiddlewareResponseHandler>[0]): boolean {
try {
return context.clientAddress != null;
} catch {
return false;
}
}