@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.
- package/CHANGELOG.md +329 -0
- package/api-surface.md +298 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +106 -1
- package/src/TraceStore.ts +12 -0
- package/src/activity.ts +116 -0
- package/src/callsite.ts +146 -0
- package/src/client/filter.ts +108 -0
- package/src/client/index.ts +127 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +87 -0
- package/src/client/state.ts +350 -0
- package/src/client/tabs/all.ts +323 -0
- package/src/client/tabs/app.ts +293 -0
- package/src/client/tabs/cache.ts +50 -0
- package/src/client/tabs/channel.ts +264 -0
- package/src/client/tabs/exceptions.ts +69 -0
- package/src/client/tabs/jobs.ts +51 -0
- package/src/client/tabs/live.ts +66 -0
- package/src/client/tabs/logs.ts +45 -0
- package/src/client/tabs/mail.ts +60 -0
- package/src/client/tabs/queries.ts +125 -0
- package/src/client/tabs/request.ts +75 -0
- package/src/client/tabs/sections.ts +115 -0
- package/src/client/tabs/timeline.ts +133 -0
- package/src/client/tabs/types.ts +68 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +137 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +560 -0
- package/src/client/ui/theme.ts +445 -0
- package/src/client-auto.ts +1 -1
- package/src/config.ts +77 -2
- package/src/dashboard-auto.ts +1 -1
- package/src/editor.ts +107 -0
- package/src/enabled.ts +59 -0
- package/src/index.ts +19 -3
- package/src/map.ts +213 -0
- package/src/provider/DevtoolsProvider.ts +32 -7
- package/src/redaction.ts +161 -20
- package/src/tracing.ts +261 -29
- package/src/client.ts +0 -1048
- package/src/panel-app.js +0 -519
package/src/redaction.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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.
|
|
6
|
-
* actual values — the password on a registration, a reset token, a
|
|
7
|
-
* payload, every customer email a listing selects by. An ephemeral dev
|
|
8
|
-
* a plaintext file with a day's worth of credentials in it are
|
|
9
|
-
* so
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 = {
|
|
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
|
-
/**
|
|
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>)]
|
|
76
|
-
(
|
|
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
|
-
// ──
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
265
|
-
//
|
|
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(
|
|
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(
|
|
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
|
}
|