@descryy/runtime-browser 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/browser-action-collector.d.ts +181 -0
  2. package/dist/browser-action-collector.d.ts.map +1 -0
  3. package/dist/browser-action-collector.js +427 -0
  4. package/dist/browser-action-collector.js.map +1 -0
  5. package/dist/browser-console-collector.d.ts +33 -0
  6. package/dist/browser-console-collector.d.ts.map +1 -0
  7. package/dist/browser-console-collector.js +185 -0
  8. package/dist/browser-console-collector.js.map +1 -0
  9. package/dist/browser-network-collector.d.ts +112 -0
  10. package/dist/browser-network-collector.d.ts.map +1 -0
  11. package/dist/browser-network-collector.js +682 -0
  12. package/dist/browser-network-collector.js.map +1 -0
  13. package/dist/browser-session.d.ts +144 -0
  14. package/dist/browser-session.d.ts.map +1 -0
  15. package/dist/browser-session.js +136 -0
  16. package/dist/browser-session.js.map +1 -0
  17. package/dist/collector-version.d.ts +10 -0
  18. package/dist/collector-version.d.ts.map +1 -0
  19. package/dist/collector-version.js +12 -0
  20. package/dist/collector-version.js.map +1 -0
  21. package/dist/dom-evidence.d.ts +20 -0
  22. package/dist/dom-evidence.d.ts.map +1 -0
  23. package/dist/dom-evidence.js +32 -0
  24. package/dist/dom-evidence.js.map +1 -0
  25. package/dist/frontend-initiator-capture.d.ts +87 -0
  26. package/dist/frontend-initiator-capture.d.ts.map +1 -0
  27. package/dist/frontend-initiator-capture.js +142 -0
  28. package/dist/frontend-initiator-capture.js.map +1 -0
  29. package/dist/index.d.ts +16 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +9 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/network-correlation.d.ts +15 -0
  34. package/dist/network-correlation.d.ts.map +1 -0
  35. package/dist/network-correlation.js +29 -0
  36. package/dist/network-correlation.js.map +1 -0
  37. package/dist/script-url-mapping.d.ts +40 -0
  38. package/dist/script-url-mapping.d.ts.map +1 -0
  39. package/dist/script-url-mapping.js +96 -0
  40. package/dist/script-url-mapping.js.map +1 -0
  41. package/package.json +36 -0
@@ -0,0 +1,682 @@
1
+ /**
2
+ * §8's network evidence bucket: method · URL · safe request metadata/body
3
+ * · response status · safe response metadata/body · timing · initiator
4
+ * where available. `NETWORK_REQUEST` on `request`; `NETWORK_RESPONSE` (and
5
+ * `HTTP_ERROR` alongside it for status >= 400) on `requestfinished`, which
6
+ * is when `request.timing()` is actually complete; a network-level
7
+ * failure with no HTTP response at all (`requestfailed` -- DNS, connection
8
+ * refused, aborted) also becomes `HTTP_ERROR`, with `status: null`
9
+ * distinguishing "never got a response" from "got a bad one."
10
+ *
11
+ * Auth headers/cookies/tokens are not stripped here -- `headers()` already
12
+ * lower-cases header names, and `EvidenceStore.write()`'s generic
13
+ * redaction (RT-015) deep-redacts `authorization`/`cookie`/`set-cookie`/
14
+ * etc. by key name unconditionally at persist time, so a header object
15
+ * passed straight into payload is already covered by an existing,
16
+ * already-tested guarantee -- reimplementing it here would be a second,
17
+ * divergent copy of the same rule.
18
+ *
19
+ * A third `status: null` case (RT-194): a request that gets neither
20
+ * `requestfinished` nor `requestfailed` within this collector's own
21
+ * timeout window (`networkTimeoutMs`, default `context.configuration.
22
+ * timeoutMs` -- RT-030's precedent, not a second disagreeing default) is
23
+ * reported as `HTTP_ERROR` with `networkError` describing a collector-side
24
+ * timeout, distinguishable from a real Playwright `requestfailed` (whose
25
+ * `networkError` carries Chromium's own `net::ERR_*` text). Nothing in
26
+ * Playwright itself imposes a timeout on an in-page `fetch`/`XHR` that
27
+ * never completes -- a hung server would otherwise produce silence
28
+ * forever, not evidence.
29
+ *
30
+ * **"initiator where available" above is no longer only `resourceType`.**
31
+ * With a `stackTraceParser` injected, `NETWORK_REQUEST.stackTrace` carries
32
+ * a real, resolved call-site stack for `fetch()` requests (Chromium only —
33
+ * see `frontend-initiator-capture.ts` for the mechanism and the write it
34
+ * makes to the observed page). This is still not the same claim as
35
+ * `@descryy/runtime-graph-correlator`'s `resolveObservedFrontendCaller`
36
+ * upgrading a structural candidate to `observed`: that join happens one
37
+ * layer up, against the graph. This collector's job stops at attaching the
38
+ * resolved stack to the evidence record.
39
+ *
40
+ * **RT-225: WebSocket traffic is a fourth listener here, not a sibling
41
+ * collector.** `page.on("request"/"requestfinished"/"requestfailed")` never
42
+ * fires for a WebSocket connection -- Playwright surfaces it through a
43
+ * wholly separate `page.on("websocket")` stream, and before this a refused
44
+ * or dropped WebSocket connection produced zero evidence of any kind, not
45
+ * degraded, silently absent
46
+ * (`documents/decisions-inbox/RT-PENDING-websocket-network-observation-gap.md`).
47
+ * This collector draws its boundary by domain watched (browser network
48
+ * traffic), not by wire protocol, so WS traffic belongs here.
49
+ *
50
+ * Three moments, three choices:
51
+ * - **connection attempt** (`page.on("websocket")` firing -- "Emitted when
52
+ * WebSocket request is sent," Playwright's own doc for the event) reuses
53
+ * `NETWORK_REQUEST`: a real outbound call to a URL, the same fact that
54
+ * type exists to record. No `method`/`headers`/`postData` fields are
55
+ * invented -- Playwright's `WebSocket` object exposes only `url()`, so
56
+ * the payload states only what was actually observed, plus a `protocol:
57
+ * "websocket"` discriminator.
58
+ * - **connection failure** (`socketerror`) reuses `HTTP_ERROR` with
59
+ * `status: null` -- the identical "no usable response ever came back"
60
+ * convention already applied above to a refused `fetch()`
61
+ * (`requestfailed`) and to this collector's own timeout. A refused
62
+ * WebSocket is the same class of fact under a different wire protocol,
63
+ * not a different fact.
64
+ * - **close** gets its own type, `WEBSOCKET_CLOSED` (`runtime-event.ts`),
65
+ * because there is no honest way to fold it into `NETWORK_RESPONSE`:
66
+ * that type means a response with a status arrived, and a close is
67
+ * neither a response nor does it carry a status. Measured, not assumed:
68
+ * Playwright's public `WebSocket.on("close")` carries no code or reason
69
+ * at all, and probing it directly (a clean server-sent 1000 close vs. a
70
+ * raw abrupt TCP termination) produced byte-identical event shapes --
71
+ * so this collector does not claim "normal" vs "abnormal" either, only
72
+ * that the socket closed. Only emitted when no `socketerror` already
73
+ * reported the same socket as failed, so one failure is never reported
74
+ * twice.
75
+ *
76
+ * Deliberately **not** captured: per-message frame traffic
77
+ * (`framesent`/`framereceived`) -- high-volume and not what the proven gap
78
+ * asked for; `EventSource`/SSE, which shares the same uncovered shape but
79
+ * is unmeasured and explicitly out of scope for this change, same as the
80
+ * RT-PENDING note that proved this gap already said.
81
+ */
82
+ import { primaryFrameLocation } from "@descryy/runtime-contracts";
83
+ import { extractHeaderCorrelationIds } from "./network-correlation.js";
84
+ import { rewriteScriptUrls } from "./script-url-mapping.js";
85
+ import { createFrontendInitiatorCapture, stripInitiatorWrapperFrame, INITIATOR_HEADER_NAME } from "./frontend-initiator-capture.js";
86
+ import { COLLECTOR_VERSION } from "./collector-version.js";
87
+ const DEFAULT_NETWORK_TIMEOUT_MS = 30000;
88
+ function notThisCollectorsJob(what) {
89
+ return { availability: "unavailable", reason: `BrowserNetworkCollector handles network observation only, not ${what} -- see the dedicated collector.` };
90
+ }
91
+ const CAPABILITIES = {
92
+ domObservation: notThisCollectorsJob("DOM inspection"),
93
+ consoleObservation: notThisCollectorsJob("console/page-error observation"),
94
+ networkObservation: {
95
+ availability: "degraded",
96
+ reason: "response bodies are captured for JSON/text content types only; binary, streamed, or already-consumed bodies are not available. " +
97
+ "WebSocket connections (RT-225) are observed for connection attempt, connection failure and close, but not per-message frame " +
98
+ "traffic (framesent/framereceived, not captured), and a close cannot be told apart as normal vs. abnormal -- Playwright's public " +
99
+ "WebSocket API exposes no close code or reason, measured directly rather than assumed. EventSource/SSE traffic is ordinary browser " +
100
+ "network traffic under the hood and is already observed via the request/requestfailed path above -- verified directly, not assumed " +
101
+ "(see this collector's test suite).",
102
+ },
103
+ backendLogAccess: { availability: "unavailable", reason: "this collector observes browser-originated network activity, not a backend process." },
104
+ distributedTrace: { availability: "available", reason: null },
105
+ sourceMapping: { availability: "unavailable", reason: "no stackTraceParser was injected -- network evidence carries no stack traces." },
106
+ stackCapture: { availability: "unavailable", reason: "no stackTraceParser was injected -- network evidence carries no stack traces." },
107
+ processLifecycle: notThisCollectorsJob("process lifecycle observation"),
108
+ databaseObservation: notThisCollectorsJob("database query observation"),
109
+ // Not a claim on the requests this collector already captures under
110
+ // networkObservation, some of which do reach external hosts -- this
111
+ // field is specifically the process-instrumentation mechanism
112
+ // `ExternalRequestCollector` (RT-146) uses, a different capture path
113
+ // this collector does not have. See capability.ts's own doc comment.
114
+ externalServiceObservation: {
115
+ availability: "unavailable",
116
+ reason: "this collector captures browser-originated requests broadly (NETWORK_REQUEST/RESPONSE, §6); it does not instrument a process's own outbound client the way this field's producer does.",
117
+ },
118
+ };
119
+ /**
120
+ * With a parser wired: fetch-initiator capture is installed, so
121
+ * `NETWORK_REQUEST.stackTrace` and `.sourceLocation` are populated whenever
122
+ * a real call-site stack was captured and resolved. `degraded`, not
123
+ * `available`, for the identical reason `BrowserConsoleCollector` uses it
124
+ * for `sourceMapping`: a successful rewrite proves the script's origin maps
125
+ * to a configured directory, not that the served file came from that exact
126
+ * checkout. `stackCapture` is `degraded` too, one level up from that: it
127
+ * covers Chromium + `fetch()` only — no Firefox/WebKit, no `XMLHttpRequest`
128
+ * — and a header an intermediate proxy strips, or a call site the parser
129
+ * cannot symbolicate (a minified build), both degrade a given request back
130
+ * to `stackTrace: null` silently at the per-request level, which is exactly
131
+ * what "degraded" rather than "available" discloses at the capability
132
+ * level.
133
+ */
134
+ const CAPABILITIES_WITH_STACKS = {
135
+ ...CAPABILITIES,
136
+ sourceMapping: {
137
+ availability: "degraded",
138
+ reason: "fetch() call-site stacks are parsed and script URLs are rewritten to on-disk paths via the execution's configured service roots, the same way BrowserConsoleCollector's page-error stacks are; " +
139
+ "a successful rewrite proves the origin maps to a configured directory, not that the served file came from that checkout.",
140
+ },
141
+ stackCapture: {
142
+ availability: "degraded",
143
+ reason: "captures the real call-site stack for fetch() requests only (Chromium + fetch(), via an injected header carrying a per-call id -- not XMLHttpRequest, not Firefox/WebKit); " +
144
+ "a header stripped by an intermediate proxy, or a call site the parser cannot symbolicate, degrades that one request's stackTrace to null rather than the whole capability.",
145
+ },
146
+ };
147
+ const BODY_CAPTURABLE_CONTENT_TYPE = /^(application\/json|text\/)/i;
148
+ async function captureResponseBody(response, headers) {
149
+ const contentType = headers["content-type"] ?? "";
150
+ if (!BODY_CAPTURABLE_CONTENT_TYPE.test(contentType))
151
+ return null;
152
+ return response.text().catch(() => null);
153
+ }
154
+ export function createBrowserNetworkCollector(options) {
155
+ const { session } = options;
156
+ const { page } = session;
157
+ let emit;
158
+ let networkTimeoutMs = options.networkTimeoutMs ?? DEFAULT_NETWORK_TIMEOUT_MS;
159
+ let resolveSourceRoot;
160
+ /** Only true once both halves are present: a parser to read the stack, and a way to map its URLs -- same rule `BrowserConsoleCollector` uses. */
161
+ let stacksParsed = false;
162
+ let initiatorCapture;
163
+ const source = "browser-network";
164
+ // Tracks requests still awaiting `requestfinished`/`requestfailed`, keyed
165
+ // by the Playwright `Request` object itself -- not a URL or an id, which
166
+ // could collide across concurrent requests to the same endpoint. Cleared
167
+ // the moment either real event fires, or when this collector's own timer
168
+ // fires first and reports the exchange as timed out.
169
+ const pending = new Map();
170
+ /**
171
+ * In-flight `onRequest` handlers -- the identical reason
172
+ * `BrowserConsoleCollector.onPageError` keeps one (RT-023 regression):
173
+ * resolving a call-site stack is asynchronous, and Playwright's `request`
174
+ * listener is not, so `stop()` must drain this before clearing `emit`, or
175
+ * a NETWORK_REQUEST whose stack was still resolving at execution end is
176
+ * silently dropped rather than reported.
177
+ */
178
+ const inFlight = new Set();
179
+ const activeSockets = new Map();
180
+ function clearPending(request) {
181
+ const timer = pending.get(request);
182
+ if (timer !== undefined) {
183
+ clearTimeout(timer);
184
+ pending.delete(request);
185
+ }
186
+ }
187
+ /**
188
+ * `null` whenever this request's real call-site stack cannot be
189
+ * recovered, for any reason -- no parser wired, no id header (an
190
+ * intermediate proxy stripped it, or the request predates the init
191
+ * script installing on this document), or nothing was ever buffered for
192
+ * the id (evicted, or issued by a different capture instance). Every one
193
+ * of those degrades to exactly the same `null`, which is what lets the
194
+ * graph-correlator's structural-only fallback stay the single, unified
195
+ * "no observation" path rather than needing to distinguish the reason.
196
+ */
197
+ async function resolveInitiatorStack(headers) {
198
+ if (!stacksParsed || initiatorCapture === undefined)
199
+ return null;
200
+ const id = headers[INITIATOR_HEADER_NAME];
201
+ if (id === undefined)
202
+ return null;
203
+ const rawStack = initiatorCapture.take(id);
204
+ if (rawStack === undefined)
205
+ return null;
206
+ const mapping = rewriteScriptUrls(stripInitiatorWrapperFrame(rawStack), resolveSourceRoot);
207
+ try {
208
+ return await options.stackTraceParser.parse(mapping.text);
209
+ }
210
+ catch (error) {
211
+ // **A frame that cannot be resolved must not take the request with it.**
212
+ // Resolution reads files off disk — a stale source map, a permission
213
+ // error, a path that turned out to be a directory (`EISDIR`; see
214
+ // `script-url-mapping.ts`'s own `isFile` note) — and every one of those
215
+ // is a failure to *annotate* an observation, not a failure to make it.
216
+ // Before this, the throw propagated out of `emitNetworkRequest` into a
217
+ // bare `console.error` and the entire `NETWORK_REQUEST` row was dropped:
218
+ // a real request the page really issued, gone, with nothing in the
219
+ // evidence stream to say so. That is rule 7's "never silently do less",
220
+ // and the emphasis is on *silently*.
221
+ //
222
+ // So: degrade to `null` — the same value every other unresolvable
223
+ // initiator already degrades to, which is what keeps the
224
+ // graph-correlator's structural-only fallback a single path — and
225
+ // disclose the degradation as a first-class `COLLECTOR_ERROR` row.
226
+ emitCollectorError(`initiator stack resolution failed; this request's NETWORK_REQUEST carries no call-site stack: ${describe(error)}`, error);
227
+ return null;
228
+ }
229
+ }
230
+ /** A first-hand observation of this collector's own failure. Never throws — see the nested catch. */
231
+ function emitCollectorError(message, error) {
232
+ try {
233
+ emit?.({
234
+ timestamp: new Date().toISOString(),
235
+ source,
236
+ service: options.service ?? null,
237
+ process: null,
238
+ eventType: "COLLECTOR_ERROR",
239
+ payload: {
240
+ raw: `browser network collector for session "${session.sessionId}": ${message}`,
241
+ collectorId: `browser-network:${session.sessionId}`,
242
+ error: describe(error),
243
+ browserSessionId: session.sessionId,
244
+ },
245
+ traceId: null,
246
+ requestId: null,
247
+ correlationId: null,
248
+ graphNodeId: null,
249
+ sourceLocation: null,
250
+ stackTrace: null,
251
+ // About the observer, not the observed — as certain as any captured line.
252
+ confidence: 1,
253
+ redactionStatus: "pending-redaction",
254
+ collectorVersion: COLLECTOR_VERSION,
255
+ });
256
+ }
257
+ catch (emitError) {
258
+ // The evidence channel itself is gone, so the console is genuinely the
259
+ // last resort here rather than the first one it used to be.
260
+ console.error(`[browser-network:${session.sessionId}] could not disclose a collector error: ${String(emitError)}`);
261
+ }
262
+ }
263
+ function describe(error) {
264
+ return error instanceof Error ? error.message : String(error);
265
+ }
266
+ async function emitNetworkRequest(request, headers, traceId, requestId) {
267
+ const stackTrace = await resolveInitiatorStack(headers);
268
+ emit?.({
269
+ timestamp: new Date().toISOString(),
270
+ source,
271
+ service: options.service ?? null,
272
+ process: null,
273
+ eventType: "NETWORK_REQUEST",
274
+ payload: {
275
+ method: request.method(),
276
+ url: request.url(),
277
+ headers,
278
+ postData: request.postData(),
279
+ resourceType: request.resourceType(),
280
+ browserSessionId: session.sessionId,
281
+ },
282
+ traceId,
283
+ requestId,
284
+ correlationId: null,
285
+ graphNodeId: null,
286
+ // Same rule `Evidence.primaryFrameLocation`'s own doc states: the
287
+ // innermost frame after the wrapper's own frame is stripped, never a
288
+ // second, divergent copy of "which frame is the site of interest."
289
+ sourceLocation: primaryFrameLocation(stackTrace),
290
+ stackTrace,
291
+ confidence: 1,
292
+ redactionStatus: "pending-redaction",
293
+ collectorVersion: COLLECTOR_VERSION,
294
+ });
295
+ }
296
+ function onRequest(request) {
297
+ const headers = request.headers();
298
+ const { traceId, requestId } = extractHeaderCorrelationIds(headers);
299
+ // The timeout window starts here, synchronously -- not after
300
+ // emitNetworkRequest's stack resolution completes, which would let
301
+ // parse latency silently skew what this collector's own timer is
302
+ // supposed to measure.
303
+ const timer = setTimeout(() => {
304
+ pending.delete(request);
305
+ emit?.({
306
+ timestamp: new Date().toISOString(),
307
+ source,
308
+ service: options.service ?? null,
309
+ process: null,
310
+ eventType: "HTTP_ERROR",
311
+ payload: {
312
+ // No response, and no Playwright-observed failure either -- this
313
+ // collector gave up waiting. status: null puts it in the same
314
+ // "never got a response" bucket onRequestFailed uses; networkError
315
+ // names the collector as the reporter rather than Chromium, which
316
+ // is what a test (or a reader) uses to tell it apart from a real
317
+ // net::ERR_* failure.
318
+ status: null,
319
+ method: request.method(),
320
+ url: request.url(),
321
+ networkError: `collector timeout: no response within ${networkTimeoutMs}ms`,
322
+ browserSessionId: session.sessionId,
323
+ },
324
+ traceId,
325
+ requestId,
326
+ correlationId: null,
327
+ graphNodeId: null,
328
+ sourceLocation: null,
329
+ stackTrace: null,
330
+ confidence: 1,
331
+ redactionStatus: "pending-redaction",
332
+ collectorVersion: COLLECTOR_VERSION,
333
+ });
334
+ }, networkTimeoutMs);
335
+ pending.set(request, timer);
336
+ const handled = emitNetworkRequest(request, headers, traceId, requestId).catch((error) => {
337
+ // Defence in depth. `resolveInitiatorStack` now absorbs the one failure
338
+ // that actually reached here in practice, so anything still arriving is
339
+ // unanticipated — which is exactly the case that most needs disclosing
340
+ // rather than logging to a console nobody reads. The row is still lost
341
+ // (whatever threw did so before `emit`), but the loss is now on the
342
+ // record instead of being indistinguishable from a request that never
343
+ // happened.
344
+ emitCollectorError(`request handling failed, so no NETWORK_REQUEST row was recorded for ${request.method()} ${request.url()}`, error);
345
+ console.error(`[browser-network:${session.sessionId}] request handling failed: ${String(error)}`);
346
+ });
347
+ inFlight.add(handled);
348
+ void handled.finally(() => inFlight.delete(handled));
349
+ }
350
+ async function onRequestFinished(request) {
351
+ const alreadyTimedOut = !pending.has(request);
352
+ clearPending(request);
353
+ if (alreadyTimedOut)
354
+ return; // this collector already reported it as timed out; a late real completion is not double-reported
355
+ const response = await request.response();
356
+ if (response === null)
357
+ return; // finished with no response is a real, if rare, Playwright outcome -- nothing to report
358
+ const headers = await response.allHeaders().catch(() => response.headers());
359
+ // **A response inherits its own request's correlation ids.** This read
360
+ // the response headers alone, and the result was that a NETWORK_REQUEST
361
+ // carried `x-request-id` while the NETWORK_RESPONSE and HTTP_ERROR for
362
+ // the very same exchange carried null — because a server is under no
363
+ // obligation to echo a header the client sent, and most do not.
364
+ //
365
+ // RT-044 measured what that cost: on the real fixture, one source landed
366
+ // in the real request bucket and two fell into the unscoped pool, which
367
+ // was the only route to `confirmed` and the reason the gate had to be
368
+ // tightened. This is the other half of that finding — the half that adds
369
+ // attribution rather than removing a false one.
370
+ //
371
+ // **This is not proxy matching.** `request` is the Playwright Request
372
+ // object this response belongs to, not one selected by matching a URL or
373
+ // a timestamp. It is the same HTTP exchange, so carrying its id forward
374
+ // is bookkeeping, not inference — which is exactly what separates it
375
+ // from joining a console message to a request by URL, something this
376
+ // collector still cannot honestly do.
377
+ //
378
+ // Per field, and the response wins: a server that assigns or rewrites an
379
+ // id is authoritative about the request it actually processed, which is
380
+ // the id its logs will carry. The request's value fills a gap, never
381
+ // overrides.
382
+ const fromResponse = extractHeaderCorrelationIds(headers);
383
+ const fromRequest = extractHeaderCorrelationIds(request.headers());
384
+ const traceId = fromResponse.traceId ?? fromRequest.traceId;
385
+ const requestId = fromResponse.requestId ?? fromRequest.requestId;
386
+ /** Which side of the exchange each id came from — so a reader can audit the join rather than trust it. */
387
+ const correlationOrigin = {
388
+ traceId: fromResponse.traceId !== null ? "response-headers" : fromRequest.traceId !== null ? "request-headers" : null,
389
+ requestId: fromResponse.requestId !== null ? "response-headers" : fromRequest.requestId !== null ? "request-headers" : null,
390
+ };
391
+ const status = response.status();
392
+ const body = await captureResponseBody(response, headers);
393
+ const basePayload = {
394
+ status,
395
+ statusText: response.statusText(),
396
+ url: response.url(),
397
+ headers,
398
+ body,
399
+ timing: request.timing(),
400
+ browserSessionId: session.sessionId,
401
+ correlationOrigin,
402
+ };
403
+ emit?.({
404
+ timestamp: new Date().toISOString(),
405
+ source,
406
+ service: options.service ?? null,
407
+ process: null,
408
+ eventType: "NETWORK_RESPONSE",
409
+ payload: basePayload,
410
+ traceId,
411
+ requestId,
412
+ correlationId: null,
413
+ graphNodeId: null,
414
+ sourceLocation: null,
415
+ stackTrace: null,
416
+ confidence: 1,
417
+ redactionStatus: "pending-redaction",
418
+ collectorVersion: COLLECTOR_VERSION,
419
+ });
420
+ if (status >= 400) {
421
+ emit?.({
422
+ timestamp: new Date().toISOString(),
423
+ source,
424
+ service: options.service ?? null,
425
+ process: null,
426
+ eventType: "HTTP_ERROR",
427
+ payload: { ...basePayload, method: request.method() },
428
+ traceId,
429
+ requestId,
430
+ correlationId: null,
431
+ graphNodeId: null,
432
+ sourceLocation: null,
433
+ stackTrace: null,
434
+ confidence: 1,
435
+ redactionStatus: "pending-redaction",
436
+ collectorVersion: COLLECTOR_VERSION,
437
+ });
438
+ }
439
+ }
440
+ function onRequestFailed(request) {
441
+ const alreadyTimedOut = !pending.has(request);
442
+ clearPending(request);
443
+ if (alreadyTimedOut)
444
+ return; // already reported as a collector timeout; a late Playwright failure is not double-reported
445
+ const headers = request.headers();
446
+ const { traceId, requestId } = extractHeaderCorrelationIds(headers);
447
+ emit?.({
448
+ timestamp: new Date().toISOString(),
449
+ source,
450
+ service: options.service ?? null,
451
+ process: null,
452
+ eventType: "HTTP_ERROR",
453
+ payload: {
454
+ // No response ever arrived -- status: null distinguishes this from
455
+ // a real HTTP error status, which onRequestFinished handles.
456
+ status: null,
457
+ method: request.method(),
458
+ url: request.url(),
459
+ networkError: request.failure()?.errorText ?? "unknown network failure",
460
+ browserSessionId: session.sessionId,
461
+ },
462
+ traceId,
463
+ requestId,
464
+ correlationId: null,
465
+ graphNodeId: null,
466
+ sourceLocation: null,
467
+ stackTrace: null,
468
+ confidence: 1,
469
+ redactionStatus: "pending-redaction",
470
+ collectorVersion: COLLECTOR_VERSION,
471
+ });
472
+ }
473
+ /**
474
+ * A WebSocket connection attempt -- `page.on("websocket")` firing.
475
+ * Reuses `NETWORK_REQUEST` (see this file's module doc, RT-225): a real
476
+ * outbound call was made to `ws.url()`, the same fact that type already
477
+ * exists to record. No `method`/`headers`/`postData` are invented --
478
+ * Playwright's `WebSocket` object exposes none of those, so they are
479
+ * simply absent from the payload rather than null-padded to look like a
480
+ * fetch/XHR request. `traceId`/`requestId` stay null: unlike `fetch()`,
481
+ * a page cannot attach a custom header to a WebSocket handshake, so
482
+ * there is no channel for this collector's own correlation id to ride on
483
+ * (`frontend-initiator-capture.ts`'s mechanism is fetch-only for the
484
+ * identical reason).
485
+ */
486
+ function emitWebSocketAttempt(ws) {
487
+ emit?.({
488
+ timestamp: new Date().toISOString(),
489
+ source,
490
+ service: options.service ?? null,
491
+ process: null,
492
+ eventType: "NETWORK_REQUEST",
493
+ payload: {
494
+ url: ws.url(),
495
+ protocol: "websocket",
496
+ browserSessionId: session.sessionId,
497
+ },
498
+ traceId: null,
499
+ requestId: null,
500
+ correlationId: null,
501
+ graphNodeId: null,
502
+ sourceLocation: null,
503
+ stackTrace: null,
504
+ confidence: 1,
505
+ redactionStatus: "pending-redaction",
506
+ collectorVersion: COLLECTOR_VERSION,
507
+ });
508
+ }
509
+ /**
510
+ * A WebSocket-level failure -- `socketerror`. Reuses `HTTP_ERROR` with
511
+ * `status: null`, the identical "no usable response ever came back"
512
+ * convention `onRequestFailed` above already uses for a refused
513
+ * `fetch()`: this is the same class of fact (an outbound network call
514
+ * that never got a usable response) under a different wire protocol, not
515
+ * a different fact requiring a different type.
516
+ */
517
+ function emitWebSocketError(ws, errorText) {
518
+ emit?.({
519
+ timestamp: new Date().toISOString(),
520
+ source,
521
+ service: options.service ?? null,
522
+ process: null,
523
+ eventType: "HTTP_ERROR",
524
+ payload: {
525
+ // No response ever arrived, and there is no HTTP status for a
526
+ // WebSocket handshake failure in the first place -- status: null
527
+ // is the same "never got a response" bucket onRequestFailed uses,
528
+ // not a WS-specific meaning bolted onto it.
529
+ status: null,
530
+ url: ws.url(),
531
+ protocol: "websocket",
532
+ networkError: errorText,
533
+ browserSessionId: session.sessionId,
534
+ },
535
+ traceId: null,
536
+ requestId: null,
537
+ correlationId: null,
538
+ graphNodeId: null,
539
+ sourceLocation: null,
540
+ stackTrace: null,
541
+ confidence: 1,
542
+ redactionStatus: "pending-redaction",
543
+ collectorVersion: COLLECTOR_VERSION,
544
+ });
545
+ }
546
+ /**
547
+ * A WebSocket closed with no prior `socketerror` reported for it. Its own
548
+ * type, `WEBSOCKET_CLOSED` (`runtime-event.ts`) -- see that type's doc
549
+ * for why forcing this into `NETWORK_RESPONSE` would misrepresent what
550
+ * was actually observed (no status, no body, nothing HTTP happened) and
551
+ * why "normal" vs. "abnormal" is not claimed either (measured: Playwright
552
+ * exposes no close code or reason on this event at all).
553
+ */
554
+ function emitWebSocketClosed(ws) {
555
+ emit?.({
556
+ timestamp: new Date().toISOString(),
557
+ source,
558
+ service: options.service ?? null,
559
+ process: null,
560
+ eventType: "WEBSOCKET_CLOSED",
561
+ payload: {
562
+ url: ws.url(),
563
+ protocol: "websocket",
564
+ browserSessionId: session.sessionId,
565
+ },
566
+ traceId: null,
567
+ requestId: null,
568
+ correlationId: null,
569
+ graphNodeId: null,
570
+ sourceLocation: null,
571
+ stackTrace: null,
572
+ confidence: 1,
573
+ redactionStatus: "pending-redaction",
574
+ collectorVersion: COLLECTOR_VERSION,
575
+ });
576
+ }
577
+ function onWebSocket(ws) {
578
+ const tracked = {
579
+ reported: false,
580
+ onSocketError: (error) => {
581
+ const entry = activeSockets.get(ws);
582
+ if (entry !== undefined)
583
+ entry.reported = true;
584
+ emitWebSocketError(ws, error);
585
+ },
586
+ onClose: () => {
587
+ const entry = activeSockets.get(ws);
588
+ activeSockets.delete(ws);
589
+ // A failure already reported this exact socket via HTTP_ERROR --
590
+ // `close` always follows a failed connection, and reporting it
591
+ // again here would double-report the identical failure under a
592
+ // second eventType, the same double-report `onRequestFinished`/
593
+ // `onRequestFailed` guard against via `alreadyTimedOut` above.
594
+ if (entry?.reported !== true)
595
+ emitWebSocketClosed(ws);
596
+ },
597
+ };
598
+ activeSockets.set(ws, tracked);
599
+ ws.on("socketerror", tracked.onSocketError);
600
+ ws.on("close", tracked.onClose);
601
+ emitWebSocketAttempt(ws);
602
+ }
603
+ function onRequestFinishedHandler(request) {
604
+ // Playwright event handlers don't propagate a returned promise's
605
+ // rejection to the emitter -- an unhandled rejection here would
606
+ // silently vanish rather than surface. Caught and logged rather than
607
+ // crashing the page's event loop, same failure-containment choice
608
+ // LogCollector's background consumption already makes.
609
+ const handled = onRequestFinished(request).catch((error) => {
610
+ console.error(`[browser-network:${session.sessionId}] requestfinished handling failed: ${String(error)}`);
611
+ });
612
+ // **Tracked, for the identical reason `onRequest` above is.** This
613
+ // handler is asynchronous the whole way down -- `request.response()`,
614
+ // `response.allHeaders()` and `captureResponseBody()` are each an IPC
615
+ // round-trip to the browser -- while Playwright's listener is not. Left
616
+ // untracked, a `NETWORK_RESPONSE` for a response the browser had already
617
+ // fully received met an `emit` that `stop()` had set to `undefined`
618
+ // underneath it, and the row was gone with nothing to say so. That is
619
+ // RT-023's regression exactly, one listener over in the same file: the
620
+ // fix was applied to `request` and not to `requestfinished`.
621
+ inFlight.add(handled);
622
+ void handled.finally(() => inFlight.delete(handled));
623
+ }
624
+ return {
625
+ collectorId: `browser-network:${session.sessionId}`,
626
+ async start(context) {
627
+ if (!session.isUsable()) {
628
+ return { available: false, reason: "the browser session's page has already crashed or closed." };
629
+ }
630
+ emit = context.emit;
631
+ resolveSourceRoot = context.resolveSourceRoot;
632
+ networkTimeoutMs = options.networkTimeoutMs ?? context.configuration.timeoutMs ?? DEFAULT_NETWORK_TIMEOUT_MS;
633
+ stacksParsed = options.stackTraceParser !== undefined && context.resolveSourceRoot !== undefined;
634
+ if (stacksParsed) {
635
+ initiatorCapture = createFrontendInitiatorCapture();
636
+ // Must be installed (and, per `page.addInitScript`'s own contract,
637
+ // installed before any navigation this execution wants covered) —
638
+ // start() being async is exactly what lets a caller await this
639
+ // before calling page.goto(), the identical ordering
640
+ // BrowserConsoleCollector's own page.on() registration already
641
+ // relies on.
642
+ await initiatorCapture.install(page);
643
+ }
644
+ page.on("request", onRequest);
645
+ page.on("requestfinished", onRequestFinishedHandler);
646
+ page.on("requestfailed", onRequestFailed);
647
+ page.on("websocket", onWebSocket);
648
+ return { available: true };
649
+ },
650
+ async stop() {
651
+ page.off("request", onRequest);
652
+ page.off("requestfinished", onRequestFinishedHandler);
653
+ page.off("requestfailed", onRequestFailed);
654
+ page.off("websocket", onWebSocket);
655
+ for (const timer of pending.values())
656
+ clearTimeout(timer);
657
+ pending.clear();
658
+ // `page.off("websocket", ...)` above only stops new sockets from
659
+ // being tracked -- a socket already open keeps its own `socketerror`/
660
+ // `close` listeners live until they are individually detached here,
661
+ // the same "no removeAllListeners on this interface" reason
662
+ // `TrackedSocket` keeps the exact listener references it registered.
663
+ for (const [ws, tracked] of activeSockets) {
664
+ ws.off("socketerror", tracked.onSocketError);
665
+ ws.off("close", tracked.onClose);
666
+ }
667
+ activeSockets.clear();
668
+ // Listeners off, timers cleared, THEN drain in-flight stack
669
+ // resolution, THEN clear emit -- draining before clearing is the
670
+ // RT-023-regression fix `BrowserConsoleCollector.stop()` already
671
+ // applies, for the identical reason: a NETWORK_REQUEST whose stack
672
+ // was still resolving when stop() was called must not be silently
673
+ // dropped by `emit?.()` becoming a no-op out from under it.
674
+ await Promise.allSettled([...inFlight]);
675
+ emit = undefined;
676
+ },
677
+ capabilities() {
678
+ return stacksParsed ? CAPABILITIES_WITH_STACKS : CAPABILITIES;
679
+ },
680
+ };
681
+ }
682
+ //# sourceMappingURL=browser-network-collector.js.map