Skip to content

Commit 8e55979

Browse files
committed
Fix memory leak and race condition
1 parent 2ff42a6 commit 8e55979

File tree

6 files changed

+905
-23
lines changed

6 files changed

+905
-23
lines changed

dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ function handleBotRequest(
5555

5656
responseHeaders.set('Content-Type', 'text/html');
5757

58-
// Server-Timing header is automatically injected by Sentry SDK
59-
// via wrapRequestHandler in instrumentServer.ts
60-
6158
resolve(
6259
new Response(stream, {
6360
headers: responseHeaders,
@@ -104,9 +101,6 @@ function handleBrowserRequest(
104101

105102
responseHeaders.set('Content-Type', 'text/html');
106103

107-
// Server-Timing header is automatically injected by Sentry SDK
108-
// via wrapRequestHandler in instrumentServer.ts
109-
110104
resolve(
111105
new Response(stream, {
112106
headers: responseHeaders,

packages/remix/src/client/performance.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,16 @@ function getTransactionNameAndSource(
9090
return { name: routeId, source: 'route' };
9191
}
9292

93+
// Flag to prevent async callback from creating a span after navigation has occurred
94+
let pageloadSpanStarted = false;
95+
9396
export function startPageloadSpan(client: Client): void {
9497
const initPathName = getInitPathName();
9598

9699
if (!initPathName) {
97100
return;
98101
}
99102

100-
// Try to parameterize the route using the route manifest
101103
const parameterizedRoute = maybeParameterizeRemixRoute(initPathName);
102104
const spanName = parameterizedRoute || initPathName;
103105
const source = parameterizedRoute ? 'route' : 'url';
@@ -111,22 +113,27 @@ export function startPageloadSpan(client: Client): void {
111113
},
112114
};
113115

114-
// Try meta tags first (contains loader span ID for precise parent linking)
116+
// Priority: meta tags > Server-Timing header > async retry
115117
const metaTagTrace = getMetaTagTraceContext();
116118
if (metaTagTrace) {
119+
pageloadSpanStarted = true;
117120
startBrowserTracingPageLoadSpan(client, spanContext, metaTagTrace);
118121
return;
119122
}
120123

121-
// Fall back to Server-Timing header
122124
const serverTimingTrace = getNavigationTraceContext();
123125
if (serverTimingTrace) {
126+
pageloadSpanStarted = true;
124127
startBrowserTracingPageLoadSpan(client, spanContext, serverTimingTrace);
125128
return;
126129
}
127130

128-
// Async retry for slow header processing
129131
getNavigationTraceContextAsync(trace => {
132+
// Skip if pageload span was already started (e.g., navigation occurred during retry)
133+
if (pageloadSpanStarted) {
134+
return;
135+
}
136+
pageloadSpanStarted = true;
130137
startBrowserTracingPageLoadSpan(client, spanContext, trace ?? undefined);
131138
});
132139
}
@@ -223,6 +230,9 @@ export function withSentry<P extends Record<string, unknown>, R extends React.Co
223230
}
224231

225232
if (_instrumentNavigation && matches?.length) {
233+
// Mark pageload as started to prevent async callback from firing after navigation
234+
pageloadSpanStarted = true;
235+
226236
if (activeRootSpan) {
227237
activeRootSpan.end();
228238
}

packages/remix/src/client/serverTimingTracePropagation.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ type NavigationTraceResult =
1212
| { status: 'unavailable' }
1313
| { status: 'available'; data: ServerTimingTraceContext };
1414

15-
// Cache for navigation trace to avoid repeated parsing
15+
/**
16+
* Cache for navigation trace context.
17+
* - undefined: Not yet attempted to retrieve
18+
* - null: Attempted but unavailable (no Server-Timing data or API not supported)
19+
* - ServerTimingTraceContext: Successfully retrieved trace context
20+
*/
1621
let navigationTraceCache: ServerTimingTraceContext | null | undefined;
1722

18-
// 40 attempts × 50ms = 2 seconds max wait for Performance API to process navigation entries
19-
// Aligned with react-router's hydration polling pattern (see hydratedRouter.ts)
2023
const MAX_RETRY_ATTEMPTS = 40;
2124
const RETRY_INTERVAL_MS = 50;
2225

@@ -45,6 +48,11 @@ export function isServerTimingSupported(): boolean {
4548
}
4649
}
4750

51+
/**
52+
* Parses Server-Timing header entries to extract Sentry trace context.
53+
* Expects entries with names 'sentry-trace' and 'baggage'.
54+
* Baggage is URL-decoded as it's encoded in the Server-Timing header.
55+
*/
4856
function parseServerTimingTrace(serverTiming: readonly PerformanceServerTiming[]): ServerTimingTraceContext | null {
4957
let sentryTrace = '';
5058
let baggage = '';
@@ -75,6 +83,14 @@ function parseServerTimingTrace(serverTiming: readonly PerformanceServerTiming[]
7583
return { sentryTrace, baggage };
7684
}
7785

86+
/**
87+
* Attempts to retrieve trace context from the navigation performance entry.
88+
*
89+
* @returns
90+
* - `{ status: 'available', data }` - Trace context successfully retrieved
91+
* - `{ status: 'pending' }` - Headers not yet processed (responseStart === 0), retry recommended
92+
* - `{ status: 'unavailable' }` - No Server-Timing data available, don't retry
93+
*/
7894
function tryGetNavigationTraceContext(): NavigationTraceResult {
7995
try {
8096
const navEntries = WINDOW.performance.getEntriesByType('navigation');
@@ -137,27 +153,39 @@ export function getNavigationTraceContext(): ServerTimingTraceContext | null {
137153
/**
138154
* Get trace context from navigation with retry mechanism.
139155
* Useful during SDK init when browser may not have finished processing headers.
156+
*
157+
* @returns Cleanup function to cancel pending retries (e.g., on navigation)
140158
*/
141159
export function getNavigationTraceContextAsync(
142160
callback: (trace: ServerTimingTraceContext | null) => void,
143161
maxAttempts: number = MAX_RETRY_ATTEMPTS,
144162
delayMs: number = RETRY_INTERVAL_MS,
145-
): void {
163+
): () => void {
164+
const state = { cancelled: false };
165+
146166
if (navigationTraceCache !== undefined) {
147167
callback(navigationTraceCache);
148-
return;
168+
return () => {
169+
state.cancelled = true;
170+
};
149171
}
150172

151173
if (!isServerTimingSupported()) {
152174
DEBUG_BUILD && debug.log('[Server-Timing] Server-Timing API not supported');
153175
navigationTraceCache = null;
154176
callback(null);
155-
return;
177+
return () => {
178+
state.cancelled = true;
179+
};
156180
}
157181

158182
let attempts = 0;
159183

160184
const tryGet = (): void => {
185+
if (state.cancelled) {
186+
return;
187+
}
188+
161189
attempts++;
162190
const result = tryGetNavigationTraceContext();
163191

@@ -171,7 +199,7 @@ export function getNavigationTraceContextAsync(
171199
setTimeout(tryGet, delayMs);
172200
return;
173201
}
174-
DEBUG_BUILD && debug.log('[Server-Timing] Max retry attempts reached');
202+
DEBUG_BUILD && debug.warn('[Server-Timing] Max retry attempts reached, trace context unavailable');
175203
navigationTraceCache = null;
176204
callback(null);
177205
return;
@@ -182,10 +210,14 @@ export function getNavigationTraceContextAsync(
182210
};
183211

184212
tryGet();
213+
214+
return () => {
215+
state.cancelled = true;
216+
};
185217
}
186218

187219
/**
188-
* Get trace context from meta tags (fallback for older browsers).
220+
* Get trace context from meta tags.
189221
* Looks for `<meta name="sentry-trace">` and `<meta name="baggage">` tags.
190222
*/
191223
export function getMetaTagTraceContext(): ServerTimingTraceContext | null {

packages/remix/src/server/instrumentServer.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,30 +104,32 @@ export function wrapHandleErrorWithSentry(
104104
};
105105
}
106106

107+
/**
108+
* Get trace context for injection into loader response data.
109+
* Prioritizes active span context to ensure client pageload continues from the loader span,
110+
* not the http.server span, enabling proper trace continuity via Server-Timing headers.
111+
*/
107112
function getTraceAndBaggage(): {
108113
sentryTrace?: string;
109114
sentryBaggage?: string;
110115
} {
111116
if (isNodeEnv() || isCloudflareEnv()) {
112-
// Use activeSpan (loader) so client's pageload parent is the loader, not http.server
113117
const activeSpan = getActiveSpan();
114118
if (activeSpan) {
115119
const sentryTrace = spanToTraceHeader(activeSpan);
116120
if (sentryTrace) {
117121
return {
118122
sentryTrace,
119-
sentryBaggage: spanToBaggageHeader(activeSpan) || '',
123+
sentryBaggage: spanToBaggageHeader(activeSpan),
120124
};
121125
}
122126
}
123127

124-
// Fall back to scope's propagation context
125128
const scope = getCurrentScope();
126129
const propagationContext = scope.getPropagationContext();
127130
const traceData = getTraceData();
128131
const spanId = propagationContext.propagationSpanId ?? propagationContext.parentSpanId;
129132

130-
// Only generate if we have valid IDs to avoid "traceid-undefined-1" format
131133
if (propagationContext.traceId && spanId) {
132134
const fallbackTrace = generateSentryTraceHeader(propagationContext.traceId, spanId, propagationContext.sampled);
133135
DEBUG_BUILD && debug.log('[getTraceAndBaggage] Falling back to propagation context:', fallbackTrace);
@@ -381,10 +383,12 @@ function wrapRequestHandler<T extends ServerBuild | (() => ServerBuild | Promise
381383
return (await origRequestHandler.call(this, request, loadContext)) as Response;
382384
}
383385

386+
// We update the existing http.server span (created by OTel) with Remix-specific
387+
// attributes rather than creating a nested child span. This ensures proper trace
388+
// hierarchy where the http.server span is the parent of loader/action spans.
384389
const handleRequest = async (): Promise<Response> => {
385390
const res = (await origRequestHandler.call(this, request, loadContext)) as Response;
386391

387-
// Span may be null if OTel has already ended it
388392
const activeSpan = getActiveSpan();
389393
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
390394

@@ -410,6 +414,8 @@ function wrapRequestHandler<T extends ServerBuild | (() => ServerBuild | Promise
410414
return res;
411415
};
412416

417+
// Only continue trace if there's an incoming sentry-trace header.
418+
// Otherwise, start a fresh trace in the isolation scope.
413419
if (sentryTrace) {
414420
return continueTrace({ sentryTrace, baggage: baggage || '' }, handleRequest);
415421
}

0 commit comments

Comments
 (0)