Skip to content

Commit

Permalink
feat(astro): Add distributed tracing via <meta> tags (#9483)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Lms24 committed Nov 9, 2023
1 parent ab39b26 commit 4fc2e7e
Show file tree
Hide file tree
Showing 4 changed files with 408 additions and 8 deletions.
78 changes: 78 additions & 0 deletions packages/astro/src/server/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { getDynamicSamplingContextFromClient } from '@sentry/core';
import type { Hub, Span } from '@sentry/types';
import {
dynamicSamplingContextToSentryBaggageHeader,
generateSentryTraceHeader,
logger,
TRACEPARENT_REGEXP,
} from '@sentry/utils';

/**
* Extracts the tracing data from the current span or from the client's scope
* (via transaction or propagation context) and renders the data to <meta> tags.
*
* This function creates two serialized <meta> tags:
* - `<meta name="sentry-trace" content="..."/>`
* - `<meta name="baggage" content="..."/>`
*
* TODO: Extract this later on and export it from the Core or Node SDK
*
* @param span the currently active span
* @param client the SDK's client
*
* @returns an object with the two serialized <meta> tags
*/
export function getTracingMetaTags(span: Span | undefined, hub: Hub): { sentryTrace: string; baggage?: string } {
const scope = hub.getScope();
const client = hub.getClient();
const { dsc, sampled, traceId } = scope.getPropagationContext();
const transaction = span?.transaction;

const sentryTrace = span ? span.toTraceparent() : generateSentryTraceHeader(traceId, undefined, sampled);

const dynamicSamplingContext = transaction
? transaction.getDynamicSamplingContext()
: dsc
? dsc
: client
? getDynamicSamplingContextFromClient(traceId, client, scope)
: undefined;

const baggage = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext);

const isValidSentryTraceHeader = TRACEPARENT_REGEXP.test(sentryTrace);
if (!isValidSentryTraceHeader) {
logger.warn('Invalid sentry-trace data. Returning empty <meta name="sentry-trace"/> tag');
}

const validBaggage = isValidBaggageString(baggage);
if (!validBaggage) {
logger.warn('Invalid baggage data. Returning empty <meta name="baggage"/> tag');
}

return {
sentryTrace: `<meta name="sentry-trace" content="${isValidSentryTraceHeader ? sentryTrace : ''}"/>`,
baggage: baggage && `<meta name="baggage" content="${validBaggage ? baggage : ''}"/>`,
};
}

/**
* Tests string against baggage spec as defined in:
*
* - W3C Baggage grammar: https://www.w3.org/TR/baggage/#definition
* - RFC7230 token definition: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
*
* exported for testing
*/
export function isValidBaggageString(baggage?: string): boolean {
if (!baggage || !baggage.length) {
return false;
}
const keyRegex = "[-!#$%&'*+.^_`|~A-Za-z0-9]+";
const valueRegex = '[!#-+-./0-9:<=>?@A-Z\\[\\]a-z{-}]+';
const spaces = '\\s*';
const baggageRegex = new RegExp(
`^${keyRegex}${spaces}=${spaces}${valueRegex}(${spaces},${spaces}${keyRegex}${spaces}=${spaces}${valueRegex})*$`,
);
return baggageRegex.test(baggage);
}
58 changes: 53 additions & 5 deletions packages/astro/src/server/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { captureException, configureScope, startSpan } from '@sentry/node';
import { captureException, configureScope, getCurrentHub, startSpan } from '@sentry/node';
import type { Hub, Span } from '@sentry/types';
import { addExceptionMechanism, objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils';
import type { APIContext, MiddlewareResponseHandler } from 'astro';

import { getTracingMetaTags } from './meta';

type MiddlewareOptions = {
/**
* If true, the client IP will be attached to the event by calling `setUser`.
Expand Down Expand Up @@ -97,11 +100,42 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH
},
},
async span => {
const res = await next();
if (span && res.status) {
span.setHttpStatus(res.status);
const originalResponse = await next();

if (span && originalResponse.status) {
span.setHttpStatus(originalResponse.status);
}

const hub = getCurrentHub();
const client = hub.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;
}
return res;

const newResponseStream = new ReadableStream({
start: async controller => {
for await (const chunk of originalBody) {
const html = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
const modifiedHtml = addMetaTagToHead(html, hub, span);
controller.enqueue(new TextEncoder().encode(modifiedHtml));
}
controller.close();
},
});

return new Response(newResponseStream, originalResponse);
},
);
return res;
Expand All @@ -113,6 +147,20 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseH
};
};

/**
* 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, hub: Hub, span?: Span): string {
if (typeof htmlChunk !== 'string') {
return htmlChunk;
}

const { sentryTrace, baggage } = getTracingMetaTags(span, hub);
const content = `<head>\n${sentryTrace}\n${baggage}\n`;
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.
Expand Down
178 changes: 178 additions & 0 deletions packages/astro/test/server/meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import * as SentryCore from '@sentry/core';
import { vi } from 'vitest';

import { getTracingMetaTags, isValidBaggageString } from '../../src/server/meta';

const mockedSpan = {
toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1',
transaction: {
getDynamicSamplingContext: () => ({
environment: 'production',
}),
},
};

const mockedHub = {
getScope: () => ({
getPropagationContext: () => ({
traceId: '123',
}),
}),
getClient: () => ({}),
};

describe('getTracingMetaTags', () => {
it('returns the tracing tags from the span, if it is provided', () => {
{
// @ts-expect-error - only passing a partial span object
const tags = getTracingMetaTags(mockedSpan, mockedHub);

expect(tags).toEqual({
sentryTrace: '<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1"/>',
baggage: '<meta name="baggage" content="sentry-environment=production"/>',
});
}
});

it('returns propagationContext DSC data if no span is available', () => {
const tags = getTracingMetaTags(undefined, {
...mockedHub,
// @ts-expect-error - only passing a partial scope object
getScope: () => ({
getPropagationContext: () => ({
traceId: '12345678901234567890123456789012',
sampled: true,
spanId: '1234567890123456',
dsc: {
environment: 'staging',
public_key: 'key',
trace_id: '12345678901234567890123456789012',
},
}),
}),
});

expect(tags).toEqual({
sentryTrace: expect.stringMatching(
/<meta name="sentry-trace" content="12345678901234567890123456789012-(.{16})-1"\/>/,
),
baggage:
'<meta name="baggage" content="sentry-environment=staging,sentry-public_key=key,sentry-trace_id=12345678901234567890123456789012"/>',
});
});

it('returns only the `sentry-trace` tag if no DSC is available', () => {
vi.spyOn(SentryCore, 'getDynamicSamplingContextFromClient').mockReturnValueOnce({
trace_id: '',
public_key: undefined,
});

const tags = getTracingMetaTags(
// @ts-expect-error - only passing a partial span object
{
toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1',
transaction: undefined,
},
mockedHub,
);

expect(tags).toEqual({
sentryTrace: '<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1"/>',
});
});

it('returns only the `sentry-trace` tag if no DSC is available', () => {
vi.spyOn(SentryCore, 'getDynamicSamplingContextFromClient').mockReturnValueOnce({
trace_id: '',
public_key: undefined,
});

const tags = getTracingMetaTags(
// @ts-expect-error - only passing a partial span object
{
toTraceparent: () => '12345678901234567890123456789012-1234567890123456-1',
transaction: undefined,
},
{
...mockedHub,
getClient: () => undefined,
},
);

expect(tags).toEqual({
sentryTrace: '<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1"/>',
});
});
});

describe('isValidBaggageString', () => {
it.each([
'sentry-environment=production',
'sentry-environment=staging,sentry-public_key=key,sentry-trace_id=abc',
// @ is allowed in values
'sentry-release=project@1.0.0',
// spaces are allowed around the delimiters
'sentry-environment=staging , sentry-public_key=key ,sentry-release=myproject@1.0.0',
'sentry-environment=staging , thirdparty=value ,sentry-release=myproject@1.0.0',
// these characters are explicitly allowed for keys in the baggage spec:
"!#$%&'*+-.^_`|~1234567890abcxyzABCXYZ=true",
// special characters in values are fine (except for ",;\ - see other test)
'key=(value)',
'key=[{(value)}]',
'key=some$value',
'key=more#value',
'key=max&value',
'key=max:value',
'key=x=value',
])('returns true if the baggage string is valid (%s)', baggageString => {
expect(isValidBaggageString(baggageString)).toBe(true);
});

it.each([
// baggage spec doesn't permit leading spaces
' sentry-environment=production,sentry-publickey=key,sentry-trace_id=abc',
// no spaces in keys or values
'sentry-public key=key',
'sentry-publickey=my key',
// no delimiters ("(),/:;<=>?@[\]{}") in keys
'asdf(x=value',
'asdf)x=value',
'asdf,x=value',
'asdf/x=value',
'asdf:x=value',
'asdf;x=value',
'asdf<x=value',
'asdf>x=value',
'asdf?x=value',
'asdf@x=value',
'asdf[x=value',
'asdf]x=value',
'asdf\\x=value',
'asdf{x=value',
'asdf}x=value',
// no ,;\" in values
'key=va,lue',
'key=va;lue',
'key=va\\lue',
'key=va"lue"',
// baggage headers can have properties but we currently don't support them
'sentry-environment=production;prop1=foo;prop2=bar,nextkey=value',
// no fishy stuff
'absolutely not a valid baggage string',
'val"/><script>alert("xss")</script>',
'something"/>',
'<script>alert("xss")</script>',
'/>',
'" onblur="alert("xss")',
])('returns false if the baggage string is invalid (%s)', baggageString => {
expect(isValidBaggageString(baggageString)).toBe(false);
});

it('returns false if the baggage string is empty', () => {
expect(isValidBaggageString('')).toBe(false);
});

it('returns false if the baggage string is empty', () => {
expect(isValidBaggageString(undefined)).toBe(false);
});
});

0 comments on commit 4fc2e7e

Please sign in to comment.