Skip to content

Commit 13b44f1

Browse files
committed
feat(expo): fetch network collector with header redaction
1 parent 9da4569 commit 13b44f1

2 files changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { test, expect, beforeEach, afterEach } from "bun:test"
2+
import { createNetworkCollector } from "./network"
3+
4+
let originalFetch: typeof fetch
5+
6+
beforeEach(() => {
7+
originalFetch = globalThis.fetch
8+
})
9+
10+
afterEach(() => {
11+
globalThis.fetch = originalFetch
12+
})
13+
14+
test("records a fetch call with method, URL, status, duration", async () => {
15+
const c = createNetworkCollector({
16+
max: 10,
17+
captureBodies: false,
18+
redact: { headerDenylist: [], bodyRedactKeys: [] },
19+
})
20+
21+
globalThis.fetch = (async () => {
22+
return new Response("ok", { status: 200, headers: { "content-length": "2" } })
23+
}) as typeof fetch
24+
25+
c.start()
26+
const res = await fetch("https://api.test/x", { method: "POST" })
27+
expect(res.status).toBe(200)
28+
29+
const entries = c.snapshot()
30+
expect(entries).toHaveLength(1)
31+
expect(entries[0]?.method).toBe("POST")
32+
expect(entries[0]?.url).toBe("https://api.test/x")
33+
expect(entries[0]?.status).toBe(200)
34+
expect(entries[0]?.durationMs).toBeGreaterThanOrEqual(0)
35+
expect(entries[0]?.initiator).toBe("fetch")
36+
c.stop()
37+
})
38+
39+
test("records fetch failures as entries with error", async () => {
40+
const c = createNetworkCollector({
41+
max: 10,
42+
captureBodies: false,
43+
redact: { headerDenylist: [], bodyRedactKeys: [] },
44+
})
45+
46+
globalThis.fetch = (async () => {
47+
throw new Error("network down")
48+
}) as typeof fetch
49+
50+
c.start()
51+
await expect(fetch("https://api.test/fail")).rejects.toThrow("network down")
52+
53+
const entries = c.snapshot()
54+
expect(entries[0]?.error).toContain("network down")
55+
expect(entries[0]?.status).toBeNull()
56+
c.stop()
57+
})
58+
59+
test("redacts denylisted request headers", async () => {
60+
const c = createNetworkCollector({
61+
max: 10,
62+
captureBodies: false,
63+
redact: { headerDenylist: ["authorization"], bodyRedactKeys: [] },
64+
})
65+
66+
globalThis.fetch = (async () => new Response("ok", { status: 200 })) as typeof fetch
67+
68+
c.start()
69+
await fetch("https://api.test/x", { headers: { Authorization: "secret", "X-Ok": "public" } })
70+
71+
const entry = c.snapshot()[0]
72+
expect(entry?.requestHeaders?.authorization).toBe("[redacted]")
73+
expect(entry?.requestHeaders?.["x-ok"]).toBe("public")
74+
c.stop()
75+
})
76+
77+
test("stop restores the original fetch", () => {
78+
const original = globalThis.fetch
79+
const c = createNetworkCollector({
80+
max: 10,
81+
captureBodies: false,
82+
redact: { headerDenylist: [], bodyRedactKeys: [] },
83+
})
84+
c.start()
85+
expect(globalThis.fetch).not.toBe(original)
86+
c.stop()
87+
expect(globalThis.fetch).toBe(original)
88+
})
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { RingBuffer } from "@reprojs/sdk-utils"
2+
3+
export interface NetworkEntry {
4+
id: string
5+
ts: number
6+
method: string
7+
url: string
8+
status: number | null
9+
durationMs: number | null
10+
size: number | null
11+
initiator: "fetch" | "xhr"
12+
requestHeaders?: Record<string, string>
13+
responseHeaders?: Record<string, string>
14+
requestBody?: string
15+
responseBody?: string
16+
error?: string
17+
}
18+
19+
const SENTINEL = "__reprojs_patched"
20+
21+
function rid() {
22+
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
23+
}
24+
25+
function headersToMap(h: HeadersInit | undefined, denylist: string[]): Record<string, string> {
26+
const out: Record<string, string> = {}
27+
if (!h) return out
28+
const set = new Set(denylist.map((k) => k.toLowerCase()))
29+
const add = (k: string, v: string) => {
30+
const lk = k.toLowerCase()
31+
out[lk] = set.has(lk) ? "[redacted]" : v
32+
}
33+
if (h instanceof Headers) {
34+
h.forEach((v, k) => add(k, v))
35+
} else if (Array.isArray(h)) {
36+
for (const [k, v] of h) add(k, v)
37+
} else {
38+
for (const [k, v] of Object.entries(h)) add(k, v)
39+
}
40+
return out
41+
}
42+
43+
export interface NetworkCollector {
44+
start: () => void
45+
stop: () => void
46+
snapshot: () => NetworkEntry[]
47+
clear: () => void
48+
}
49+
50+
export function createNetworkCollector(opts: {
51+
max: number
52+
captureBodies: boolean
53+
redact: { headerDenylist: string[]; bodyRedactKeys: string[] }
54+
}): NetworkCollector {
55+
const buf = new RingBuffer<NetworkEntry>(opts.max)
56+
let originalFetch: typeof fetch | null = null
57+
58+
function start() {
59+
const currentFetch = globalThis.fetch as typeof fetch & { [SENTINEL]?: boolean }
60+
if (currentFetch && !currentFetch[SENTINEL]) {
61+
originalFetch = currentFetch
62+
const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => {
63+
const id = rid()
64+
const ts = Date.now()
65+
const method = (init?.method ?? "GET").toUpperCase()
66+
const url =
67+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
68+
const requestHeaders = headersToMap(init?.headers, opts.redact.headerDenylist)
69+
const t0 = Date.now()
70+
const original = originalFetch
71+
if (!original) return Promise.reject(new Error("fetch not captured"))
72+
return original(input as RequestInfo, init).then(
73+
(res) => {
74+
const clen = Number(res.headers.get("content-length") ?? "NaN")
75+
try {
76+
buf.push({
77+
id,
78+
ts,
79+
method,
80+
url,
81+
status: res.status,
82+
durationMs: Date.now() - t0,
83+
size: Number.isNaN(clen) ? null : clen,
84+
initiator: "fetch",
85+
requestHeaders,
86+
responseHeaders: headersToMap(res.headers, opts.redact.headerDenylist),
87+
})
88+
} catch {
89+
// fail-open
90+
}
91+
return res
92+
},
93+
(err: unknown) => {
94+
try {
95+
buf.push({
96+
id,
97+
ts,
98+
method,
99+
url,
100+
status: null,
101+
durationMs: Date.now() - t0,
102+
size: null,
103+
initiator: "fetch",
104+
requestHeaders,
105+
error: err instanceof Error ? err.message : String(err),
106+
})
107+
} catch {
108+
// fail-open
109+
}
110+
throw err
111+
},
112+
)
113+
}) as typeof fetch & { [SENTINEL]?: boolean }
114+
wrapped[SENTINEL] = true
115+
globalThis.fetch = wrapped
116+
}
117+
}
118+
119+
function stop() {
120+
if (originalFetch) {
121+
globalThis.fetch = originalFetch
122+
originalFetch = null
123+
}
124+
}
125+
126+
return {
127+
start,
128+
stop,
129+
snapshot: () => buf.peek(),
130+
clear: () => buf.clear(),
131+
}
132+
}

0 commit comments

Comments
 (0)