@zerotal/devtools 1.6.3 → 1.7.2

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 (45) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/api-surface.md +298 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +106 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +127 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +87 -0
  13. package/src/client/state.ts +350 -0
  14. package/src/client/tabs/all.ts +323 -0
  15. package/src/client/tabs/app.ts +293 -0
  16. package/src/client/tabs/cache.ts +50 -0
  17. package/src/client/tabs/channel.ts +264 -0
  18. package/src/client/tabs/exceptions.ts +69 -0
  19. package/src/client/tabs/jobs.ts +51 -0
  20. package/src/client/tabs/live.ts +66 -0
  21. package/src/client/tabs/logs.ts +45 -0
  22. package/src/client/tabs/mail.ts +60 -0
  23. package/src/client/tabs/queries.ts +125 -0
  24. package/src/client/tabs/request.ts +75 -0
  25. package/src/client/tabs/sections.ts +115 -0
  26. package/src/client/tabs/timeline.ts +133 -0
  27. package/src/client/tabs/types.ts +68 -0
  28. package/src/client/transport.ts +81 -0
  29. package/src/client/tree.ts +138 -0
  30. package/src/client/ui/format.ts +137 -0
  31. package/src/client/ui/render.ts +87 -0
  32. package/src/client/ui/shell.ts +560 -0
  33. package/src/client/ui/theme.ts +445 -0
  34. package/src/client-auto.ts +1 -1
  35. package/src/config.ts +77 -2
  36. package/src/dashboard-auto.ts +1 -1
  37. package/src/editor.ts +107 -0
  38. package/src/enabled.ts +59 -0
  39. package/src/index.ts +19 -3
  40. package/src/map.ts +213 -0
  41. package/src/provider/DevtoolsProvider.ts +32 -7
  42. package/src/redaction.ts +161 -20
  43. package/src/tracing.ts +261 -29
  44. package/src/client.ts +0 -1048
  45. package/src/panel-app.js +0 -519
package/src/redaction.ts CHANGED
@@ -1,19 +1,30 @@
1
1
  /**
2
- * Query-binding redaction.
2
+ * Trace redaction.
3
3
  *
4
4
  * A trace does not stay on screen: it is streamed to the browser and written to
5
- * `.zerotal/devtools.sqlite`, where it sits for a day. Bindings are the request's
6
- * actual values — the password on a registration, a reset token, a session
7
- * payload, every customer email a listing selects by. An ephemeral dev panel and
8
- * a plaintext file with a day's worth of credentials in it are different risks,
9
- * so the values are masked by default and you opt individual columns back in.
5
+ * `.zerotal/devtools.sqlite`, where it sits for a day. What it carries is the
6
+ * request's actual values — the password on a registration, a reset token, a
7
+ * session payload, every customer email a listing selects by. An ephemeral dev
8
+ * panel and a plaintext file with a day's worth of credentials in it are
9
+ * different risks, so those values are masked by default and you opt individual
10
+ * names back in.
10
11
  *
11
- * Matching is on the column each binding belongs to, recovered by pairing the
12
- * SQL's placeholders with the identifiers around them. When a binding cannot be
13
- * attributed to a column a raw expression, a dialect this does not parse it
14
- * is masked, because guessing wrong in the other direction is what writes a
15
- * password to disk.
12
+ * Three entry points, one rule. {@link redactBindings} masks query bindings by
13
+ * the column each belongs to, recovered by pairing the SQL's placeholders with
14
+ * the identifiers around them; when a binding cannot be attributed to a column
15
+ * a raw expression, a dialect this does not parse it is masked, because
16
+ * guessing wrong in the other direction is what writes a password to disk.
17
+ * {@link redactValue} walks anything with named fields (a channel entry, a
18
+ * logged object) and masks by field name. {@link redactCacheKey} masks the tail
19
+ * of a cache key whose name says it holds a secret.
20
+ *
21
+ * All three share {@link isSensitiveName}, so `allow` and `deny` in the app's
22
+ * config mean the same thing everywhere. The walk itself is `redactGraph` from
23
+ * `@zerotal/core/security` — the same one the Inertia recorder runs, which is
24
+ * how two recorders that must not agree on their *markers* still agree on how a
25
+ * cycle, a depth limit, and a nested secret are handled.
16
26
  */
27
+ import { redactGraph } from "@zerotal/core/security";
17
28
 
18
29
  export interface RedactionOptions {
19
30
  /**
@@ -22,12 +33,13 @@ export interface RedactionOptions {
22
33
  */
23
34
  enabled?: boolean;
24
35
  /**
25
- * Column names whose values are safe to show in full. Matched
26
- * case-insensitively against the column each binding belongs to.
36
+ * Names whose values are safe to show in full. Matched case-insensitively
37
+ * against the column a binding belongs to, a channel entry's field name, or a
38
+ * cache key's segment.
27
39
  */
28
40
  allow?: string[];
29
41
  /**
30
- * Extra column names to mask, added to the built-in list.
42
+ * Extra names to mask, added to the built-in list.
31
43
  */
32
44
  deny?: string[];
33
45
  }
@@ -64,9 +76,141 @@ const SENSITIVE = [
64
76
  /** Columns always shown: structural values that make a trace readable at all. */
65
77
  const STRUCTURAL = ["id", "created_at", "updated_at", "deleted_at"];
66
78
 
67
- /** What a masked binding is replaced with. */
79
+ /** What a masked value is replaced with. */
68
80
  const MASK = "‹redacted›";
69
81
 
82
+ /** Stand-ins for values a walk cannot render rather than will not. */
83
+ const CIRCULAR = "‹circular›";
84
+ const TOO_DEEP = "‹truncated›";
85
+
86
+ /**
87
+ * How far {@link redactValue} descends before it stops.
88
+ *
89
+ * A panel row shows a few fields, not an object graph, and the walk runs on
90
+ * every entry of every request — so the cap is about the cost of the walk as
91
+ * much as the size of what comes out of it.
92
+ */
93
+ const MAX_DEPTH = 6;
94
+
95
+ /** The allow/deny sets a redaction pass runs against. */
96
+ interface Rules {
97
+ allow: Set<string>;
98
+ deny: string[];
99
+ }
100
+
101
+ function _rules(options: RedactionOptions): Rules {
102
+ return {
103
+ allow: new Set((options.allow ?? []).map((c) => c.toLowerCase())),
104
+ deny: [...SENSITIVE, ...(options.deny ?? []).map((c) => c.toLowerCase())],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Whether a name means "the value under me is a secret".
110
+ *
111
+ * The one place the rule lives, so a column, a channel field, and a cache-key
112
+ * segment are judged the same way — and so `allow`/`deny` in the app's config
113
+ * cannot mean one thing on the Queries tab and another on the Cache tab.
114
+ *
115
+ * Matching is by substring, which is why `password` covers `password_hash`. It
116
+ * also means `author_id` matches `auth`; that is the trade the built-in list
117
+ * makes, and `allow: ['author_id']` opts it back in.
118
+ */
119
+ export function isSensitiveName(name: string, options: RedactionOptions = {}): boolean {
120
+ return _sensitive(name, _rules(options));
121
+ }
122
+
123
+ function _sensitive(name: string, { allow, deny }: Rules): boolean {
124
+ const key = name.toLowerCase();
125
+ if (allow.has(key)) return false;
126
+ if (STRUCTURAL.includes(key)) return false;
127
+ return deny.some((s) => key.includes(s));
128
+ }
129
+
130
+ /**
131
+ * Mask every field of `value` whose name says it holds a secret.
132
+ *
133
+ * Used at the sink boundary for the things that arrive as named fields rather
134
+ * than as SQL — a channel entry a package recorded, an object someone passed to
135
+ * `console.log`. A bare scalar has no name to judge it by and comes back
136
+ * unchanged; masking on the *contents* of a string is a different, guessier job
137
+ * this deliberately does not attempt.
138
+ *
139
+ * Cycles and over-deep branches are replaced rather than followed, so the result
140
+ * is always safe to `JSON.stringify` — which is the other half of why the log
141
+ * capture calls this: a circular argument used to throw out of the console patch.
142
+ *
143
+ * @returns A new value; the input is not modified.
144
+ */
145
+ export function redactValue(value: unknown, options: RedactionOptions = {}): unknown {
146
+ if (options.enabled === false) return value;
147
+ const rules = _rules(options);
148
+ return redactGraph(value, {
149
+ sensitive: (key) => _sensitive(key, rules),
150
+ mask: MASK,
151
+ circular: CIRCULAR,
152
+ tooDeep: TOO_DEEP,
153
+ maxDepth: MAX_DEPTH,
154
+ flatten: _flatten,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Three shapes that read better named than walked, and that a devtools entry
160
+ * carries often enough to be worth naming. A function is here because
161
+ * `JSON.stringify` drops the key it sits under, and a log line that quietly
162
+ * loses a field is the kind of wrongness a debugging tool must not have.
163
+ */
164
+ function _flatten(value: unknown): string | undefined {
165
+ if (value instanceof Date) return value.toISOString();
166
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
167
+ if (typeof value === "function") return `‹fn ${value.name || "anonymous"}›`;
168
+ return undefined;
169
+ }
170
+
171
+ /**
172
+ * What separates a cache key's *name* from its *value*, kept in the split so the
173
+ * key rebuilds.
174
+ *
175
+ * Deliberately not `_` or `-`: those sit inside a name — `password_reset` is one
176
+ * word — so splitting on them would mask `reset` and leave the row unreadable
177
+ * without protecting anything.
178
+ */
179
+ const KEY_PARTS = /([:|/.])/;
180
+
181
+ /**
182
+ * Mask the identifying tail of a cache key whose name says it holds a secret.
183
+ *
184
+ * A cache key is a name and a value welded together: `password_reset:9f2c…` *is*
185
+ * the reset token. Masking the whole key would leave the Cache tab a column of
186
+ * `‹redacted›` with nothing to read it by, so only what follows the sensitive
187
+ * segment goes — the name that makes the row identifiable stays.
188
+ *
189
+ * @returns The key with its tail masked, or the key unchanged.
190
+ */
191
+ export function redactCacheKey(key: string, options: RedactionOptions = {}): string {
192
+ if (options.enabled === false) return key;
193
+ if (!key) return key;
194
+
195
+ const rules = _rules(options);
196
+ const parts = key.split(KEY_PARTS);
197
+ let masked = false;
198
+
199
+ const out = parts.map((part, i) => {
200
+ // Odd indices are the captured separators; they hold no value.
201
+ if (i % 2 === 1) return part;
202
+ if (masked) return part ? MASK : part;
203
+ if (_sensitive(part, rules)) masked = true;
204
+ return part;
205
+ });
206
+
207
+ // A key with no separator to split a name from its value — `sessionabc123` —
208
+ // cannot be trimmed to its name, so it goes whole. Naming a value we cannot
209
+ // clear is the wrong half to keep.
210
+ if (!masked) return key;
211
+ return out.length === 1 ? MASK : out.join("");
212
+ }
213
+
70
214
  /**
71
215
  * Mask the bindings of `sql` that belong to a sensitive column.
72
216
  *
@@ -83,8 +227,7 @@ export function redactBindings(
83
227
  if (options.enabled === false) return bindings;
84
228
  if (!Array.isArray(bindings) || bindings.length === 0) return bindings;
85
229
 
86
- const allow = new Set((options.allow ?? []).map((c) => c.toLowerCase()));
87
- const deny = [...SENSITIVE, ...(options.deny ?? []).map((c) => c.toLowerCase())];
230
+ const rules = _rules(options);
88
231
  const columns = attributeBindings(sql, bindings.length);
89
232
 
90
233
  return bindings.map((value, i) => {
@@ -93,9 +236,7 @@ export function redactBindings(
93
236
  // An unattributable binding is masked: a value we cannot name is a value we
94
237
  // cannot clear.
95
238
  if (!column) return MASK;
96
- if (allow.has(column)) return value;
97
- if (STRUCTURAL.includes(column)) return value;
98
- return deny.some((s) => column.includes(s)) ? MASK : value;
239
+ return _sensitive(column, rules) ? MASK : value;
99
240
  });
100
241
  }
101
242
 
package/src/tracing.ts CHANGED
@@ -19,10 +19,27 @@
19
19
  *
20
20
  * Console patching is handled separately via startConsoleCapture() since
21
21
  * console.log is a hook (interception) not a broadcast event.
22
+ *
23
+ * ## This package's cast boundary
24
+ *
25
+ * Listed under `boundaries` in `cast-baseline.json`, so the per-file cast ratchet
26
+ * does not apply here. That is deliberate and confined to this file, for two
27
+ * invariants nothing in the type system can express:
28
+ *
29
+ * 1. **`HttpContext` is read structurally.** `ctx.session`, `ctx.user` and
30
+ * `ctx._routeDef` are contributed by packages devtools does not import — that
31
+ * independence is the whole design, and it is why an app without the session
32
+ * middleware simply has no `session` to read. Every such read is guarded and
33
+ * falls back to empty; a missing property is the ordinary case, not an error.
34
+ * 2. **`console` is patched by name.** Replacing `console[level]` at runtime is
35
+ * interception, not a typed call, so the index has to be untyped.
36
+ *
37
+ * Nothing else in `@zerotal/devtools` is exempt. A cast that wants to live
38
+ * somewhere other than this file is a cast to remove.
22
39
  */
23
40
 
24
41
  import { FrameworkEvents, RequestContext } from "@zerotal/core";
25
- import type { RequestHandled, RequestFailed } from "@zerotal/core";
42
+ import type { RequestHandled, RequestFailed, OutgoingRequestCompleted } from "@zerotal/core";
26
43
  import type { HttpContext } from "@zerotal/core";
27
44
  import type {
28
45
  QuerySpan,
@@ -31,19 +48,26 @@ import type {
31
48
  MailEntry,
32
49
  CacheEntry,
33
50
  JobEntry,
51
+ ExceptionInfo,
34
52
  RequestTrace,
35
53
  TraceChannelDescriptor,
36
54
  TraceChannelEntry,
37
55
  } from "./RequestTrace.ts";
38
56
  import { traceStore } from "./TraceStore.ts";
39
- import { redactBindings, type RedactionOptions } from "./redaction.ts";
57
+ import { captureCallSite, parseStack } from "./callsite.ts";
58
+ import { redactBindings, redactCacheKey, redactValue, type RedactionOptions } from "./redaction.ts";
40
59
 
41
60
  // ── Per-context event buffers ─────────────────────────────────────────────────
42
61
  // Events are buffered for the full request lifetime (including phases that run
43
62
  // before DevtoolsInjectionMiddleware, e.g. AuthMiddleware loading the user).
44
63
  // Buffers are GC'd with the HttpContext via WeakMap.
45
64
 
46
- type _BufLog = { level: LogEntry["level"]; args: string[]; absMs: number };
65
+ type _BufLog = {
66
+ level: LogEntry["level"];
67
+ args: string[];
68
+ absMs: number;
69
+ source?: LogEntry["source"];
70
+ };
47
71
  type _BufMail = Omit<MailEntry, "offsetMs"> & { absMs: number };
48
72
  type _BufCache = Omit<CacheEntry, "offsetMs"> & { absMs: number };
49
73
  type _BufJob = Omit<JobEntry, "offsetMs"> & { absMs: number };
@@ -70,11 +94,16 @@ export function _bufPush<T>(map: WeakMap<object, T[]>, ctx: object, item: T): vo
70
94
 
71
95
  const _channels = new Map<string, TraceChannelDescriptor>();
72
96
 
73
- /** Every channel declared so far, in display order. */
97
+ /**
98
+ * Every channel that wants a tab, in display order.
99
+ *
100
+ * A `hidden` channel is left out: its entries are still recorded and still reach
101
+ * the panel on the trace, but whatever renders them is not this generic row list.
102
+ */
74
103
  export function traceChannels(): TraceChannelDescriptor[] {
75
- return [...(_channels.values() as Iterable<TraceChannelDescriptor>)].sort(
76
- (a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label),
77
- );
104
+ return [...(_channels.values() as Iterable<TraceChannelDescriptor>)]
105
+ .filter((c) => !c.hidden)
106
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
78
107
  }
79
108
 
80
109
  /** @internal — drop every declared channel (provider teardown, tests). */
@@ -82,7 +111,7 @@ export function _resetChannels(): void {
82
111
  _channels.clear();
83
112
  }
84
113
 
85
- // ── Redaction ─────────────────────────────────────────────────────────────────
114
+ // ── Capture settings ──────────────────────────────────────────────────────────
86
115
 
87
116
  let _redaction: RedactionOptions = {};
88
117
 
@@ -91,6 +120,31 @@ export function _setRedaction(options: RedactionOptions): void {
91
120
  _redaction = options;
92
121
  }
93
122
 
123
+ /**
124
+ * Whether to walk the stack for each query and log line.
125
+ *
126
+ * A field rather than a config read per event: this runs on the hot path of a
127
+ * request running forty queries, and resolving config forty times to answer the
128
+ * same question would cost more than the walk it is guarding.
129
+ */
130
+ let _captureSource = true;
131
+
132
+ /** @internal — set by DevtoolsProvider from the app's `devtools` config. */
133
+ export function _setCaptureSource(enabled: boolean): void {
134
+ _captureSource = enabled;
135
+ }
136
+
137
+ /**
138
+ * Request headers recorded beyond the built-in safe list, lower-cased.
139
+ * `"*"` records every header the redaction rules do not mask.
140
+ */
141
+ let _extraHeaders = new Set<string>();
142
+
143
+ /** @internal — set by DevtoolsProvider from the app's `devtools` config. */
144
+ export function _setHeaderAllowlist(headers: string[]): void {
145
+ _extraHeaders = new Set(headers.map((h) => h.toLowerCase()));
146
+ }
147
+
94
148
  // ── The sink feature packages contribute to ───────────────────────────────────
95
149
 
96
150
  /**
@@ -115,6 +169,25 @@ export interface TraceSink {
115
169
  * picks up the entries already buffered for the request in flight.
116
170
  */
117
171
  record(ctx: object, channel: string, entry: Record<string, unknown>): void;
172
+ /**
173
+ * Turn a context into a trace, for work that never was an HTTP request.
174
+ *
175
+ * Traces are normally finalised from core's `RequestHandled` / `RequestFailed`,
176
+ * which covers everything the HTTP kernel serves and nothing else. A Flow action
177
+ * arrives over a WebSocket and runs against its own `HttpContext` — as do queue
178
+ * jobs and scheduled tasks — so no HTTP lifecycle event ever fires for it, and
179
+ * without this call everything buffered against that context (its channel rows,
180
+ * but equally its queries, its logs, its N+1 warnings) accumulated and was
181
+ * dropped unread. The Flow tab could then only ever report "no flow activity",
182
+ * which is exactly what it did.
183
+ *
184
+ * `method` labels the trace in the request list, because the underlying request
185
+ * is synthetic: Flow passes `FLOW` so an action is not read as a second `GET` of
186
+ * the page it acted on.
187
+ *
188
+ * Finalising happens once per context; a second call for the same one is ignored.
189
+ */
190
+ finalise(ctx: object, meta: { startMs: number; durationMs: number; method?: string }): void;
118
191
  bufferQuery(ctx: object, q: QuerySpan): void;
119
192
  bufferWarning(ctx: object, w: NPlusOneWarning): void;
120
193
  bufferMail(ctx: object, m: Omit<MailEntry, "offsetMs">): void;
@@ -122,15 +195,35 @@ export interface TraceSink {
122
195
  bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void;
123
196
  }
124
197
 
198
+ // Everything below masks on the way *in*. Redacting in a renderer would protect
199
+ // nothing: by the time a panel draws a row, the unredacted copy has already been
200
+ // streamed to the browser and written to `.zerotal/devtools.sqlite`, where it
201
+ // sits for a day. The sink is the last point where "not recorded" is still true.
125
202
  export const traceSink: TraceSink = {
126
203
  channel(descriptor: TraceChannelDescriptor): void {
127
204
  _channels.set(descriptor.id, descriptor);
128
205
  },
129
206
  record(ctx: object, channel: string, entry: Record<string, unknown>): void {
130
- _bufPush(_ctxChannels, ctx, { channel, entry, absMs: Date.now() });
207
+ _bufPush(_ctxChannels, ctx, {
208
+ channel,
209
+ entry: redactValue(entry, _redaction) as Record<string, unknown>,
210
+ absMs: Date.now(),
211
+ });
212
+ },
213
+ finalise(ctx: object, meta: { startMs: number; durationMs: number; method?: string }): void {
214
+ _finaliseTrace(ctx as HttpContext, meta.startMs, meta.durationMs, null, meta.method);
131
215
  },
132
216
  bufferQuery(ctx: object, q: QuerySpan): void {
133
- _bufPush(_ctxQueries, ctx, { ...q, bindings: redactBindings(q.sql, q.bindings, _redaction) });
217
+ // The call site is captured here rather than at the emit site because here
218
+ // is the only place that knows whether anyone is recording. `skip` is 0: the
219
+ // frames above are this method and the ORM's bridge, both of which the
220
+ // framework filter drops anyway.
221
+ const source = _captureSource && !q.source ? captureCallSite() : q.source;
222
+ _bufPush(_ctxQueries, ctx, {
223
+ ...q,
224
+ bindings: redactBindings(q.sql, q.bindings, _redaction),
225
+ ...(source ? { source } : {}),
226
+ });
134
227
  },
135
228
  bufferWarning(ctx: object, w: NPlusOneWarning): void {
136
229
  _bufPush(_ctxWarnings, ctx, w);
@@ -139,7 +232,7 @@ export const traceSink: TraceSink = {
139
232
  _bufPush(_ctxMail, ctx, { ...m, absMs: Date.now() });
140
233
  },
141
234
  bufferCache(ctx: object, c: Omit<CacheEntry, "offsetMs">): void {
142
- _bufPush(_ctxCache, ctx, { ...c, absMs: Date.now() });
235
+ _bufPush(_ctxCache, ctx, { ...c, key: redactCacheKey(c.key, _redaction), absMs: Date.now() });
143
236
  },
144
237
  bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void {
145
238
  _bufPush(_ctxJobs, ctx, { ...j, absMs: Date.now() });
@@ -158,17 +251,38 @@ function _cleanupBuffers(ctx: object): void {
158
251
 
159
252
  // ── Trace builder ─────────────────────────────────────────────────────────────
160
253
 
254
+ /**
255
+ * Request headers recorded without being asked.
256
+ *
257
+ * An allowlist rather than a denylist because a trace is *persisted*: `cookie`
258
+ * and `authorization` are the request's credentials, and a header nobody thought
259
+ * to deny is a header on disk for a day. The cost is that the custom header you
260
+ * are actually debugging is invisible, which is what `devtools.headers` opens.
261
+ */
161
262
  const SAFE_HEADERS = new Set([
162
263
  "accept",
163
264
  "content-type",
265
+ "content-length",
164
266
  "user-agent",
165
267
  "referer",
268
+ "origin",
269
+ "accept-language",
166
270
  "x-request-id",
167
271
  "x-forwarded-for",
168
272
  "x-inertia",
169
273
  "x-inertia-version",
274
+ "x-requested-with",
170
275
  ]);
171
276
 
277
+ /** Never recorded, whatever the allowlist says — these *are* the credentials. */
278
+ const NEVER_HEADERS = new Set(["cookie", "set-cookie", "authorization", "proxy-authorization"]);
279
+
280
+ function _recordHeader(name: string): boolean {
281
+ const key = name.toLowerCase();
282
+ if (NEVER_HEADERS.has(key)) return false;
283
+ return SAFE_HEADERS.has(key) || _extraHeaders.has("*") || _extraHeaders.has(key);
284
+ }
285
+
172
286
  const INTERNAL_PREFIXES = ["/__flow/", "/__zerotal/", "/__dev/"];
173
287
 
174
288
  function _isInternal(path: string): boolean {
@@ -189,7 +303,35 @@ function _offset(absMs: number, startMs: number): number {
189
303
  return Math.max(0, absMs - startMs);
190
304
  }
191
305
 
192
- function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): RequestTrace {
306
+ /**
307
+ * What is in the session — the key names, never the values.
308
+ *
309
+ * "Is the CSRF token there, did the flash survive the redirect, is the user id
310
+ * set" are the session questions a request inspector is asked, and all three are
311
+ * answered by the keys. The values are the request's real state — the user's id,
312
+ * the token itself, whatever a form flashed — and this trace is written to disk
313
+ * for a day.
314
+ *
315
+ * Read structurally: devtools imports no feature package, and an app without the
316
+ * session middleware has no `ctx.session` at all.
317
+ */
318
+ function _sessionKeys(ctx: HttpContext): string[] {
319
+ try {
320
+ const session = (ctx as unknown as Record<string, unknown>)["session"] as
321
+ { _data?: Record<string, unknown> } | undefined;
322
+ return session?._data ? Object.keys(session._data).sort() : [];
323
+ } catch {
324
+ return [];
325
+ }
326
+ }
327
+
328
+ function _buildTrace(
329
+ ctx: HttpContext,
330
+ startMs: number,
331
+ durationMs: number,
332
+ exception: ExceptionInfo | null,
333
+ method?: string,
334
+ ): RequestTrace {
193
335
  const queryParams: Record<string, string> = {};
194
336
  ctx.url.searchParams.forEach((v, k) => {
195
337
  queryParams[k] = v;
@@ -197,7 +339,14 @@ function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): Req
197
339
 
198
340
  const headers: Record<string, string> = {};
199
341
  ctx.request.headers.forEach((v, k) => {
200
- if (SAFE_HEADERS.has(k.toLowerCase())) headers[k] = v;
342
+ if (_recordHeader(k)) headers[k] = v;
343
+ });
344
+
345
+ // The response half of the exchange: its status line and the headers it set.
346
+ // A request tab that shows only what came in answers half the question.
347
+ const responseHeaders: Record<string, string> = {};
348
+ ctx.response?.headers.forEach((v, k) => {
349
+ if (_recordHeader(k)) responseHeaders[k] = v;
201
350
  });
202
351
 
203
352
  const ctxRecord = ctx as unknown as Record<string, unknown>;
@@ -214,7 +363,7 @@ function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): Req
214
363
  return {
215
364
  id: crypto.randomUUID().slice(0, 12),
216
365
  requestId: ctx.requestId,
217
- method: ctx.request.method.toUpperCase(),
366
+ method: (method ?? ctx.request.method).toUpperCase(),
218
367
  path: ctx.url.pathname,
219
368
  statusCode: ctx.response?.status ?? 0,
220
369
  startMs,
@@ -222,14 +371,18 @@ function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): Req
222
371
  memory: _heapUsed(),
223
372
  queryParams,
224
373
  headers,
374
+ responseHeaders,
375
+ session: _sessionKeys(ctx),
225
376
  route: rd ? { pattern: rd.pattern, controller: rd.controller, action: rd.action } : null,
226
377
  auth: user ? { id: user["id"], name: user["name"], email: user["email"] } : null,
378
+ exception,
227
379
  queries: _ctxQueries.get(ctx) ?? [],
228
380
  warnings: _ctxWarnings.get(ctx) ?? [],
229
381
  logs: (_ctxLogs.get(ctx) ?? []).map((l) => ({
230
382
  level: l.level,
231
383
  args: l.args,
232
384
  offsetMs: _offset(l.absMs, startMs),
385
+ ...(l.source ? { source: l.source } : {}),
233
386
  })),
234
387
  mail: (_ctxMail.get(ctx) ?? []).map(({ absMs, ...rest }) => ({
235
388
  ...rest,
@@ -261,27 +414,86 @@ export function startDevtoolsTracing(): void {
261
414
 
262
415
  // Both successful and failed requests finalise the trace. Failed requests still
263
416
  // carry the rendered error response on ctx, so the trace records the error status
264
- // code like any other outcome. Everything else on the trace is buffered by
265
- // feature packages through `traceSink`.
417
+ // code like any other outcome and now the message with it, which used to be
418
+ // dropped here, leaving a red 500 in the panel with nothing to read next to it.
419
+ // Everything else on the trace is buffered by feature packages through `traceSink`.
266
420
  _unsubs = [
267
421
  FrameworkEvents.on<RequestHandled>("RequestHandled", (e) =>
268
- _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs),
269
- ),
270
- FrameworkEvents.on<RequestFailed>("RequestFailed", (e) =>
271
- _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs),
422
+ _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs, null),
272
423
  ),
424
+ FrameworkEvents.on<RequestFailed>("RequestFailed", (e) => {
425
+ const frames = parseStack(e.stack);
426
+ _finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs, {
427
+ message: e.error,
428
+ status: e.status,
429
+ ...(e.type ? { type: e.type } : {}),
430
+ ...(frames.length ? { frames } : {}),
431
+ });
432
+ }),
433
+ // Outgoing calls, recorded here rather than by a bridge in the package that
434
+ // owns them: the owner is `@zerotal/core`, and core cannot know about the
435
+ // channel API because devtools is what depends on core. Devtools already
436
+ // subscribes to core's events, so this costs no new dependency in either
437
+ // direction.
438
+ FrameworkEvents.on<OutgoingRequestCompleted>("OutgoingRequestCompleted", (e) => {
439
+ // The event carries no context — the client does not take one — so the
440
+ // request is read from the ambient scope it was called in.
441
+ const ctx = RequestContext.tryGet();
442
+ if (!ctx) return;
443
+ traceSink.record(ctx, "http", {
444
+ method: e.method,
445
+ url: e.url,
446
+ host: e.host,
447
+ status: e.status || "—",
448
+ durationMs: e.durationMs,
449
+ failed: !e.ok,
450
+ });
451
+ }),
273
452
  ];
453
+
454
+ // Declared here rather than in a satellite for the same reason. `order` puts
455
+ // it beside the other per-request feeds rather than at the back of the strip.
456
+ traceSink.channel({
457
+ id: "http",
458
+ label: "Outgoing",
459
+ badge: "method",
460
+ title: "url",
461
+ meta: ["status", "durationMs", "host"],
462
+ warn: "failed",
463
+ order: 35,
464
+ render: "table",
465
+ });
274
466
  }
275
467
 
468
+ /**
469
+ * Contexts already turned into a trace.
470
+ *
471
+ * One trace per context, enforced here rather than trusted: the HTTP lifecycle
472
+ * finalises exactly once, but a context that finalises *itself* — a Flow action,
473
+ * a queue job — could also be claimed by something else, and the second call
474
+ * would push a duplicate carrying none of the evidence, since the first cleaned
475
+ * the buffers out. Weak so it holds no context alive.
476
+ */
477
+ const _finalised = new WeakSet<object>();
478
+
276
479
  /** Merge buffered events into a trace and push it to the store (once per request). */
277
- function _finaliseTrace(ctx: HttpContext, startMs: number, durationMs: number): void {
480
+ function _finaliseTrace(
481
+ ctx: HttpContext,
482
+ startMs: number,
483
+ durationMs: number,
484
+ exception: ExceptionInfo | null,
485
+ method?: string,
486
+ ): void {
487
+ if (_finalised.has(ctx)) return;
488
+ _finalised.add(ctx);
489
+
278
490
  // Internal framework paths are noise — skip them
279
491
  if (_isInternal(ctx.url.pathname)) {
280
492
  _cleanupBuffers(ctx);
281
493
  return;
282
494
  }
283
495
 
284
- const trace = _buildTrace(ctx, startMs, durationMs);
496
+ const trace = _buildTrace(ctx, startMs, durationMs, exception, method);
285
497
  _cleanupBuffers(ctx);
286
498
  traceStore().push(trace);
287
499
  }
@@ -298,6 +510,28 @@ const LOG_LEVELS = ["log", "debug", "info", "warn", "error"] as const;
298
510
  let _origConsole: Partial<Record<string, unknown>> = {};
299
511
  let _consoleCaptured = false;
300
512
 
513
+ /**
514
+ * One logged argument, as the line the panel shows.
515
+ *
516
+ * Objects are redacted before they are serialised, not after: `console.log(user)`
517
+ * during a debug session used to write the whole record — password hash included
518
+ * — to disk for a day. Redacting also makes the value safe to serialise at all,
519
+ * since the walk replaces cycles; a circular argument used to throw a
520
+ * `Converting circular structure to JSON` out of this patch and into the caller's
521
+ * `console.log`.
522
+ */
523
+ function _formatLogArg(value: unknown): string {
524
+ if (typeof value === "string") return value;
525
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
526
+ const safe = redactValue(value, _redaction);
527
+ try {
528
+ return JSON.stringify(safe) ?? String(safe);
529
+ } catch {
530
+ // BigInt, a throwing toJSON — the log line is not worth failing the request.
531
+ return String(safe);
532
+ }
533
+ }
534
+
301
535
  /** @internal — patch console.* to capture log lines per request context */
302
536
  export function startConsoleCapture(): void {
303
537
  // Idempotent: a second start() without an intervening stop() would otherwise
@@ -319,16 +553,14 @@ export function startConsoleCapture(): void {
319
553
  orig(...args);
320
554
  const ctx = RequestContext.tryGet();
321
555
  if (!ctx) return;
556
+ // One frame to skip: this wrapper is standing between the caller and the
557
+ // stack, and without dropping it every log line would point at devtools.
558
+ const source = _captureSource ? captureCallSite(1) : null;
322
559
  _bufPush(_ctxLogs, ctx, {
323
560
  level,
324
- args: args.map((a) =>
325
- typeof a === "string"
326
- ? a
327
- : a instanceof Error
328
- ? `${a.name}: ${a.message}`
329
- : (JSON.stringify(a, null, 0) ?? String(a)),
330
- ),
561
+ args: args.map(_formatLogArg),
331
562
  absMs: Date.now(),
563
+ ...(source ? { source } : {}),
332
564
  });
333
565
  };
334
566
  }