@zerotal/devtools 1.0.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +53 -0
- package/src/DevtoolsInjectionMiddleware.ts +167 -0
- package/src/RequestTrace.ts +148 -0
- package/src/TraceStore.ts +272 -0
- package/src/client-auto.ts +9 -0
- package/src/client.ts +1048 -0
- package/src/config.ts +60 -0
- package/src/dashboard-auto.ts +13 -0
- package/src/index.ts +27 -0
- package/src/panel-app.js +519 -0
- package/src/panel.html +26 -0
- package/src/provider/DevtoolsProvider.ts +153 -0
- package/src/redaction.ts +208 -0
- package/src/tracing.ts +347 -0
package/src/tracing.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devtools request tracing — event-driven architecture.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the core request lifecycle (`RequestHandled` / `RequestFailed`)
|
|
5
|
+
* and, when a request finalises, merges everything buffered against its context
|
|
6
|
+
* into a `RequestTrace` pushed to the trace store (which broadcasts via SSE).
|
|
7
|
+
*
|
|
8
|
+
* Feature packages buffer their own per-request signal through {@link traceSink} —
|
|
9
|
+
* bound in the container as `devtools.trace` and resolved by each package's own
|
|
10
|
+
* devtools bridge when devtools is installed. Devtools therefore imports no
|
|
11
|
+
* feature package.
|
|
12
|
+
*
|
|
13
|
+
* Five signals (queries, N+1, mail, cache, jobs) have dedicated fields and
|
|
14
|
+
* bespoke panels because their UI has earned the special case — a query needs
|
|
15
|
+
* its bindings and a duration bar, mail needs a preview. Everything else arrives
|
|
16
|
+
* through {@link TraceSink.channel} / {@link TraceSink.record}: a package
|
|
17
|
+
* declares how its entries should read and devtools renders them generically, so
|
|
18
|
+
* contributing a tab takes no change in this file.
|
|
19
|
+
*
|
|
20
|
+
* Console patching is handled separately via startConsoleCapture() since
|
|
21
|
+
* console.log is a hook (interception) not a broadcast event.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { FrameworkEvents, RequestContext } from "@zerotal/core";
|
|
25
|
+
import type { RequestHandled, RequestFailed } from "@zerotal/core";
|
|
26
|
+
import type { HttpContext } from "@zerotal/core";
|
|
27
|
+
import type {
|
|
28
|
+
QuerySpan,
|
|
29
|
+
NPlusOneWarning,
|
|
30
|
+
LogEntry,
|
|
31
|
+
MailEntry,
|
|
32
|
+
CacheEntry,
|
|
33
|
+
JobEntry,
|
|
34
|
+
RequestTrace,
|
|
35
|
+
TraceChannelDescriptor,
|
|
36
|
+
TraceChannelEntry,
|
|
37
|
+
} from "./RequestTrace.ts";
|
|
38
|
+
import { traceStore } from "./TraceStore.ts";
|
|
39
|
+
import { redactBindings, type RedactionOptions } from "./redaction.ts";
|
|
40
|
+
|
|
41
|
+
// ── Per-context event buffers ─────────────────────────────────────────────────
|
|
42
|
+
// Events are buffered for the full request lifetime (including phases that run
|
|
43
|
+
// before DevtoolsInjectionMiddleware, e.g. AuthMiddleware loading the user).
|
|
44
|
+
// Buffers are GC'd with the HttpContext via WeakMap.
|
|
45
|
+
|
|
46
|
+
type _BufLog = { level: LogEntry["level"]; args: string[]; absMs: number };
|
|
47
|
+
type _BufMail = Omit<MailEntry, "offsetMs"> & { absMs: number };
|
|
48
|
+
type _BufCache = Omit<CacheEntry, "offsetMs"> & { absMs: number };
|
|
49
|
+
type _BufJob = Omit<JobEntry, "offsetMs"> & { absMs: number };
|
|
50
|
+
type _BufChannel = { channel: string; entry: Record<string, unknown>; absMs: number };
|
|
51
|
+
|
|
52
|
+
export const _ctxQueries = new WeakMap<object, QuerySpan[]>();
|
|
53
|
+
export const _ctxWarnings = new WeakMap<object, NPlusOneWarning[]>();
|
|
54
|
+
export const _ctxLogs = new WeakMap<object, _BufLog[]>();
|
|
55
|
+
export const _ctxMail = new WeakMap<object, _BufMail[]>();
|
|
56
|
+
export const _ctxCache = new WeakMap<object, _BufCache[]>();
|
|
57
|
+
export const _ctxJobs = new WeakMap<object, _BufJob[]>();
|
|
58
|
+
export const _ctxChannels = new WeakMap<object, _BufChannel[]>();
|
|
59
|
+
|
|
60
|
+
export function _bufPush<T>(map: WeakMap<object, T[]>, ctx: object, item: T): void {
|
|
61
|
+
let arr = map.get(ctx);
|
|
62
|
+
if (!arr) {
|
|
63
|
+
arr = [];
|
|
64
|
+
map.set(ctx, arr);
|
|
65
|
+
}
|
|
66
|
+
arr.push(item);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Channel registry ──────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const _channels = new Map<string, TraceChannelDescriptor>();
|
|
72
|
+
|
|
73
|
+
/** Every channel declared so far, in display order. */
|
|
74
|
+
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
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @internal — drop every declared channel (provider teardown, tests). */
|
|
81
|
+
export function _resetChannels(): void {
|
|
82
|
+
_channels.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Redaction ─────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
let _redaction: RedactionOptions = {};
|
|
88
|
+
|
|
89
|
+
/** @internal — set by DevtoolsProvider from the app's `devtools` config. */
|
|
90
|
+
export function _setRedaction(options: RedactionOptions): void {
|
|
91
|
+
_redaction = options;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── The sink feature packages contribute to ───────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The buffer surface feature packages contribute to, bound in the container as
|
|
98
|
+
* `devtools.trace`.
|
|
99
|
+
*
|
|
100
|
+
* Each method buffers one entry against the request context it ran under; the
|
|
101
|
+
* request-finalise handler merges them into the trace and stamps each entry's
|
|
102
|
+
* offset from the request start.
|
|
103
|
+
*/
|
|
104
|
+
export interface TraceSink {
|
|
105
|
+
/**
|
|
106
|
+
* Declare a channel so its entries get a tab. Idempotent — a package can call
|
|
107
|
+
* this on every boot. A later declaration replaces an earlier one with the
|
|
108
|
+
* same id, so a package can refine its display without a restart.
|
|
109
|
+
*/
|
|
110
|
+
channel(descriptor: TraceChannelDescriptor): void;
|
|
111
|
+
/**
|
|
112
|
+
* Record one entry on a channel. `offsetMs` is stamped for you.
|
|
113
|
+
*
|
|
114
|
+
* Unknown channel ids are still recorded — a channel declared after the fact
|
|
115
|
+
* picks up the entries already buffered for the request in flight.
|
|
116
|
+
*/
|
|
117
|
+
record(ctx: object, channel: string, entry: Record<string, unknown>): void;
|
|
118
|
+
bufferQuery(ctx: object, q: QuerySpan): void;
|
|
119
|
+
bufferWarning(ctx: object, w: NPlusOneWarning): void;
|
|
120
|
+
bufferMail(ctx: object, m: Omit<MailEntry, "offsetMs">): void;
|
|
121
|
+
bufferCache(ctx: object, c: Omit<CacheEntry, "offsetMs">): void;
|
|
122
|
+
bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const traceSink: TraceSink = {
|
|
126
|
+
channel(descriptor: TraceChannelDescriptor): void {
|
|
127
|
+
_channels.set(descriptor.id, descriptor);
|
|
128
|
+
},
|
|
129
|
+
record(ctx: object, channel: string, entry: Record<string, unknown>): void {
|
|
130
|
+
_bufPush(_ctxChannels, ctx, { channel, entry, absMs: Date.now() });
|
|
131
|
+
},
|
|
132
|
+
bufferQuery(ctx: object, q: QuerySpan): void {
|
|
133
|
+
_bufPush(_ctxQueries, ctx, { ...q, bindings: redactBindings(q.sql, q.bindings, _redaction) });
|
|
134
|
+
},
|
|
135
|
+
bufferWarning(ctx: object, w: NPlusOneWarning): void {
|
|
136
|
+
_bufPush(_ctxWarnings, ctx, w);
|
|
137
|
+
},
|
|
138
|
+
bufferMail(ctx: object, m: Omit<MailEntry, "offsetMs">): void {
|
|
139
|
+
_bufPush(_ctxMail, ctx, { ...m, absMs: Date.now() });
|
|
140
|
+
},
|
|
141
|
+
bufferCache(ctx: object, c: Omit<CacheEntry, "offsetMs">): void {
|
|
142
|
+
_bufPush(_ctxCache, ctx, { ...c, absMs: Date.now() });
|
|
143
|
+
},
|
|
144
|
+
bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void {
|
|
145
|
+
_bufPush(_ctxJobs, ctx, { ...j, absMs: Date.now() });
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function _cleanupBuffers(ctx: object): void {
|
|
150
|
+
_ctxQueries.delete(ctx);
|
|
151
|
+
_ctxWarnings.delete(ctx);
|
|
152
|
+
_ctxLogs.delete(ctx);
|
|
153
|
+
_ctxMail.delete(ctx);
|
|
154
|
+
_ctxCache.delete(ctx);
|
|
155
|
+
_ctxJobs.delete(ctx);
|
|
156
|
+
_ctxChannels.delete(ctx);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Trace builder ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const SAFE_HEADERS = new Set([
|
|
162
|
+
"accept",
|
|
163
|
+
"content-type",
|
|
164
|
+
"user-agent",
|
|
165
|
+
"referer",
|
|
166
|
+
"x-request-id",
|
|
167
|
+
"x-forwarded-for",
|
|
168
|
+
"x-inertia",
|
|
169
|
+
"x-inertia-version",
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const INTERNAL_PREFIXES = ["/__flow/", "/__zerotal/", "/__dev/"];
|
|
173
|
+
|
|
174
|
+
function _isInternal(path: string): boolean {
|
|
175
|
+
return INTERNAL_PREFIXES.some((p) => path.startsWith(p));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Heap in use as the request finishes — what the panel's Memory stat reports. */
|
|
179
|
+
function _heapUsed(): number {
|
|
180
|
+
try {
|
|
181
|
+
return process.memoryUsage().heapUsed;
|
|
182
|
+
} catch {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Stamp an offset from the request start onto a buffered entry. */
|
|
188
|
+
function _offset(absMs: number, startMs: number): number {
|
|
189
|
+
return Math.max(0, absMs - startMs);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): RequestTrace {
|
|
193
|
+
const queryParams: Record<string, string> = {};
|
|
194
|
+
ctx.url.searchParams.forEach((v, k) => {
|
|
195
|
+
queryParams[k] = v;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const headers: Record<string, string> = {};
|
|
199
|
+
ctx.request.headers.forEach((v, k) => {
|
|
200
|
+
if (SAFE_HEADERS.has(k.toLowerCase())) headers[k] = v;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const ctxRecord = ctx as unknown as Record<string, unknown>;
|
|
204
|
+
const rd = ctxRecord["_routeDef"] as
|
|
205
|
+
{ pattern: string; controller: string; action: string } | undefined;
|
|
206
|
+
|
|
207
|
+
const user = ctxRecord["user"] as Record<string, unknown> | undefined;
|
|
208
|
+
|
|
209
|
+
const channels: Record<string, TraceChannelEntry[]> = {};
|
|
210
|
+
for (const { channel, entry, absMs } of _ctxChannels.get(ctx) ?? []) {
|
|
211
|
+
(channels[channel] ??= []).push({ ...entry, offsetMs: _offset(absMs, startMs) });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
id: crypto.randomUUID().slice(0, 12),
|
|
216
|
+
requestId: ctx.requestId,
|
|
217
|
+
method: ctx.request.method.toUpperCase(),
|
|
218
|
+
path: ctx.url.pathname,
|
|
219
|
+
statusCode: ctx.response?.status ?? 0,
|
|
220
|
+
startMs,
|
|
221
|
+
durationMs,
|
|
222
|
+
memory: _heapUsed(),
|
|
223
|
+
queryParams,
|
|
224
|
+
headers,
|
|
225
|
+
route: rd ? { pattern: rd.pattern, controller: rd.controller, action: rd.action } : null,
|
|
226
|
+
auth: user ? { id: user["id"], name: user["name"], email: user["email"] } : null,
|
|
227
|
+
queries: _ctxQueries.get(ctx) ?? [],
|
|
228
|
+
warnings: _ctxWarnings.get(ctx) ?? [],
|
|
229
|
+
logs: (_ctxLogs.get(ctx) ?? []).map((l) => ({
|
|
230
|
+
level: l.level,
|
|
231
|
+
args: l.args,
|
|
232
|
+
offsetMs: _offset(l.absMs, startMs),
|
|
233
|
+
})),
|
|
234
|
+
mail: (_ctxMail.get(ctx) ?? []).map(({ absMs, ...rest }) => ({
|
|
235
|
+
...rest,
|
|
236
|
+
offsetMs: _offset(absMs, startMs),
|
|
237
|
+
})),
|
|
238
|
+
cache: (_ctxCache.get(ctx) ?? []).map(({ absMs, ...rest }) => ({
|
|
239
|
+
...rest,
|
|
240
|
+
offsetMs: _offset(absMs, startMs),
|
|
241
|
+
})),
|
|
242
|
+
jobs: (_ctxJobs.get(ctx) ?? []).map(({ absMs, ...rest }) => ({
|
|
243
|
+
...rest,
|
|
244
|
+
offsetMs: _offset(absMs, startMs),
|
|
245
|
+
})),
|
|
246
|
+
channels,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── FrameworkEvents subscriptions ─────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
let _unsubs: Array<() => void> = [];
|
|
253
|
+
|
|
254
|
+
/** @internal — subscribe to all framework events; called by DevtoolsProvider.onBooted() */
|
|
255
|
+
export function startDevtoolsTracing(): void {
|
|
256
|
+
// Idempotent on purpose. Assigning `_unsubs` used to drop the handles to any
|
|
257
|
+
// existing subscription without unsubscribing it, so a second call left the
|
|
258
|
+
// first listener live and unreachable — and `_finaliseTrace` then ran once per
|
|
259
|
+
// live listener, recording every request as many times as start() was called.
|
|
260
|
+
stopDevtoolsTracing();
|
|
261
|
+
|
|
262
|
+
// Both successful and failed requests finalise the trace. Failed requests still
|
|
263
|
+
// 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`.
|
|
266
|
+
_unsubs = [
|
|
267
|
+
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),
|
|
272
|
+
),
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 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 {
|
|
278
|
+
// Internal framework paths are noise — skip them
|
|
279
|
+
if (_isInternal(ctx.url.pathname)) {
|
|
280
|
+
_cleanupBuffers(ctx);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const trace = _buildTrace(ctx, startMs, durationMs);
|
|
285
|
+
_cleanupBuffers(ctx);
|
|
286
|
+
traceStore().push(trace);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @internal — unsubscribe; called by DevtoolsProvider.onStopping() */
|
|
290
|
+
export function stopDevtoolsTracing(): void {
|
|
291
|
+
for (const unsub of _unsubs) unsub();
|
|
292
|
+
_unsubs = [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Console capture ───────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
const LOG_LEVELS = ["log", "debug", "info", "warn", "error"] as const;
|
|
298
|
+
let _origConsole: Partial<Record<string, unknown>> = {};
|
|
299
|
+
let _consoleCaptured = false;
|
|
300
|
+
|
|
301
|
+
/** @internal — patch console.* to capture log lines per request context */
|
|
302
|
+
export function startConsoleCapture(): void {
|
|
303
|
+
// Idempotent: a second start() without an intervening stop() would otherwise
|
|
304
|
+
// wrap the wrapper and capture already-patched methods as "originals".
|
|
305
|
+
if (_consoleCaptured) return;
|
|
306
|
+
_consoleCaptured = true;
|
|
307
|
+
for (const level of LOG_LEVELS) {
|
|
308
|
+
// Capture the original in a closure-local so the wrapper never depends on
|
|
309
|
+
// mutable module state. If a wrapper is left installed after stop() clears
|
|
310
|
+
// _origConsole (e.g. multiple app lifecycles share one process), it still
|
|
311
|
+
// forwards to the real console instead of dereferencing undefined.
|
|
312
|
+
const orig = (console as unknown as Record<string, (...a: unknown[]) => void>)[level] as (
|
|
313
|
+
...a: unknown[]
|
|
314
|
+
) => void;
|
|
315
|
+
_origConsole[level] = orig;
|
|
316
|
+
(console as unknown as Record<string, (...a: unknown[]) => void>)[level] = function (
|
|
317
|
+
...args: unknown[]
|
|
318
|
+
) {
|
|
319
|
+
orig(...args);
|
|
320
|
+
const ctx = RequestContext.tryGet();
|
|
321
|
+
if (!ctx) return;
|
|
322
|
+
_bufPush(_ctxLogs, ctx, {
|
|
323
|
+
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
|
+
),
|
|
331
|
+
absMs: Date.now(),
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @internal — restore original console methods */
|
|
338
|
+
export function stopConsoleCapture(): void {
|
|
339
|
+
if (!_consoleCaptured) return;
|
|
340
|
+
for (const level of LOG_LEVELS) {
|
|
341
|
+
if (_origConsole[level]) {
|
|
342
|
+
(console as unknown as Record<string, unknown>)[level] = _origConsole[level];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
_origConsole = {};
|
|
346
|
+
_consoleCaptured = false;
|
|
347
|
+
}
|