Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0403684
feat(remix): Server Timing Headers Trace Propagation PoC
onurtemizkan Jan 1, 2026
cb0d98f
Reduce duplication
onurtemizkan Jan 2, 2026
04e1662
Enhance trace context handling with status management
onurtemizkan Jan 2, 2026
ccbb500
Fix memory leak and race condition
onurtemizkan Jan 2, 2026
17322a6
Improve dynamic sampling context and baggage consistency
onurtemizkan Jan 2, 2026
e17f237
Enhance e2e tests
onurtemizkan Jan 3, 2026
6ed2bb0
Add dynamic sampling context handling to server timing tests
onurtemizkan Jan 5, 2026
2d76278
Prioritize Server-Timing headers over meta tags
onurtemizkan Jan 5, 2026
aa22331
Update integration tests
onurtemizkan Jan 7, 2026
b455ccf
Use quoted-string escaping instead of URL-encoding
onurtemizkan Jan 7, 2026
5deb1cf
Fix race condition and improve navigation state management
onurtemizkan Jan 7, 2026
2324526
Update E2E tests for Server-Timing header trace propagation
onurtemizkan Jan 8, 2026
8f8a88e
Fix race condition with shared variables
onurtemizkan Jan 8, 2026
3dd1901
Simplify Server-Timing header baggage generation using spanToBaggageH…
onurtemizkan Jan 15, 2026
7832807
Make Server-Timing E2E tests fail when headers are missing
onurtemizkan Jan 19, 2026
54bbbcf
Update Remix dependencies to 2.17.4 to fix security vulnerabilities
onurtemizkan Jan 19, 2026
1f7c85c
Inject Server-Timing header on redirect responses
onurtemizkan Jan 22, 2026
3f48804
Remove custom client-side Server-Timing trace propagation in favor of…
onurtemizkan Feb 3, 2026
ebf2eca
Always call continueTrace
onurtemizkan Feb 3, 2026
488415f
Merge branch 'develop' into onur/remix-server-timing-headers
onurtemizkan Feb 3, 2026
25fa718
Skip Server-Timing header injection when sentry-trace already exists
onurtemizkan Feb 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,30 +44,30 @@ test('Sends a navigation transaction with parameterized route to Sentry', async
expect(transactionEvent.transaction).toBeTruthy();
});

test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
test('Server-Timing header contains sentry-trace and baggage for the root route', async ({ page }) => {
const responsePromise = page.waitForResponse(response => response.url().endsWith('/') && response.status() === 200);

await page.goto('/');

const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
state: 'attached',
});
const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
state: 'attached',
});
const response = await responsePromise;
const serverTimingHeader = response.headers()['server-timing'];

expect(sentryTraceMetaTag).toBeTruthy();
expect(baggageMetaTag).toBeTruthy();
expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');
expect(serverTimingHeader).toContain('baggage');
});

test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => {
test('Server-Timing header contains sentry-trace and baggage for a sub-route', async ({ page }) => {
const responsePromise = page.waitForResponse(
response => response.url().includes('/user/123') && response.status() === 200,
);

await page.goto('/user/123');

const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
state: 'attached',
});
const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
state: 'attached',
});
const response = await responsePromise;
const serverTimingHeader = response.headers()['server-timing'];

expect(sentryTraceMetaTag).toBeTruthy();
expect(baggageMetaTag).toBeTruthy();
expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');
expect(serverTimingHeader).toContain('baggage');
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,6 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
expect(httpServerSpanId).toBeDefined();

expect(pageLoadTraceId).toEqual(httpServerTraceId);
expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,224 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => {
expect(transactionEvent).toBeDefined();
});

test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
test('Server-Timing header contains sentry-trace and baggage for the root route', async ({ page }) => {
const responsePromise = page.waitForResponse(response => response.url().endsWith('/') && response.status() === 200);

await page.goto('/');

const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
state: 'attached',
});
const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
state: 'attached',
});
const response = await responsePromise;
const serverTimingHeader = response.headers()['server-timing'];

expect(sentryTraceMetaTag).toBeTruthy();
expect(baggageMetaTag).toBeTruthy();
expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');
expect(serverTimingHeader).toContain('baggage');
});

test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => {
test('Server-Timing header contains sentry-trace and baggage for a sub-route', async ({ page }) => {
const responsePromise = page.waitForResponse(
response => response.url().includes('/user/123') && response.status() === 200,
);

await page.goto('/user/123');

const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
state: 'attached',
const response = await responsePromise;
const serverTimingHeader = response.headers()['server-timing'];

expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');
expect(serverTimingHeader).toContain('baggage');
});

// =============================================================================
// META TAG FALLBACK TESTS
// Testing fallback for browsers without Server-Timing support (e.g., Safari < 16.4)
//
// These tests simulate a scenario where:
// 1. The server injects trace context via meta tags (like older Remix setups or non-Node environments)
// 2. The browser doesn't support the Server-Timing API (Safari < 16.4)
//
// We achieve this by:
// 1. Intercepting responses and injecting meta tags with trace data from Server-Timing header
// 2. Disabling the Server-Timing API via page.addInitScript()
// =============================================================================

test.describe('Meta tag fallback for browsers without Server-Timing support', () => {
test.use({
// Emulate Safari 15.6.1 which doesn't support Server-Timing on PerformanceNavigationTiming
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15',
});
const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
state: 'attached',

test('Server-Timing and meta tag fallback provide consistent trace context', async ({ page }) => {
// This test verifies that when we inject meta tags with trace context from Server-Timing,
// both sources contain consistent trace context that can be used for trace propagation.
//
// The test simulates a scenario where:
// 1. Server sends trace context via Server-Timing header
// 2. We also inject meta tags with the same trace context (as a fallback would)
// 3. Both should contain the same trace ID and span ID

let capturedSentryTrace: string | null = null;

// Intercept responses to inject meta tags (simulating a server that uses meta tags as fallback)
await page.route('**/*', async route => {
const response = await route.fetch();
const contentType = response.headers()['content-type'] || '';

// Only modify HTML responses
if (contentType.includes('text/html')) {
const serverTimingHeader = response.headers()['server-timing'];
let body = await response.text();

if (serverTimingHeader) {
// Parse sentry-trace from Server-Timing header
const sentryTraceMatch = serverTimingHeader.match(/sentry-trace;desc="([^"]+)"/);
const baggageMatch = serverTimingHeader.match(/baggage;desc="([^"]+)"/);

if (sentryTraceMatch?.[1]) {
const sentryTrace = sentryTraceMatch[1];
capturedSentryTrace = sentryTrace;
// Unescape baggage (it's escaped for quoted-string context)
const baggage = baggageMatch?.[1] ? baggageMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : '';

// Inject meta tags right after <head>
const metaTags = `<meta name="sentry-trace" content="${sentryTrace}"><meta name="baggage" content="${baggage}">`;
body = body.replace(/<head[^>]*>/, match => match + metaTags);
}
}

await route.fulfill({
response,
body,
headers: {
...response.headers(),
'content-length': String(Buffer.byteLength(body)),
},
});
} else {
await route.continue();
}
});

const testTag = crypto.randomUUID();

const responsePromise = page.waitForResponse(
response => response.url().includes(`tag=${testTag}`) && response.status() === 200,
);

await page.goto(`/?tag=${testTag}`);

const response = await responsePromise;

// Verify Server-Timing header contains trace data
const serverTimingHeader = response.headers()['server-timing'];
expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');

// Verify we captured the trace from the header
expect(capturedSentryTrace).toBeTruthy();

// Verify the sentry-trace format: traceId-spanId-sampled
// Using non-null assertion since we just verified it's truthy above
const parts = capturedSentryTrace!.split('-');
expect(parts).toHaveLength(3);
expect(parts[0]).toHaveLength(32); // traceId
expect(parts[1]).toHaveLength(16); // spanId
expect(['0', '1']).toContain(parts[2]); // sampled flag
});

expect(sentryTraceMetaTag).toBeTruthy();
expect(baggageMetaTag).toBeTruthy();
test('Meta tag trace data matches server trace context', async ({ page }) => {
// Same setup as above - disable Server-Timing API
await page.addInitScript(() => {
const originalGetEntriesByType = Performance.prototype.getEntriesByType;
Performance.prototype.getEntriesByType = function (type: string) {
const entries = originalGetEntriesByType.call(this, type);
if (type === 'navigation') {
return entries.map((entry: PerformanceEntry) => {
return new Proxy(entry, {
has(target, prop) {
if (prop === 'serverTiming') return false;
return prop in target;
},
get(target, prop, receiver) {
if (prop === 'serverTiming') return undefined;
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
});
}
return entries;
};
});

// Intercept responses to inject meta tags (simulating a server that uses meta tags)
await page.route('**/*', async route => {
const response = await route.fetch();
const contentType = response.headers()['content-type'] || '';

// Only modify HTML responses
if (contentType.includes('text/html')) {
const serverTimingHeader = response.headers()['server-timing'];
let body = await response.text();

if (serverTimingHeader) {
// Parse sentry-trace from Server-Timing header
const sentryTraceMatch = serverTimingHeader.match(/sentry-trace;desc="([^"]+)"/);
const baggageMatch = serverTimingHeader.match(/baggage;desc="([^"]+)"/);

if (sentryTraceMatch?.[1]) {
const sentryTrace = sentryTraceMatch[1];
// Unescape baggage (it's escaped for quoted-string context)
const baggage = baggageMatch?.[1] ? baggageMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : '';

// Inject meta tags right after <head>
const metaTags = `<meta name="sentry-trace" content="${sentryTrace}"><meta name="baggage" content="${baggage}">`;
body = body.replace(/<head[^>]*>/, match => match + metaTags);
}
}

await route.fulfill({
response,
body,
headers: {
...response.headers(),
'content-length': String(Buffer.byteLength(body)),
},
});
} else {
await route.continue();
}
});

const testTag = crypto.randomUUID();

const responsePromise = page.waitForResponse(
response => response.url().includes(`tag=${testTag}`) && response.status() === 200,
);

await page.goto(`/?tag=${testTag}`);

const response = await responsePromise;

// Server-Timing header should still be present (server doesn't know client capability)
const serverTimingHeader = response.headers()['server-timing'];
expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');

// Extract trace ID from Server-Timing header
const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
const [headerTraceId, headerSpanId] = sentryTraceMatch?.[1]?.split('-') || [];

// Extract trace ID from meta tag (which we injected)
// We use [content] selector to get the meta tag with content (the one we injected)
const metaTraceContent = await page.locator('meta[name="sentry-trace"][content]').getAttribute('content');
const [metaTraceId, metaSpanId] = metaTraceContent?.split('-') || [];

// Both should have the same trace context (from the same server request)
expect(headerTraceId).toHaveLength(32);
expect(metaTraceId).toHaveLength(32);
expect(headerTraceId).toEqual(metaTraceId);
expect(headerSpanId).toEqual(metaSpanId);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
expect(httpServerSpanId).toBeDefined();

expect(pageLoadTraceId).toEqual(httpServerTraceId);
expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});

Expand Down Expand Up @@ -139,6 +139,6 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page

expect(loaderParentSpanId).toEqual(httpServerSpanId);
expect(pageLoadTraceId).toEqual(httpServerTraceId);
expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,30 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => {
expect(transactionEvent).toBeDefined();
});

test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
test('Server-Timing header contains sentry-trace and baggage for the root route', async ({ page }) => {
const responsePromise = page.waitForResponse(response => response.url().endsWith('/') && response.status() === 200);

await page.goto('/');

const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
state: 'attached',
});
const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
state: 'attached',
});
const response = await responsePromise;
const serverTimingHeader = response.headers()['server-timing'];

expect(sentryTraceMetaTag).toBeTruthy();
expect(baggageMetaTag).toBeTruthy();
expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');
expect(serverTimingHeader).toContain('baggage');
});

test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => {
test('Server-Timing header contains sentry-trace and baggage for a sub-route', async ({ page }) => {
const responsePromise = page.waitForResponse(
response => response.url().includes('/user/123') && response.status() === 200,
);

await page.goto('/user/123');

const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
state: 'attached',
});
const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
state: 'attached',
});
const response = await responsePromise;
const serverTimingHeader = response.headers()['server-timing'];

expect(sentryTraceMetaTag).toBeTruthy();
expect(baggageMetaTag).toBeTruthy();
expect(serverTimingHeader).toBeDefined();
expect(serverTimingHeader).toContain('sentry-trace');
expect(serverTimingHeader).toContain('baggage');
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
expect(httpServerSpanId).toBeDefined();

expect(pageLoadTraceId).toEqual(httpServerTraceId);
expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});
Loading
Loading