Skip to content

Commit 43d2dc3

Browse files
authored
fix: improve network response capture (#379)
## Summary Improve capture of HTTP response bodies for long-running requests. Fix requestResponseSanitizer logic not working correctly with OTel code. ## How did you test this change? [local SDK build with example app](https://ld-stg.launchdarkly.com/projects/default/sessions/oxmayRlvMZCegsZnnIq1w0tDsHRV?relativeTime=last_30_days&page=1&env=test&selected-env=test) <img width="1652" height="1163" alt="image" src="https://github.com/user-attachments/assets/ab73cc18-8a9a-402a-a3a7-0c97e1d17505" /> testing sanitizer <img width="1003" height="206" alt="image" src="https://github.com/user-attachments/assets/4fb74ddf-f749-4461-a41e-3f760442c0b3" /> <img width="1234" height="1039" alt="image" src="https://github.com/user-attachments/assets/d43c46dd-84c6-4561-ab41-8ebcdb43b95e" /> ## Are there any deployment considerations? <!-- Backend - Do we need to consider migrations or backfilling data? --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core client-side tracing/export flow by delaying span export to await async response-body reads, which can affect performance, batching, and memory behavior under high network concurrency. > > **Overview** > Fixes a race where fetch spans could end before async response body capture completed by introducing a shared `pendingResponseAttributes` promise map and teaching `CustomBatchSpanProcessor` to await pending body/attribute work (including during `forceFlush`/`shutdown`) before exporting. > > Updates OTel network recording to apply `requestResponseSanitizer` correctly: run it synchronously after request attributes are set, skip async body reads if the sanitizer marks the span as not-to-record, and re-run sanitization once response headers/body are attached (using `Object.assign` so attributes still apply after `span.end()`). Adds e2e UI buttons for response-body capture and memory-pressure stress tests, and normalizes formatting/newlines in the .NET sample VSCode task and settings JSON files. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8cdf802. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f7a0250 commit 43d2dc3

File tree

6 files changed

+702
-213
lines changed

6 files changed

+702
-213
lines changed

e2e/react-router/src/routes/http-test.tsx

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,281 @@ export default function HttpTest() {
517517
/>
518518
</TestSection>
519519

520+
<TestSection
521+
title="Response Body Capture Tests"
522+
description="Test that http.response.body is captured on fetch spans. Previously, the async body read raced with span.end() causing the body attribute to be silently dropped."
523+
>
524+
<TestButton
525+
title="GET Response Body"
526+
description="http.response.body should contain JSON"
527+
onClick={async () => {
528+
try {
529+
const response = await fetch(
530+
'https://jsonplaceholder.typicode.com/posts/1?test=response-body-get',
531+
)
532+
const data = await response.json()
533+
console.log('GET response body capture test:', data)
534+
} catch (e) {
535+
console.error('Request error:', e)
536+
}
537+
}}
538+
/>
539+
540+
<TestButton
541+
title="Large Response Body"
542+
description="Body capture with ~5KB response"
543+
onClick={async () => {
544+
try {
545+
const response = await fetch(
546+
'https://jsonplaceholder.typicode.com/posts?test=response-body-large&_limit=10',
547+
)
548+
const data = await response.json()
549+
console.log(
550+
'Large response body capture test:',
551+
`${data.length} items, ~${JSON.stringify(data).length} bytes`,
552+
)
553+
} catch (e) {
554+
console.error('Request error:', e)
555+
}
556+
}}
557+
/>
558+
559+
<TestButton
560+
title="POST Response Body"
561+
description="Both request and response bodies captured"
562+
onClick={async () => {
563+
try {
564+
const response = await fetch(
565+
'https://jsonplaceholder.typicode.com/posts?test=response-body-post',
566+
{
567+
method: 'POST',
568+
headers: {
569+
'Content-Type': 'application/json',
570+
},
571+
body: JSON.stringify({
572+
title: 'Test',
573+
body: 'Verify both request and response bodies are on the span',
574+
userId: 1,
575+
}),
576+
},
577+
)
578+
const data = await response.json()
579+
console.log(
580+
'POST response body capture test:',
581+
data,
582+
)
583+
} catch (e) {
584+
console.error('Request error:', e)
585+
}
586+
}}
587+
/>
588+
589+
<TestButton
590+
title="Concurrent Response Bodies"
591+
description="5 parallel requests, all should have bodies"
592+
onClick={async () => {
593+
try {
594+
const promises = []
595+
for (let i = 1; i <= 5; i++) {
596+
promises.push(
597+
fetch(
598+
`https://jsonplaceholder.typicode.com/posts/${i}?test=response-body-concurrent-${i}`,
599+
),
600+
)
601+
}
602+
const responses = await Promise.all(promises)
603+
const data = await Promise.all(
604+
responses.map((r) => r.json()),
605+
)
606+
console.log(
607+
'Concurrent response body capture test:',
608+
`${data.length} responses with bodies`,
609+
)
610+
} catch (e) {
611+
console.error('Request error:', e)
612+
}
613+
}}
614+
/>
615+
</TestSection>
616+
617+
<TestSection
618+
title="Memory Pressure Tests"
619+
description="Stress test the pendingResponseAttributes map and response body cloning under high concurrency. Use browser DevTools Memory tab to observe heap impact."
620+
>
621+
<TestButton
622+
title="50 Concurrent Requests"
623+
description="Flood pendingResponseAttributes map"
624+
onClick={async () => {
625+
const before = (
626+
performance as unknown as {
627+
memory?: { usedJSHeapSize: number }
628+
}
629+
).memory?.usedJSHeapSize
630+
try {
631+
const promises = []
632+
for (let i = 1; i <= 50; i++) {
633+
promises.push(
634+
fetch(
635+
`https://jsonplaceholder.typicode.com/posts/${(i % 100) + 1}?test=memory-concurrent-${i}`,
636+
),
637+
)
638+
}
639+
const responses = await Promise.all(promises)
640+
await Promise.all(responses.map((r) => r.json()))
641+
const after = (
642+
performance as unknown as {
643+
memory?: { usedJSHeapSize: number }
644+
}
645+
).memory?.usedJSHeapSize
646+
if (before && after) {
647+
console.log(
648+
`Memory: 50 concurrent requests — heap delta: ${((after - before) / 1024).toFixed(0)}KB (${(before / 1024 / 1024).toFixed(1)}MB → ${(after / 1024 / 1024).toFixed(1)}MB)`,
649+
)
650+
} else {
651+
console.log(
652+
'Memory: 50 concurrent requests completed (enable chrome://flags/#enable-precise-memory-info for heap stats)',
653+
)
654+
}
655+
} catch (e) {
656+
console.error('Memory test error:', e)
657+
}
658+
}}
659+
/>
660+
661+
<TestButton
662+
title="Large Bodies x20"
663+
description="20 concurrent requests with ~30KB bodies"
664+
onClick={async () => {
665+
const before = (
666+
performance as unknown as {
667+
memory?: { usedJSHeapSize: number }
668+
}
669+
).memory?.usedJSHeapSize
670+
try {
671+
const promises = []
672+
for (let i = 0; i < 20; i++) {
673+
// /comments returns ~30KB
674+
promises.push(
675+
fetch(
676+
`https://jsonplaceholder.typicode.com/comments?test=memory-large-${i}`,
677+
),
678+
)
679+
}
680+
const responses = await Promise.all(promises)
681+
const bodies = await Promise.all(
682+
responses.map((r) => r.json()),
683+
)
684+
const totalSize = bodies.reduce(
685+
(sum, b) => sum + JSON.stringify(b).length,
686+
0,
687+
)
688+
const after = (
689+
performance as unknown as {
690+
memory?: { usedJSHeapSize: number }
691+
}
692+
).memory?.usedJSHeapSize
693+
if (before && after) {
694+
console.log(
695+
`Memory: 20 large responses (~${(totalSize / 1024).toFixed(0)}KB total) — heap delta: ${((after - before) / 1024).toFixed(0)}KB (${(before / 1024 / 1024).toFixed(1)}MB → ${(after / 1024 / 1024).toFixed(1)}MB)`,
696+
)
697+
} else {
698+
console.log(
699+
`Memory: 20 large responses (~${(totalSize / 1024).toFixed(0)}KB total) completed`,
700+
)
701+
}
702+
} catch (e) {
703+
console.error('Memory test error:', e)
704+
}
705+
}}
706+
/>
707+
708+
<TestButton
709+
title="Sustained Rapid Fire"
710+
description="10 batches of 10, back-to-back"
711+
onClick={async () => {
712+
const before = (
713+
performance as unknown as {
714+
memory?: { usedJSHeapSize: number }
715+
}
716+
).memory?.usedJSHeapSize
717+
try {
718+
for (let batch = 0; batch < 10; batch++) {
719+
const promises = []
720+
for (let i = 0; i < 10; i++) {
721+
promises.push(
722+
fetch(
723+
`https://jsonplaceholder.typicode.com/posts/${(i % 100) + 1}?test=memory-rapid-b${batch}-${i}`,
724+
),
725+
)
726+
}
727+
const responses = await Promise.all(promises)
728+
await Promise.all(
729+
responses.map((r) => r.json()),
730+
)
731+
}
732+
const after = (
733+
performance as unknown as {
734+
memory?: { usedJSHeapSize: number }
735+
}
736+
).memory?.usedJSHeapSize
737+
if (before && after) {
738+
console.log(
739+
`Memory: 10x10 sustained load — heap delta: ${((after - before) / 1024).toFixed(0)}KB (${(before / 1024 / 1024).toFixed(1)}MB → ${(after / 1024 / 1024).toFixed(1)}MB)`,
740+
)
741+
} else {
742+
console.log(
743+
'Memory: 10x10 sustained load completed',
744+
)
745+
}
746+
} catch (e) {
747+
console.error('Memory test error:', e)
748+
}
749+
}}
750+
/>
751+
752+
<TestButton
753+
title="Fire-and-Forget (no await body)"
754+
description="50 requests where body is never read by app"
755+
onClick={async () => {
756+
const before = (
757+
performance as unknown as {
758+
memory?: { usedJSHeapSize: number }
759+
}
760+
).memory?.usedJSHeapSize
761+
try {
762+
const promises = []
763+
for (let i = 0; i < 50; i++) {
764+
promises.push(
765+
fetch(
766+
`https://jsonplaceholder.typicode.com/posts/${(i % 100) + 1}?test=memory-fire-forget-${i}`,
767+
),
768+
)
769+
}
770+
// Only wait for responses, don't read bodies.
771+
// The SDK still clones + reads each body internally,
772+
// so this tests whether un-consumed bodies leak.
773+
await Promise.all(promises)
774+
const after = (
775+
performance as unknown as {
776+
memory?: { usedJSHeapSize: number }
777+
}
778+
).memory?.usedJSHeapSize
779+
if (before && after) {
780+
console.log(
781+
`Memory: 50 fire-and-forget — heap delta: ${((after - before) / 1024).toFixed(0)}KB (${(before / 1024 / 1024).toFixed(1)}MB → ${(after / 1024 / 1024).toFixed(1)}MB)`,
782+
)
783+
} else {
784+
console.log(
785+
'Memory: 50 fire-and-forget completed',
786+
)
787+
}
788+
} catch (e) {
789+
console.error('Memory test error:', e)
790+
}
791+
}}
792+
/>
793+
</TestSection>
794+
520795
<TestSection
521796
title="Response Tests"
522797
description="Test different response types and status codes."
@@ -642,6 +917,19 @@ export default function HttpTest() {
642917
Request/response bodies are recorded as
643918
configured
644919
</li>
920+
<li>
921+
Response Body Capture: each span has an{' '}
922+
<code>http.response.body</code> attribute with
923+
the full JSON response (not just{' '}
924+
<code>http.response.body.size</code>)
925+
</li>
926+
<li>
927+
Memory Pressure: after running stress tests,
928+
check console for heap delta logs and use
929+
DevTools Memory tab to verify the{' '}
930+
<code>pendingResponseAttributes</code> map
931+
drains and heap does not grow unbounded
932+
</li>
645933
</ul>
646934
</li>
647935
</ol>

0 commit comments

Comments
 (0)