@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.
- package/dist/browser-action-collector.d.ts +181 -0
- package/dist/browser-action-collector.d.ts.map +1 -0
- package/dist/browser-action-collector.js +427 -0
- package/dist/browser-action-collector.js.map +1 -0
- package/dist/browser-console-collector.d.ts +33 -0
- package/dist/browser-console-collector.d.ts.map +1 -0
- package/dist/browser-console-collector.js +185 -0
- package/dist/browser-console-collector.js.map +1 -0
- package/dist/browser-network-collector.d.ts +112 -0
- package/dist/browser-network-collector.d.ts.map +1 -0
- package/dist/browser-network-collector.js +682 -0
- package/dist/browser-network-collector.js.map +1 -0
- package/dist/browser-session.d.ts +144 -0
- package/dist/browser-session.d.ts.map +1 -0
- package/dist/browser-session.js +136 -0
- package/dist/browser-session.js.map +1 -0
- package/dist/collector-version.d.ts +10 -0
- package/dist/collector-version.d.ts.map +1 -0
- package/dist/collector-version.js +12 -0
- package/dist/collector-version.js.map +1 -0
- package/dist/dom-evidence.d.ts +20 -0
- package/dist/dom-evidence.d.ts.map +1 -0
- package/dist/dom-evidence.js +32 -0
- package/dist/dom-evidence.js.map +1 -0
- package/dist/frontend-initiator-capture.d.ts +87 -0
- package/dist/frontend-initiator-capture.d.ts.map +1 -0
- package/dist/frontend-initiator-capture.js +142 -0
- package/dist/frontend-initiator-capture.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/network-correlation.d.ts +15 -0
- package/dist/network-correlation.d.ts.map +1 -0
- package/dist/network-correlation.js +29 -0
- package/dist/network-correlation.js.map +1 -0
- package/dist/script-url-mapping.d.ts +40 -0
- package/dist/script-url-mapping.d.ts.map +1 -0
- package/dist/script-url-mapping.js +96 -0
- package/dist/script-url-mapping.js.map +1 -0
- 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
|