Skip to content

Commit 4fc2e7e

Browse files
authored
feat(astro): Add distributed tracing via <meta> tags (#9483)
Add `<meta>` tag injection in our new `handleRequest` Astro middleware to enable distributed traces between BE and FE transactions. This is also the first step towards exporting a `<meta>` tag helper function (tracked in #8438). In a future PR I'll extract the function to the core or utils package and export it in our server-side SDKs.
1 parent ab39b26 commit 4fc2e7e

File tree

4 files changed

+408
-8
lines changed

4 files changed

+408
-8
lines changed

packages/astro/src/server/meta.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { getDynamicSamplingContextFromClient } from '@sentry/core';
2+
import type { Hub, Span } from '@sentry/types';
3+
import {
4+
dynamicSamplingContextToSentryBaggageHeader,
5+
generateSentryTraceHeader,
6+
logger,
7+
TRACEPARENT_REGEXP,
8+
} from '@sentry/utils';
9+
10+
/**
11+
* Extracts the tracing data from the current span or from the client's scope
12+
* (via transaction or propagation context) and renders the data to <meta> tags.
13+
*
14+
* This function creates two serialized <meta> tags:
15+
* - `<meta name="sentry-trace" content="..."/>`
16+
* - `<meta name="baggage" content="..."/>`
17+
*
18+
* TODO: Extract this later on and export it from the Core or Node SDK
19+
*
20+
* @param span the currently active span
21+
* @param client the SDK's client
22+
*
23+
* @returns an object with the two serialized <meta> tags
24+
*/
25+
export function getTracingMetaTags(span: Span | undefined, hub: Hub): { sentryTrace: string; baggage?: string } {
26+
const scope = hub.getScope();
27+
const client = hub.getClient();
28+
const { dsc, sampled, traceId } = scope.getPropagationContext();
29+
const transaction = span?.transaction;
30+
31+
const sentryTrace = span ? span.toTraceparent() : generateSentryTraceHeader(traceId, undefined, sampled);
32+
33+
const dynamicSamplingContext = transaction
34+
? transaction.getDynamicSamplingContext()
35+
: dsc
36+
? dsc
37+
: client
38+
? getDynamicSamplingContextFromClient(traceId, client, scope)
39+
: undefined;
40+
41+
const baggage = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext);
42+
43+
const isValidSentryTraceHeader = TRACEPARENT_REGEXP.test(sentryTrace);
44+
if (!isValidSentryTraceHeader) {
45+
logger.warn('Invalid sentry-trace data. Returning empty <meta name="sentry-trace"/> tag');
46+
}
47+
48+
const validBaggage = isValidBaggageString(baggage);
49+
if (!validBaggage) {
50+
logger.warn('Invalid baggage data. Returning empty <meta name="baggage"/> tag');
51+
}
52+
53+
return {
54+
sentryTrace: `<meta name="sentry-trace" content="${isValidSentryTraceHeader ? sentryTrace : ''}"/>`,
55+
baggage: baggage && `<meta name="baggage" content="${validBaggage ? baggage : ''}"/>`,
56+
};
57+
}
58+
59+
/**
60+
* Tests string against baggage spec as defined in:
61+
*
62+
* - W3C Baggage grammar: https://www.w3.org/TR/baggage/#definition
63+
* - RFC7230 token definition: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
64+
*
65+
* exported for testing
66+
*/
67+
export function isValidBaggageString(baggage?: string): boolean {
68+
if (!baggage || !baggage.length) {
69+
return false;
70+
}
71+
const keyRegex = "[-!#$%&'*+.^_`|~A-Za-z0-9]+";
72+
const valueRegex = '[!#-+-./0-9:<=>?@A-Z\\[\\]a-z{-}]+';
73+
const spaces = '\\s*';
74+
const baggageRegex = new RegExp(
75+
`^${keyRegex}${spaces}=${spaces}${valueRegex}(${spaces},${spaces}${keyRegex}${spaces}=${spaces}${valueRegex})*$`,
76+
);
77+
return baggageRegex.test(baggage);
78+
}

packages/astro/src/server/middleware.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { captureException, configureScope, startSpan } from '@sentry/node';
1+
import { captureException, configureScope, getCurrentHub, startSpan } from '@sentry/node';
2+
import type { Hub, Span } from '@sentry/types';
23
import { addExceptionMechanism, objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils';
34
import type { APIContext, MiddlewareResponseHandler } from 'astro';
45

6+
import { getTracingMetaTags } from './meta';
7+
58
type MiddlewareOptions = {
69
/**
710
* If true, the client IP will be attached to the event by calling `setUser`.
@@ -97,11 +100,42 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH
97100
},
98101
},
99102
async span => {
100-
const res = await next();
101-
if (span && res.status) {
102-
span.setHttpStatus(res.status);
103+
const originalResponse = await next();
104+
105+
if (span && originalResponse.status) {
106+
span.setHttpStatus(originalResponse.status);
107+
}
108+
109+
const hub = getCurrentHub();
110+
const client = hub.getClient();
111+
const contentType = originalResponse.headers.get('content-type');
112+
113+
const isPageloadRequest = contentType && contentType.startsWith('text/html');
114+
if (!isPageloadRequest || !client) {
115+
return originalResponse;
116+
}
117+
118+
// Type case necessary b/c the body's ReadableStream type doesn't include
119+
// the async iterator that is actually available in Node
120+
// We later on use the async iterator to read the body chunks
121+
// see https://github.com/microsoft/TypeScript/issues/39051
122+
const originalBody = originalResponse.body as NodeJS.ReadableStream | null;
123+
if (!originalBody) {
124+
return originalResponse;
103125
}
104-
return res;
126+
127+
const newResponseStream = new ReadableStream({
128+
start: async controller => {
129+
for await (const chunk of originalBody) {
130+
const html = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
131+
const modifiedHtml = addMetaTagToHead(html, hub, span);
132+
controller.enqueue(new TextEncoder().encode(modifiedHtml));
133+
}
134+
controller.close();
135+
},
136+
});
137+
138+
return new Response(newResponseStream, originalResponse);
105139
},
106140
);
107141
return res;
@@ -113,6 +147,20 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH
113147
};
114148
};
115149

150+
/**
151+
* This function optimistically assumes that the HTML coming in chunks will not be split
152+
* within the <head> tag. If this still happens, we simply won't replace anything.
153+
*/
154+
function addMetaTagToHead(htmlChunk: string, hub: Hub, span?: Span): string {
155+
if (typeof htmlChunk !== 'string') {
156+
return htmlChunk;
157+
}
158+
159+
const { sentryTrace, baggage } = getTracingMetaTags(span, hub);
160+
const content = `<head>\n${sentryTrace}\n${baggage}\n`;
161+
return htmlChunk.replace('<head>', content);
162+
}
163+
116164
/**
117165
* Interpolates the route from the URL and the passed params.
118166
* Best we can do to get a route name instead of a raw URL.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import * as SentryCore from '@sentry/core';
2+
import { vi } from 'vitest';
3+
4+
import { getTracingMetaTags, isValidBaggageString } from '../../src/server/meta';
5+
6+
const mockedSpan = {
7+
toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1',
8+
transaction: {
9+
getDynamicSamplingContext: () => ({
10+
environment: 'production',
11+
}),
12+
},
13+
};
14+
15+
const mockedHub = {
16+
getScope: () => ({
17+
getPropagationContext: () => ({
18+
traceId: '123',
19+
}),
20+
}),
21+
getClient: () => ({}),
22+
};
23+
24+
describe('getTracingMetaTags', () => {
25+
it('returns the tracing tags from the span, if it is provided', () => {
26+
{
27+
// @ts-expect-error - only passing a partial span object
28+
const tags = getTracingMetaTags(mockedSpan, mockedHub);
29+
30+
expect(tags).toEqual({
31+
sentryTrace: '<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1"/>',
32+
baggage: '<meta name="baggage" content="sentry-environment=production"/>',
33+
});
34+
}
35+
});
36+
37+
it('returns propagationContext DSC data if no span is available', () => {
38+
const tags = getTracingMetaTags(undefined, {
39+
...mockedHub,
40+
// @ts-expect-error - only passing a partial scope object
41+
getScope: () => ({
42+
getPropagationContext: () => ({
43+
traceId: '12345678901234567890123456789012',
44+
sampled: true,
45+
spanId: '1234567890123456',
46+
dsc: {
47+
environment: 'staging',
48+
public_key: 'key',
49+
trace_id: '12345678901234567890123456789012',
50+
},
51+
}),
52+
}),
53+
});
54+
55+
expect(tags).toEqual({
56+
sentryTrace: expect.stringMatching(
57+
/<meta name="sentry-trace" content="12345678901234567890123456789012-(.{16})-1"\/>/,
58+
),
59+
baggage:
60+
'<meta name="baggage" content="sentry-environment=staging,sentry-public_key=key,sentry-trace_id=12345678901234567890123456789012"/>',
61+
});
62+
});
63+
64+
it('returns only the `sentry-trace` tag if no DSC is available', () => {
65+
vi.spyOn(SentryCore, 'getDynamicSamplingContextFromClient').mockReturnValueOnce({
66+
trace_id: '',
67+
public_key: undefined,
68+
});
69+
70+
const tags = getTracingMetaTags(
71+
// @ts-expect-error - only passing a partial span object
72+
{
73+
toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1',
74+
transaction: undefined,
75+
},
76+
mockedHub,
77+
);
78+
79+
expect(tags).toEqual({
80+
sentryTrace: '<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1"/>',
81+
});
82+
});
83+
84+
it('returns only the `sentry-trace` tag if no DSC is available', () => {
85+
vi.spyOn(SentryCore, 'getDynamicSamplingContextFromClient').mockReturnValueOnce({
86+
trace_id: '',
87+
public_key: undefined,
88+
});
89+
90+
const tags = getTracingMetaTags(
91+
// @ts-expect-error - only passing a partial span object
92+
{
93+
toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1',
94+
transaction: undefined,
95+
},
96+
{
97+
...mockedHub,
98+
getClient: () => undefined,
99+
},
100+
);
101+
102+
expect(tags).toEqual({
103+
sentryTrace: '<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1"/>',
104+
});
105+
});
106+
});
107+
108+
describe('isValidBaggageString', () => {
109+
it.each([
110+
'sentry-environment=production',
111+
'sentry-environment=staging,sentry-public_key=key,sentry-trace_id=abc',
112+
// @ is allowed in values
113+
114+
// spaces are allowed around the delimiters
115+
'sentry-environment=staging , sentry-public_key=key ,[email protected]',
116+
'sentry-environment=staging , thirdparty=value ,[email protected]',
117+
// these characters are explicitly allowed for keys in the baggage spec:
118+
"!#$%&'*+-.^_`|~1234567890abcxyzABCXYZ=true",
119+
// special characters in values are fine (except for ",;\ - see other test)
120+
'key=(value)',
121+
'key=[{(value)}]',
122+
'key=some$value',
123+
'key=more#value',
124+
'key=max&value',
125+
'key=max:value',
126+
'key=x=value',
127+
])('returns true if the baggage string is valid (%s)', baggageString => {
128+
expect(isValidBaggageString(baggageString)).toBe(true);
129+
});
130+
131+
it.each([
132+
// baggage spec doesn't permit leading spaces
133+
' sentry-environment=production,sentry-publickey=key,sentry-trace_id=abc',
134+
// no spaces in keys or values
135+
'sentry-public key=key',
136+
'sentry-publickey=my key',
137+
// no delimiters ("(),/:;<=>?@[\]{}") in keys
138+
'asdf(x=value',
139+
'asdf)x=value',
140+
'asdf,x=value',
141+
'asdf/x=value',
142+
'asdf:x=value',
143+
'asdf;x=value',
144+
'asdf<x=value',
145+
'asdf>x=value',
146+
'asdf?x=value',
147+
'asdf@x=value',
148+
'asdf[x=value',
149+
'asdf]x=value',
150+
'asdf\\x=value',
151+
'asdf{x=value',
152+
'asdf}x=value',
153+
// no ,;\" in values
154+
'key=va,lue',
155+
'key=va;lue',
156+
'key=va\\lue',
157+
'key=va"lue"',
158+
// baggage headers can have properties but we currently don't support them
159+
'sentry-environment=production;prop1=foo;prop2=bar,nextkey=value',
160+
// no fishy stuff
161+
'absolutely not a valid baggage string',
162+
'val"/><script>alert("xss")</script>',
163+
'something"/>',
164+
'<script>alert("xss")</script>',
165+
'/>',
166+
'" onblur="alert("xss")',
167+
])('returns false if the baggage string is invalid (%s)', baggageString => {
168+
expect(isValidBaggageString(baggageString)).toBe(false);
169+
});
170+
171+
it('returns false if the baggage string is empty', () => {
172+
expect(isValidBaggageString('')).toBe(false);
173+
});
174+
175+
it('returns false if the baggage string is empty', () => {
176+
expect(isValidBaggageString(undefined)).toBe(false);
177+
});
178+
});

0 commit comments

Comments
 (0)