Skip to content

Commit 0eff054

Browse files
Vadman97claude
andauthored
fix: flush OTEL spans on page unload via keepalive fetch (#483)
## Summary https://www.loom.com/share/77ebc3923ab24064a987659daf1d338e - Listen on `visibilitychange: hidden` and `pagehide`; `forceFlush()` both tracer and meter providers - Route the unload flush through `fetch({ keepalive: true })` (sendBeacon fallback) so the request survives the navigation — XHR gets canceled on unload - Drop `scheduledDelayMillis` from 30s to 5s (OTEL browser default), reducing the window where pending spans are exposed to an unload ## Context `BatchSpanProcessor` was buffering spans for up to 30s with no flush on navigation, and the XHR transport (chosen deliberately via `headers: {}` to work around an old sendBeacon stall) gets canceled by the browser when the page unloads. For client-side navigations — notably Next.js Server Actions that redirect — the span for the action POST itself plus anything else in the batch never reach the backend. Seen on a customer site: form submit triggers a fetch POST with `next-action` + `traceparent` headers (so the fetch instrumentation is capturing it fine), server returns a redirect directive, Next.js navigates, and no `/v1/traces` request fires before the tab tears down. The keepalive path is used only on unload; normal operation keeps the existing XHR-retry transport. ## Test plan works with redirect <img width="3170" height="1281" alt="image" src="https://github.com/user-attachments/assets/0ab117e6-aa6b-4367-9398-79977a09a5e3" /> - [x] `yarn turbo run build --filter highlight.run --filter '@launchdarkly/*'` - [x] `yarn turbo run test --filter highlight.run` (387/387 pass) - [x] `yarn turbo run enforce-size` (165 kB brotlied, limit 256 kB) - [x] `yarn format-check` - [ ] Verify against the live customer repro (Milan Laser) that `/v1/traces` POST lands after the form submit → redirect - [ ] Smoke test on the Next.js e2e app 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core telemetry export/flush behavior and changes transport semantics during unload, which could affect data delivery or error handling across browsers despite being gated to unload-time paths and covered by tests. > > **Overview** > Ensures browser OTEL traces/metrics are flushed on page unload by triggering `forceFlush()` on `pagehide` and `visibilitychange: hidden`, and routing that unload-time export through `fetch(..., { keepalive: true })` (with `sendBeacon` fallback) instead of the existing XHR-based transport. > > Adds unload-mode toggling/reset (including bfcache restore), allows the span processor to skip waiting on pending async response-body reads during unload flushes, and reduces batch scheduling delay to 5s to shrink the window for losing buffered spans. Includes new unit tests for unload flushing and exporter keepalive behavior, plus a React Router e2e/manual test page wired at `/flush-on-unload`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f81c59e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e2c1735 commit 0eff054

11 files changed

Lines changed: 807 additions & 6 deletions

File tree

e2e/react-router/src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Root from './routes/root'
1212
import Welcome from './routes/welcome'
1313
import PrivacyDemo from './routes/privacy-demo'
1414
import HttpTest from './routes/http-test'
15+
import FlushOnUnload from './routes/flush-on-unload'
1516
import LDClientPage, {
1617
LDClientPageA,
1718
LDClientPageB,
@@ -65,6 +66,7 @@ const router = createBrowserRouter(
6566
<Route path={'/welcome'} element={<Welcome />} />
6667
<Route path={'/privacy'} element={<PrivacyDemo />} />
6768
<Route path={'/http-test'} element={<HttpTest />} />
69+
<Route path={'/flush-on-unload'} element={<FlushOnUnload />} />
6870
<Route path={'/ldclient'} element={<LDClientPage />}>
6971
<Route path="page-a" element={<LDClientPageA />} />
7072
<Route path="page-b" element={<LDClientPageB />} />
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { LDObserve } from '@launchdarkly/observability'
2+
import { useEffect, useState } from 'react'
3+
4+
// Manual-test scaffolding for the "flush OTEL spans on page unload" fix.
5+
// Open DevTools → Network and filter for `/v1/traces`. With the fix in place,
6+
// every button below should produce a POST that shows up as `Type: fetch`
7+
// with the request staying in flight (keepalive) through the navigation /
8+
// visibility change. Before the fix, the batched spans sat in memory for up
9+
// to 30s and the XHR transport was cancelled by the browser on unload, so the
10+
// request never landed server-side.
11+
const UNLOAD_LOG_KEY = 'flush-on-unload.events'
12+
13+
type UnloadEvent = { ts: number; name: string; detail?: string }
14+
15+
function readUnloadLog(): UnloadEvent[] {
16+
try {
17+
return JSON.parse(sessionStorage.getItem(UNLOAD_LOG_KEY) ?? '[]')
18+
} catch {
19+
return []
20+
}
21+
}
22+
23+
function recordUnloadEvent(name: string, detail?: string) {
24+
const log = readUnloadLog()
25+
log.push({ ts: Date.now(), name, detail })
26+
try {
27+
sessionStorage.setItem(UNLOAD_LOG_KEY, JSON.stringify(log))
28+
} catch {
29+
// ignore
30+
}
31+
}
32+
33+
export default function FlushOnUnload() {
34+
const [lastSpan, setLastSpan] = useState<string>('')
35+
const [unloadLog, setUnloadLog] = useState<UnloadEvent[]>(() =>
36+
readUnloadLog(),
37+
)
38+
39+
// Persist unload events to sessionStorage so we can observe them after
40+
// navigating back from example.com — console.log from pagehide/unload is
41+
// unreliable across a cross-document nav.
42+
useEffect(() => {
43+
const onPageHide = (e: PageTransitionEvent) =>
44+
recordUnloadEvent('pagehide', `persisted=${e.persisted}`)
45+
const onVisibility = () =>
46+
recordUnloadEvent('visibilitychange', document.visibilityState)
47+
const onBeforeUnload = () => recordUnloadEvent('beforeunload')
48+
const onUnload = () => recordUnloadEvent('unload')
49+
const onFreeze = () => recordUnloadEvent('freeze')
50+
window.addEventListener('pagehide', onPageHide)
51+
document.addEventListener('visibilitychange', onVisibility)
52+
window.addEventListener('beforeunload', onBeforeUnload)
53+
window.addEventListener('unload', onUnload)
54+
document.addEventListener('freeze', onFreeze)
55+
return () => {
56+
window.removeEventListener('pagehide', onPageHide)
57+
document.removeEventListener('visibilitychange', onVisibility)
58+
window.removeEventListener('beforeunload', onBeforeUnload)
59+
window.removeEventListener('unload', onUnload)
60+
document.removeEventListener('freeze', onFreeze)
61+
}
62+
}, [])
63+
64+
const emitSpan = (name: string) => {
65+
const suffix = Math.random().toString(36).slice(2, 8)
66+
const spanName = `${name}-${suffix}`
67+
LDObserve.startSpan(spanName, (span) => {
68+
span?.setAttribute('flush-on-unload.test', true)
69+
span?.setAttribute('flush-on-unload.scenario', name)
70+
})
71+
setLastSpan(spanName)
72+
return spanName
73+
}
74+
75+
return (
76+
<div style={{ padding: 16, maxWidth: 720 }}>
77+
<h2>Flush on unload</h2>
78+
<p>
79+
Each button ends a span and then immediately triggers the unload
80+
path the fix targets. Watch DevTools → Network, filter{' '}
81+
<code>v1/traces</code>, and verify the POST goes out before the
82+
navigation completes.
83+
</p>
84+
<p>
85+
Last span emitted: <code>{lastSpan || '(none)'}</code>
86+
</p>
87+
88+
<details open style={{ marginBottom: 16 }}>
89+
<summary>
90+
<strong>Unload events from previous navigation</strong> (
91+
{unloadLog.length})
92+
<button
93+
onClick={() => {
94+
sessionStorage.removeItem(UNLOAD_LOG_KEY)
95+
setUnloadLog([])
96+
}}
97+
style={{ marginLeft: 12 }}
98+
>
99+
Clear
100+
</button>
101+
</summary>
102+
<pre
103+
style={{
104+
background: '#f6f6f6',
105+
padding: 8,
106+
fontSize: 12,
107+
maxHeight: 200,
108+
overflow: 'auto',
109+
}}
110+
>
111+
{unloadLog.length === 0
112+
? '(none — navigate away and come back to see events)'
113+
: unloadLog
114+
.map(
115+
(e) =>
116+
`+${e.ts - unloadLog[0].ts}ms ${e.name}${e.detail ? ` (${e.detail})` : ''}`,
117+
)
118+
.join('\n')}
119+
</pre>
120+
</details>
121+
122+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
123+
<button
124+
onClick={() => {
125+
emitSpan('hard-nav')
126+
// Full-page navigation — fires pagehide and cancels XHR.
127+
window.location.href = '/welcome'
128+
}}
129+
>
130+
Span + hard navigate to /welcome
131+
</button>
132+
133+
<button
134+
onClick={() => {
135+
emitSpan('hard-nav-external')
136+
window.location.href = 'https://example.com/'
137+
}}
138+
>
139+
Span + navigate to example.com (cross-origin unload)
140+
</button>
141+
142+
<button
143+
onClick={() => {
144+
emitSpan('spa-nav')
145+
// SPA nav — does NOT fire pagehide, but the 5s batch
146+
// delay should still catch it. Use "Span (no nav)" to
147+
// compare the before/after scheduledDelayMillis change.
148+
window.history.pushState({}, '', '/welcome')
149+
window.dispatchEvent(new PopStateEvent('popstate'))
150+
}}
151+
>
152+
Span + SPA navigate (pushState, no unload)
153+
</button>
154+
155+
<button
156+
onClick={() => {
157+
emitSpan('visibility-hidden')
158+
// Simulate tab hidden without actually navigating.
159+
// Flush path is triggered by the visibilitychange
160+
// listener registered in registerFlushOnUnload().
161+
Object.defineProperty(document, 'visibilityState', {
162+
configurable: true,
163+
get: () => 'hidden',
164+
})
165+
document.dispatchEvent(new Event('visibilitychange'))
166+
// Flip it back so the page remains usable.
167+
setTimeout(() => {
168+
Object.defineProperty(document, 'visibilityState', {
169+
configurable: true,
170+
get: () => 'visible',
171+
})
172+
document.dispatchEvent(
173+
new Event('visibilitychange'),
174+
)
175+
}, 500)
176+
}}
177+
>
178+
Span + dispatch visibilitychange=hidden
179+
</button>
180+
181+
<button
182+
onClick={() => {
183+
emitSpan('pagehide')
184+
window.dispatchEvent(
185+
new PageTransitionEvent('pagehide'),
186+
)
187+
}}
188+
>
189+
Span + dispatch pagehide (no nav)
190+
</button>
191+
192+
<button
193+
onClick={() => {
194+
emitSpan('reload')
195+
window.location.reload()
196+
}}
197+
>
198+
Span + reload()
199+
</button>
200+
201+
<button
202+
onClick={() => {
203+
emitSpan('batch')
204+
}}
205+
>
206+
Span (no nav — should flush on 5s batch timer)
207+
</button>
208+
209+
<button
210+
onClick={() => {
211+
for (let i = 0; i < 50; i++) {
212+
emitSpan('burst')
213+
}
214+
window.location.href = '/welcome'
215+
}}
216+
>
217+
50 spans + hard navigate (batch size sanity check)
218+
</button>
219+
</div>
220+
</div>
221+
)
222+
}

e2e/react-router/src/routes/root.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default function Root() {
4343
<a href="/welcome">Welcome</a>
4444
<a href="/privacy">Privacy Demo</a>
4545
<a href="/http-test">HTTP Tests</a>
46+
<a href="/flush-on-unload">Flush on Unload</a>
4647
<a href="/ldclient">LDClient</a>
4748
<a href="/ldclient-lazy">LDClient Lazy</a>
4849
</nav>

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,5 +104,10 @@
104104
"vite@6.0.7": "^6.4.1",
105105
"vite@^7": "^7.0.8"
106106
},
107+
"dependenciesMeta": {
108+
"puppeteer@9.1.1": {
109+
"built": false
110+
}
111+
},
107112
"packageManager": "yarn@4.13.0"
108113
}

sdk/highlight-run/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"@opentelemetry/instrumentation-user-interaction": ">=0.44.0",
102102
"@opentelemetry/instrumentation-xml-http-request": ">=0.57.1 < 0.200.0",
103103
"@opentelemetry/otlp-exporter-base": ">=0.57.1 < 0.200.0",
104+
"@opentelemetry/otlp-transformer": ">=0.57.1 < 0.200.0",
104105
"@opentelemetry/resources": "^1.30.1",
105106
"@opentelemetry/sdk-metrics": "^1.30.1",
106107
"@opentelemetry/sdk-trace-web": "^1.30.1",

0 commit comments

Comments
 (0)