@zerotal/devtools 1.6.2 → 1.7.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 +233 -1
- package/api-surface.md +296 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +96 -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 +122 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +65 -0
- package/src/client/state.ts +311 -0
- package/src/client/tabs/all.ts +276 -0
- package/src/client/tabs/app.ts +292 -0
- package/src/client/tabs/cache.ts +49 -0
- package/src/client/tabs/channel.ts +263 -0
- package/src/client/tabs/exceptions.ts +68 -0
- package/src/client/tabs/jobs.ts +50 -0
- package/src/client/tabs/logs.ts +44 -0
- package/src/client/tabs/mail.ts +59 -0
- package/src/client/tabs/queries.ts +124 -0
- package/src/client/tabs/request.ts +76 -0
- package/src/client/tabs/timeline.ts +132 -0
- package/src/client/tabs/types.ts +51 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +118 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +511 -0
- package/src/client/ui/theme.ts +389 -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 +213 -24
- package/src/client.ts +0 -1048
- package/src/panel-app.js +0 -519
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 };
|
|
@@ -82,7 +106,7 @@ export function _resetChannels(): void {
|
|
|
82
106
|
_channels.clear();
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
// ──
|
|
109
|
+
// ── Capture settings ──────────────────────────────────────────────────────────
|
|
86
110
|
|
|
87
111
|
let _redaction: RedactionOptions = {};
|
|
88
112
|
|
|
@@ -91,6 +115,31 @@ export function _setRedaction(options: RedactionOptions): void {
|
|
|
91
115
|
_redaction = options;
|
|
92
116
|
}
|
|
93
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Whether to walk the stack for each query and log line.
|
|
120
|
+
*
|
|
121
|
+
* A field rather than a config read per event: this runs on the hot path of a
|
|
122
|
+
* request running forty queries, and resolving config forty times to answer the
|
|
123
|
+
* same question would cost more than the walk it is guarding.
|
|
124
|
+
*/
|
|
125
|
+
let _captureSource = true;
|
|
126
|
+
|
|
127
|
+
/** @internal — set by DevtoolsProvider from the app's `devtools` config. */
|
|
128
|
+
export function _setCaptureSource(enabled: boolean): void {
|
|
129
|
+
_captureSource = enabled;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Request headers recorded beyond the built-in safe list, lower-cased.
|
|
134
|
+
* `"*"` records every header the redaction rules do not mask.
|
|
135
|
+
*/
|
|
136
|
+
let _extraHeaders = new Set<string>();
|
|
137
|
+
|
|
138
|
+
/** @internal — set by DevtoolsProvider from the app's `devtools` config. */
|
|
139
|
+
export function _setHeaderAllowlist(headers: string[]): void {
|
|
140
|
+
_extraHeaders = new Set(headers.map((h) => h.toLowerCase()));
|
|
141
|
+
}
|
|
142
|
+
|
|
94
143
|
// ── The sink feature packages contribute to ───────────────────────────────────
|
|
95
144
|
|
|
96
145
|
/**
|
|
@@ -122,15 +171,32 @@ export interface TraceSink {
|
|
|
122
171
|
bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void;
|
|
123
172
|
}
|
|
124
173
|
|
|
174
|
+
// Everything below masks on the way *in*. Redacting in a renderer would protect
|
|
175
|
+
// nothing: by the time a panel draws a row, the unredacted copy has already been
|
|
176
|
+
// streamed to the browser and written to `.zerotal/devtools.sqlite`, where it
|
|
177
|
+
// sits for a day. The sink is the last point where "not recorded" is still true.
|
|
125
178
|
export const traceSink: TraceSink = {
|
|
126
179
|
channel(descriptor: TraceChannelDescriptor): void {
|
|
127
180
|
_channels.set(descriptor.id, descriptor);
|
|
128
181
|
},
|
|
129
182
|
record(ctx: object, channel: string, entry: Record<string, unknown>): void {
|
|
130
|
-
_bufPush(_ctxChannels, ctx, {
|
|
183
|
+
_bufPush(_ctxChannels, ctx, {
|
|
184
|
+
channel,
|
|
185
|
+
entry: redactValue(entry, _redaction) as Record<string, unknown>,
|
|
186
|
+
absMs: Date.now(),
|
|
187
|
+
});
|
|
131
188
|
},
|
|
132
189
|
bufferQuery(ctx: object, q: QuerySpan): void {
|
|
133
|
-
|
|
190
|
+
// The call site is captured here rather than at the emit site because here
|
|
191
|
+
// is the only place that knows whether anyone is recording. `skip` is 0: the
|
|
192
|
+
// frames above are this method and the ORM's bridge, both of which the
|
|
193
|
+
// framework filter drops anyway.
|
|
194
|
+
const source = _captureSource && !q.source ? captureCallSite() : q.source;
|
|
195
|
+
_bufPush(_ctxQueries, ctx, {
|
|
196
|
+
...q,
|
|
197
|
+
bindings: redactBindings(q.sql, q.bindings, _redaction),
|
|
198
|
+
...(source ? { source } : {}),
|
|
199
|
+
});
|
|
134
200
|
},
|
|
135
201
|
bufferWarning(ctx: object, w: NPlusOneWarning): void {
|
|
136
202
|
_bufPush(_ctxWarnings, ctx, w);
|
|
@@ -139,7 +205,7 @@ export const traceSink: TraceSink = {
|
|
|
139
205
|
_bufPush(_ctxMail, ctx, { ...m, absMs: Date.now() });
|
|
140
206
|
},
|
|
141
207
|
bufferCache(ctx: object, c: Omit<CacheEntry, "offsetMs">): void {
|
|
142
|
-
_bufPush(_ctxCache, ctx, { ...c, absMs: Date.now() });
|
|
208
|
+
_bufPush(_ctxCache, ctx, { ...c, key: redactCacheKey(c.key, _redaction), absMs: Date.now() });
|
|
143
209
|
},
|
|
144
210
|
bufferJob(ctx: object, j: Omit<JobEntry, "offsetMs">): void {
|
|
145
211
|
_bufPush(_ctxJobs, ctx, { ...j, absMs: Date.now() });
|
|
@@ -158,17 +224,38 @@ function _cleanupBuffers(ctx: object): void {
|
|
|
158
224
|
|
|
159
225
|
// ── Trace builder ─────────────────────────────────────────────────────────────
|
|
160
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Request headers recorded without being asked.
|
|
229
|
+
*
|
|
230
|
+
* An allowlist rather than a denylist because a trace is *persisted*: `cookie`
|
|
231
|
+
* and `authorization` are the request's credentials, and a header nobody thought
|
|
232
|
+
* to deny is a header on disk for a day. The cost is that the custom header you
|
|
233
|
+
* are actually debugging is invisible, which is what `devtools.headers` opens.
|
|
234
|
+
*/
|
|
161
235
|
const SAFE_HEADERS = new Set([
|
|
162
236
|
"accept",
|
|
163
237
|
"content-type",
|
|
238
|
+
"content-length",
|
|
164
239
|
"user-agent",
|
|
165
240
|
"referer",
|
|
241
|
+
"origin",
|
|
242
|
+
"accept-language",
|
|
166
243
|
"x-request-id",
|
|
167
244
|
"x-forwarded-for",
|
|
168
245
|
"x-inertia",
|
|
169
246
|
"x-inertia-version",
|
|
247
|
+
"x-requested-with",
|
|
170
248
|
]);
|
|
171
249
|
|
|
250
|
+
/** Never recorded, whatever the allowlist says — these *are* the credentials. */
|
|
251
|
+
const NEVER_HEADERS = new Set(["cookie", "set-cookie", "authorization", "proxy-authorization"]);
|
|
252
|
+
|
|
253
|
+
function _recordHeader(name: string): boolean {
|
|
254
|
+
const key = name.toLowerCase();
|
|
255
|
+
if (NEVER_HEADERS.has(key)) return false;
|
|
256
|
+
return SAFE_HEADERS.has(key) || _extraHeaders.has("*") || _extraHeaders.has(key);
|
|
257
|
+
}
|
|
258
|
+
|
|
172
259
|
const INTERNAL_PREFIXES = ["/__flow/", "/__zerotal/", "/__dev/"];
|
|
173
260
|
|
|
174
261
|
function _isInternal(path: string): boolean {
|
|
@@ -189,7 +276,34 @@ function _offset(absMs: number, startMs: number): number {
|
|
|
189
276
|
return Math.max(0, absMs - startMs);
|
|
190
277
|
}
|
|
191
278
|
|
|
192
|
-
|
|
279
|
+
/**
|
|
280
|
+
* What is in the session — the key names, never the values.
|
|
281
|
+
*
|
|
282
|
+
* "Is the CSRF token there, did the flash survive the redirect, is the user id
|
|
283
|
+
* set" are the session questions a request inspector is asked, and all three are
|
|
284
|
+
* answered by the keys. The values are the request's real state — the user's id,
|
|
285
|
+
* the token itself, whatever a form flashed — and this trace is written to disk
|
|
286
|
+
* for a day.
|
|
287
|
+
*
|
|
288
|
+
* Read structurally: devtools imports no feature package, and an app without the
|
|
289
|
+
* session middleware has no `ctx.session` at all.
|
|
290
|
+
*/
|
|
291
|
+
function _sessionKeys(ctx: HttpContext): string[] {
|
|
292
|
+
try {
|
|
293
|
+
const session = (ctx as unknown as Record<string, unknown>)["session"] as
|
|
294
|
+
{ _data?: Record<string, unknown> } | undefined;
|
|
295
|
+
return session?._data ? Object.keys(session._data).sort() : [];
|
|
296
|
+
} catch {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function _buildTrace(
|
|
302
|
+
ctx: HttpContext,
|
|
303
|
+
startMs: number,
|
|
304
|
+
durationMs: number,
|
|
305
|
+
exception: ExceptionInfo | null,
|
|
306
|
+
): RequestTrace {
|
|
193
307
|
const queryParams: Record<string, string> = {};
|
|
194
308
|
ctx.url.searchParams.forEach((v, k) => {
|
|
195
309
|
queryParams[k] = v;
|
|
@@ -197,7 +311,14 @@ function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): Req
|
|
|
197
311
|
|
|
198
312
|
const headers: Record<string, string> = {};
|
|
199
313
|
ctx.request.headers.forEach((v, k) => {
|
|
200
|
-
if (
|
|
314
|
+
if (_recordHeader(k)) headers[k] = v;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// The response half of the exchange: its status line and the headers it set.
|
|
318
|
+
// A request tab that shows only what came in answers half the question.
|
|
319
|
+
const responseHeaders: Record<string, string> = {};
|
|
320
|
+
ctx.response?.headers.forEach((v, k) => {
|
|
321
|
+
if (_recordHeader(k)) responseHeaders[k] = v;
|
|
201
322
|
});
|
|
202
323
|
|
|
203
324
|
const ctxRecord = ctx as unknown as Record<string, unknown>;
|
|
@@ -222,14 +343,18 @@ function _buildTrace(ctx: HttpContext, startMs: number, durationMs: number): Req
|
|
|
222
343
|
memory: _heapUsed(),
|
|
223
344
|
queryParams,
|
|
224
345
|
headers,
|
|
346
|
+
responseHeaders,
|
|
347
|
+
session: _sessionKeys(ctx),
|
|
225
348
|
route: rd ? { pattern: rd.pattern, controller: rd.controller, action: rd.action } : null,
|
|
226
349
|
auth: user ? { id: user["id"], name: user["name"], email: user["email"] } : null,
|
|
350
|
+
exception,
|
|
227
351
|
queries: _ctxQueries.get(ctx) ?? [],
|
|
228
352
|
warnings: _ctxWarnings.get(ctx) ?? [],
|
|
229
353
|
logs: (_ctxLogs.get(ctx) ?? []).map((l) => ({
|
|
230
354
|
level: l.level,
|
|
231
355
|
args: l.args,
|
|
232
356
|
offsetMs: _offset(l.absMs, startMs),
|
|
357
|
+
...(l.source ? { source: l.source } : {}),
|
|
233
358
|
})),
|
|
234
359
|
mail: (_ctxMail.get(ctx) ?? []).map(({ absMs, ...rest }) => ({
|
|
235
360
|
...rest,
|
|
@@ -261,27 +386,71 @@ export function startDevtoolsTracing(): void {
|
|
|
261
386
|
|
|
262
387
|
// Both successful and failed requests finalise the trace. Failed requests still
|
|
263
388
|
// carry the rendered error response on ctx, so the trace records the error status
|
|
264
|
-
// code like any other outcome
|
|
265
|
-
//
|
|
389
|
+
// code like any other outcome — and now the message with it, which used to be
|
|
390
|
+
// dropped here, leaving a red 500 in the panel with nothing to read next to it.
|
|
391
|
+
// Everything else on the trace is buffered by feature packages through `traceSink`.
|
|
266
392
|
_unsubs = [
|
|
267
393
|
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),
|
|
394
|
+
_finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs, null),
|
|
272
395
|
),
|
|
396
|
+
FrameworkEvents.on<RequestFailed>("RequestFailed", (e) => {
|
|
397
|
+
const frames = parseStack(e.stack);
|
|
398
|
+
_finaliseTrace(e.ctx as HttpContext, e.startMs, e.durationMs, {
|
|
399
|
+
message: e.error,
|
|
400
|
+
status: e.status,
|
|
401
|
+
...(e.type ? { type: e.type } : {}),
|
|
402
|
+
...(frames.length ? { frames } : {}),
|
|
403
|
+
});
|
|
404
|
+
}),
|
|
405
|
+
// Outgoing calls, recorded here rather than by a bridge in the package that
|
|
406
|
+
// owns them: the owner is `@zerotal/core`, and core cannot know about the
|
|
407
|
+
// channel API because devtools is what depends on core. Devtools already
|
|
408
|
+
// subscribes to core's events, so this costs no new dependency in either
|
|
409
|
+
// direction.
|
|
410
|
+
FrameworkEvents.on<OutgoingRequestCompleted>("OutgoingRequestCompleted", (e) => {
|
|
411
|
+
// The event carries no context — the client does not take one — so the
|
|
412
|
+
// request is read from the ambient scope it was called in.
|
|
413
|
+
const ctx = RequestContext.tryGet();
|
|
414
|
+
if (!ctx) return;
|
|
415
|
+
traceSink.record(ctx, "http", {
|
|
416
|
+
method: e.method,
|
|
417
|
+
url: e.url,
|
|
418
|
+
host: e.host,
|
|
419
|
+
status: e.status || "—",
|
|
420
|
+
durationMs: e.durationMs,
|
|
421
|
+
failed: !e.ok,
|
|
422
|
+
});
|
|
423
|
+
}),
|
|
273
424
|
];
|
|
425
|
+
|
|
426
|
+
// Declared here rather than in a satellite for the same reason. `order` puts
|
|
427
|
+
// it beside the other per-request feeds rather than at the back of the strip.
|
|
428
|
+
traceSink.channel({
|
|
429
|
+
id: "http",
|
|
430
|
+
label: "Outgoing",
|
|
431
|
+
badge: "method",
|
|
432
|
+
title: "url",
|
|
433
|
+
meta: ["status", "durationMs", "host"],
|
|
434
|
+
warn: "failed",
|
|
435
|
+
order: 35,
|
|
436
|
+
render: "table",
|
|
437
|
+
});
|
|
274
438
|
}
|
|
275
439
|
|
|
276
440
|
/** Merge buffered events into a trace and push it to the store (once per request). */
|
|
277
|
-
function _finaliseTrace(
|
|
441
|
+
function _finaliseTrace(
|
|
442
|
+
ctx: HttpContext,
|
|
443
|
+
startMs: number,
|
|
444
|
+
durationMs: number,
|
|
445
|
+
exception: ExceptionInfo | null,
|
|
446
|
+
): void {
|
|
278
447
|
// Internal framework paths are noise — skip them
|
|
279
448
|
if (_isInternal(ctx.url.pathname)) {
|
|
280
449
|
_cleanupBuffers(ctx);
|
|
281
450
|
return;
|
|
282
451
|
}
|
|
283
452
|
|
|
284
|
-
const trace = _buildTrace(ctx, startMs, durationMs);
|
|
453
|
+
const trace = _buildTrace(ctx, startMs, durationMs, exception);
|
|
285
454
|
_cleanupBuffers(ctx);
|
|
286
455
|
traceStore().push(trace);
|
|
287
456
|
}
|
|
@@ -298,6 +467,28 @@ const LOG_LEVELS = ["log", "debug", "info", "warn", "error"] as const;
|
|
|
298
467
|
let _origConsole: Partial<Record<string, unknown>> = {};
|
|
299
468
|
let _consoleCaptured = false;
|
|
300
469
|
|
|
470
|
+
/**
|
|
471
|
+
* One logged argument, as the line the panel shows.
|
|
472
|
+
*
|
|
473
|
+
* Objects are redacted before they are serialised, not after: `console.log(user)`
|
|
474
|
+
* during a debug session used to write the whole record — password hash included
|
|
475
|
+
* — to disk for a day. Redacting also makes the value safe to serialise at all,
|
|
476
|
+
* since the walk replaces cycles; a circular argument used to throw a
|
|
477
|
+
* `Converting circular structure to JSON` out of this patch and into the caller's
|
|
478
|
+
* `console.log`.
|
|
479
|
+
*/
|
|
480
|
+
function _formatLogArg(value: unknown): string {
|
|
481
|
+
if (typeof value === "string") return value;
|
|
482
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
483
|
+
const safe = redactValue(value, _redaction);
|
|
484
|
+
try {
|
|
485
|
+
return JSON.stringify(safe) ?? String(safe);
|
|
486
|
+
} catch {
|
|
487
|
+
// BigInt, a throwing toJSON — the log line is not worth failing the request.
|
|
488
|
+
return String(safe);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
301
492
|
/** @internal — patch console.* to capture log lines per request context */
|
|
302
493
|
export function startConsoleCapture(): void {
|
|
303
494
|
// Idempotent: a second start() without an intervening stop() would otherwise
|
|
@@ -319,16 +510,14 @@ export function startConsoleCapture(): void {
|
|
|
319
510
|
orig(...args);
|
|
320
511
|
const ctx = RequestContext.tryGet();
|
|
321
512
|
if (!ctx) return;
|
|
513
|
+
// One frame to skip: this wrapper is standing between the caller and the
|
|
514
|
+
// stack, and without dropping it every log line would point at devtools.
|
|
515
|
+
const source = _captureSource ? captureCallSite(1) : null;
|
|
322
516
|
_bufPush(_ctxLogs, ctx, {
|
|
323
517
|
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
|
-
),
|
|
518
|
+
args: args.map(_formatLogArg),
|
|
331
519
|
absMs: Date.now(),
|
|
520
|
+
...(source ? { source } : {}),
|
|
332
521
|
});
|
|
333
522
|
};
|
|
334
523
|
}
|