midline-agent 0.2.0 → 0.4.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/README.md +115 -2
- package/browser/package.json +8 -0
- package/dist/agent.d.ts +14 -1
- package/dist/agent.js +106 -20
- package/dist/browser/client.d.ts +59 -0
- package/dist/browser/client.js +608 -0
- package/dist/browser/index.d.ts +34 -0
- package/dist/browser/index.js +65 -0
- package/dist/browser/instrument.d.ts +39 -0
- package/dist/browser/instrument.js +217 -0
- package/dist/browser/transport.d.ts +43 -0
- package/dist/browser/transport.js +168 -0
- package/dist/browser/types.d.ts +94 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/version.d.ts +2 -0
- package/dist/browser/version.js +5 -0
- package/dist/browser/vitals.d.ts +16 -0
- package/dist/browser/vitals.js +135 -0
- package/dist/cli.js +0 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/console.d.ts +46 -0
- package/dist/console.js +167 -0
- package/dist/esm/browser/client.js +601 -0
- package/dist/esm/browser/index.js +52 -0
- package/dist/esm/browser/instrument.js +210 -0
- package/dist/esm/browser/transport.js +164 -0
- package/dist/esm/browser/types.js +1 -0
- package/dist/esm/browser/version.js +2 -0
- package/dist/esm/browser/vitals.js +132 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/redact.js +224 -0
- package/dist/esm/types.js +1 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +12 -6
- package/dist/types.d.ts +9 -2
- package/package.json +27 -4
- package/scripts/mark-esm.js +6 -0
- package/src/agent.ts +111 -15
- package/src/browser/client.ts +686 -0
- package/src/browser/index.ts +74 -0
- package/src/browser/instrument.ts +275 -0
- package/src/browser/transport.ts +184 -0
- package/src/browser/types.ts +105 -0
- package/src/browser/version.ts +2 -0
- package/src/browser/vitals.ts +149 -0
- package/src/config.ts +2 -0
- package/src/console.ts +182 -0
- package/src/redact.ts +12 -6
- package/src/types.ts +9 -2
- package/test/browser.test.js +328 -0
- package/test/console.test.js +182 -0
- package/tsconfig.esm.json +14 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type VitalName = "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
|
|
2
|
+
export type VitalRating = "good" | "needs-improvement" | "poor";
|
|
3
|
+
|
|
4
|
+
export interface VitalsReport {
|
|
5
|
+
vitals: Partial<Record<VitalName, { value: number; rating: VitalRating }>>;
|
|
6
|
+
navigationType?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** web.dev thresholds: at or under the first is good, over the second is poor. */
|
|
10
|
+
const THRESHOLDS: Record<VitalName, [number, number]> = {
|
|
11
|
+
LCP: [2500, 4000],
|
|
12
|
+
INP: [200, 500],
|
|
13
|
+
CLS: [0.1, 0.25],
|
|
14
|
+
FCP: [1800, 3000],
|
|
15
|
+
TTFB: [800, 1800],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const rate = (name: VitalName, value: number): VitalRating =>
|
|
19
|
+
value <= THRESHOLDS[name][0] ? "good" : value <= THRESHOLDS[name][1] ? "needs-improvement" : "poor";
|
|
20
|
+
|
|
21
|
+
type Entry = PerformanceEntry & {
|
|
22
|
+
value?: number;
|
|
23
|
+
hadRecentInput?: boolean;
|
|
24
|
+
interactionId?: number;
|
|
25
|
+
activationStart?: number;
|
|
26
|
+
responseStart?: number;
|
|
27
|
+
type?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Core Web Vitals without a dependency, measured the way the web-vitals library
|
|
32
|
+
* does and reported once, when the page is first hidden (the last moment a
|
|
33
|
+
* report reliably gets out). Browsers that don't expose an entry type simply
|
|
34
|
+
* omit that metric: Safari has no LCP, INP or CLS.
|
|
35
|
+
*/
|
|
36
|
+
export function observeVitals(win: Window, onReport: (report: VitalsReport) => void): () => void {
|
|
37
|
+
const Observer = (win as Window & { PerformanceObserver?: typeof PerformanceObserver }).PerformanceObserver;
|
|
38
|
+
const supported: readonly string[] = Observer?.supportedEntryTypes ?? [];
|
|
39
|
+
const perf = win.performance;
|
|
40
|
+
const doc = win.document;
|
|
41
|
+
|
|
42
|
+
const values: Partial<Record<VitalName, number>> = {};
|
|
43
|
+
const observers: Array<{ observer: PerformanceObserver; handle: (entries: Entry[]) => void }> = [];
|
|
44
|
+
|
|
45
|
+
let activationStart = 0;
|
|
46
|
+
let navigationType: string | undefined;
|
|
47
|
+
try {
|
|
48
|
+
const navigation = perf?.getEntriesByType?.("navigation")?.[0] as Entry | undefined;
|
|
49
|
+
if (navigation) {
|
|
50
|
+
activationStart = navigation.activationStart ?? 0;
|
|
51
|
+
navigationType = navigation.type;
|
|
52
|
+
if (typeof navigation.responseStart === "number" && navigation.responseStart > 0) {
|
|
53
|
+
values.TTFB = Math.max(0, navigation.responseStart - activationStart);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// no navigation timing
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const observe = (type: string, handle: (entries: Entry[]) => void, extra: Record<string, unknown> = {}) => {
|
|
61
|
+
if (!Observer || !supported.includes(type)) return;
|
|
62
|
+
try {
|
|
63
|
+
const observer = new Observer((list) => handle(list.getEntries() as Entry[]));
|
|
64
|
+
observer.observe({ type, buffered: true, ...extra } as PerformanceObserverInit);
|
|
65
|
+
observers.push({ observer, handle });
|
|
66
|
+
} catch {
|
|
67
|
+
// unsupported option in this browser
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
observe("paint", (entries) => {
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (entry.name === "first-contentful-paint") values.FCP = Math.max(0, entry.startTime - activationStart);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
observe("largest-contentful-paint", (entries) => {
|
|
78
|
+
const last = entries[entries.length - 1];
|
|
79
|
+
if (last) values.LCP = Math.max(0, last.startTime - activationStart);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// CLS is the largest session window: shifts less than 1s apart, at most 5s long.
|
|
83
|
+
let windowValue = 0;
|
|
84
|
+
let windowStart = 0;
|
|
85
|
+
let windowLast = 0;
|
|
86
|
+
observe("layout-shift", (entries) => {
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.hadRecentInput || typeof entry.value !== "number") continue;
|
|
89
|
+
if (windowValue && entry.startTime - windowLast < 1000 && entry.startTime - windowStart < 5000) {
|
|
90
|
+
windowValue += entry.value;
|
|
91
|
+
} else {
|
|
92
|
+
windowValue = entry.value;
|
|
93
|
+
windowStart = entry.startTime;
|
|
94
|
+
}
|
|
95
|
+
windowLast = entry.startTime;
|
|
96
|
+
values.CLS = Math.max(values.CLS ?? 0, windowValue);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// INP: the slowest interaction, or near the 98th percentile on busy pages.
|
|
101
|
+
const interactions = new Map<number, number>();
|
|
102
|
+
const recordInteractions = (entries: Entry[]) => {
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.interactionId) continue;
|
|
105
|
+
interactions.set(entry.interactionId, Math.max(interactions.get(entry.interactionId) ?? 0, entry.duration));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
observe("event", recordInteractions, { durationThreshold: 40 });
|
|
109
|
+
observe("first-input", recordInteractions);
|
|
110
|
+
|
|
111
|
+
let reported = false;
|
|
112
|
+
const report = () => {
|
|
113
|
+
if (reported) return;
|
|
114
|
+
reported = true;
|
|
115
|
+
for (const { observer, handle } of observers) {
|
|
116
|
+
try {
|
|
117
|
+
handle(observer.takeRecords() as Entry[]);
|
|
118
|
+
observer.disconnect();
|
|
119
|
+
} catch {
|
|
120
|
+
// already disconnected
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (interactions.size) {
|
|
124
|
+
const durations = [...interactions.values()].sort((a, b) => b - a);
|
|
125
|
+
values.INP = durations[Math.min(durations.length - 1, Math.floor(durations.length / 50))];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const vitals: VitalsReport["vitals"] = {};
|
|
129
|
+
for (const name of Object.keys(values) as VitalName[]) {
|
|
130
|
+
const raw = values[name];
|
|
131
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
|
|
132
|
+
const value = name === "CLS" ? Math.round(raw * 10_000) / 10_000 : Math.round(raw);
|
|
133
|
+
vitals[name] = { value, rating: rate(name, value) };
|
|
134
|
+
}
|
|
135
|
+
if (Object.keys(vitals).length) onReport({ vitals, navigationType });
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onVisibility = () => {
|
|
139
|
+
if (doc?.visibilityState === "hidden") report();
|
|
140
|
+
};
|
|
141
|
+
doc?.addEventListener("visibilitychange", onVisibility, true);
|
|
142
|
+
win.addEventListener("pagehide", report, true);
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
doc?.removeEventListener("visibilitychange", onVisibility, true);
|
|
146
|
+
win.removeEventListener("pagehide", report, true);
|
|
147
|
+
for (const { observer } of observers) observer.disconnect();
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface ResolvedConfig {
|
|
|
37
37
|
redactFields: string[];
|
|
38
38
|
redactHeaders: string[];
|
|
39
39
|
capture: ResolvedCapture;
|
|
40
|
+
captureConsole: boolean;
|
|
40
41
|
onError?: (message: string) => void;
|
|
41
42
|
debug: boolean;
|
|
42
43
|
flushIntervalMs: number;
|
|
@@ -218,6 +219,7 @@ export function resolveConfig(config: MidlineConfig): ResolvedConfig {
|
|
|
218
219
|
redactFields: [...(config.redactFields ?? []), ...(config.maskFields ?? [])],
|
|
219
220
|
redactHeaders: config.redactHeaders ?? [],
|
|
220
221
|
capture: resolveCapture(config.capture),
|
|
222
|
+
captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
|
|
221
223
|
onError: config.onError,
|
|
222
224
|
debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
|
|
223
225
|
flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60_000),
|
package/src/console.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console capture: what the host process prints — `console.log`, Nest's logger,
|
|
3
|
+
* pino, anything that reaches stdout or stderr — copied into Midline line by line.
|
|
4
|
+
*
|
|
5
|
+
* The streams are never changed. The original `write` runs first with the
|
|
6
|
+
* caller's own arguments and its return value is handed straight back, so
|
|
7
|
+
* back-pressure, callbacks and the terminal output are exactly what they were.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ConsoleStream = "stdout" | "stderr";
|
|
11
|
+
export type ConsoleLevel = "info" | "warn" | "error";
|
|
12
|
+
|
|
13
|
+
export interface ConsoleLine {
|
|
14
|
+
stream: ConsoleStream;
|
|
15
|
+
text: string;
|
|
16
|
+
level: ConsoleLevel;
|
|
17
|
+
/** When the line's first chunk was written, not when it was sent. */
|
|
18
|
+
timestamp: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Longest line kept. A log line past this is almost always a dumped payload. */
|
|
22
|
+
export const MAX_LINE_CHARS = 4096;
|
|
23
|
+
|
|
24
|
+
const STREAMS: ConsoleStream[] = ["stdout", "stderr"];
|
|
25
|
+
|
|
26
|
+
/** CSI (colours, cursor moves), OSC (titles, hyperlinks) and the remaining two-byte escapes. */
|
|
27
|
+
const ANSI_ESCAPE = /\x1B\[[0-?]*[ -\/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B[@-Z\\-_]/g;
|
|
28
|
+
|
|
29
|
+
// Level words are matched in capitals only: lower-case "error" turns up in plenty of harmless lines.
|
|
30
|
+
const ERROR_MARKERS = [/\b(?:ERROR|FATAL|CRITICAL)\b/, /"level"\s*:\s*"?(?:error|fatal|critical|50|60)\b/i];
|
|
31
|
+
const WARN_MARKERS = [/\bWARN(?:ING)?\b/, /"level"\s*:\s*"?(?:warn|warning|40)\b/i];
|
|
32
|
+
|
|
33
|
+
type WriteFn = (...args: any[]) => boolean;
|
|
34
|
+
|
|
35
|
+
let suppressed = 0;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs `fn` with capture paused. The agent writes its own diagnostics through
|
|
39
|
+
* this, so a warning about delivery can't become an event that needs delivering.
|
|
40
|
+
*/
|
|
41
|
+
export function withoutConsoleCapture<T>(fn: () => T): T {
|
|
42
|
+
suppressed += 1;
|
|
43
|
+
try {
|
|
44
|
+
return fn();
|
|
45
|
+
} finally {
|
|
46
|
+
suppressed -= 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Pending {
|
|
51
|
+
text: string;
|
|
52
|
+
at: string;
|
|
53
|
+
/** Already unfinished at the previous flush. */
|
|
54
|
+
stale: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ConsoleCapture {
|
|
58
|
+
private readonly originals = new Map<ConsoleStream, WriteFn>();
|
|
59
|
+
private readonly wrappers = new Map<ConsoleStream, WriteFn>();
|
|
60
|
+
private readonly pending: Record<ConsoleStream, Pending> = {
|
|
61
|
+
stdout: { text: "", at: "", stale: false },
|
|
62
|
+
stderr: { text: "", at: "", stale: false },
|
|
63
|
+
};
|
|
64
|
+
private busy = false;
|
|
65
|
+
private stopped = false;
|
|
66
|
+
|
|
67
|
+
constructor(private readonly onLine: (line: ConsoleLine) => void) {}
|
|
68
|
+
|
|
69
|
+
install(): void {
|
|
70
|
+
for (const stream of STREAMS) {
|
|
71
|
+
const target = process[stream];
|
|
72
|
+
const original = target.write as WriteFn;
|
|
73
|
+
const capture = this;
|
|
74
|
+
const wrapper: WriteFn = function (this: unknown, ...args: any[]) {
|
|
75
|
+
const result = original.apply(this, args);
|
|
76
|
+
capture.take(stream, args[0], args[1]);
|
|
77
|
+
return result;
|
|
78
|
+
};
|
|
79
|
+
this.originals.set(stream, original);
|
|
80
|
+
this.wrappers.set(stream, wrapper);
|
|
81
|
+
target.write = wrapper as NodeJS.WriteStream["write"];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
|
|
86
|
+
uninstall(): void {
|
|
87
|
+
this.stopped = true;
|
|
88
|
+
for (const stream of STREAMS) {
|
|
89
|
+
const wrapper = this.wrappers.get(stream);
|
|
90
|
+
if (wrapper && process[stream].write === wrapper) {
|
|
91
|
+
process[stream].write = this.originals.get(stream) as NodeJS.WriteStream["write"];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.wrappers.clear();
|
|
95
|
+
this.originals.clear();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Emits lines still waiting for a newline. Without `force`, only the ones that
|
|
100
|
+
* were already unfinished at the previous call, so a line being written in
|
|
101
|
+
* pieces isn't cut in half by a timer tick.
|
|
102
|
+
*/
|
|
103
|
+
flushPending(force = false): void {
|
|
104
|
+
if (this.stopped || this.busy) return;
|
|
105
|
+
this.busy = true;
|
|
106
|
+
try {
|
|
107
|
+
for (const stream of STREAMS) {
|
|
108
|
+
const pending = this.pending[stream];
|
|
109
|
+
if (!pending.text) continue;
|
|
110
|
+
if (force || pending.stale) {
|
|
111
|
+
this.pending[stream] = { text: "", at: "", stale: false };
|
|
112
|
+
this.emit(stream, pending.text, pending.at);
|
|
113
|
+
} else {
|
|
114
|
+
pending.stale = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Best-effort, like the rest of capture.
|
|
119
|
+
} finally {
|
|
120
|
+
this.busy = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private take(stream: ConsoleStream, chunk: unknown, encoding: unknown): void {
|
|
125
|
+
// `busy` also stops a loop if whatever handles a line prints something itself.
|
|
126
|
+
if (this.stopped || this.busy || suppressed > 0) return;
|
|
127
|
+
this.busy = true;
|
|
128
|
+
try {
|
|
129
|
+
const text = decode(chunk, encoding);
|
|
130
|
+
if (!text) return;
|
|
131
|
+
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
const pending = this.pending[stream];
|
|
134
|
+
const lines = (pending.text + text).split("\n");
|
|
135
|
+
let rest = lines.pop() ?? "";
|
|
136
|
+
let at = pending.text ? pending.at : now;
|
|
137
|
+
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
this.emit(stream, line, at);
|
|
140
|
+
at = now;
|
|
141
|
+
}
|
|
142
|
+
if (rest.length > MAX_LINE_CHARS) {
|
|
143
|
+
this.emit(stream, rest, at);
|
|
144
|
+
rest = "";
|
|
145
|
+
}
|
|
146
|
+
this.pending[stream] = { text: rest, at: rest ? at : "", stale: false };
|
|
147
|
+
} catch {
|
|
148
|
+
// The write itself already happened; a line we couldn't read is just not sent.
|
|
149
|
+
} finally {
|
|
150
|
+
this.busy = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private emit(stream: ConsoleStream, raw: string, at: string): void {
|
|
155
|
+
let text = raw.replace(ANSI_ESCAPE, "").replace(/\r$/, "");
|
|
156
|
+
// A carriage return redraws the line; what's left after the last one is what the terminal shows.
|
|
157
|
+
text = text.slice(text.lastIndexOf("\r") + 1);
|
|
158
|
+
if (!text.trim()) return;
|
|
159
|
+
if (text.length > MAX_LINE_CHARS) text = text.slice(0, MAX_LINE_CHARS);
|
|
160
|
+
this.onLine({ stream, text, level: levelOf(stream, text), timestamp: at });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
|
|
165
|
+
export function levelOf(stream: ConsoleStream, text: string): ConsoleLevel {
|
|
166
|
+
const head = text.slice(0, 256);
|
|
167
|
+
if (ERROR_MARKERS.some((pattern) => pattern.test(head))) return "error";
|
|
168
|
+
if (WARN_MARKERS.some((pattern) => pattern.test(head))) return "warn";
|
|
169
|
+
return stream === "stderr" ? "warn" : "info";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function decode(chunk: unknown, encoding: unknown): string {
|
|
173
|
+
if (typeof chunk === "string") {
|
|
174
|
+
return typeof encoding === "string" && !/^utf-?8$/i.test(encoding) && Buffer.isEncoding(encoding)
|
|
175
|
+
? Buffer.from(chunk, encoding).toString("utf8")
|
|
176
|
+
: chunk;
|
|
177
|
+
}
|
|
178
|
+
if (chunk instanceof Uint8Array) {
|
|
179
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString("utf8");
|
|
180
|
+
}
|
|
181
|
+
return "";
|
|
182
|
+
}
|
package/src/redact.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Shared by the Node agent and the browser SDK, so nothing here may touch a
|
|
3
|
+
* Node-only global (Buffer, process) — TextEncoder and URLSearchParams exist in both.
|
|
4
|
+
*
|
|
2
5
|
* Redaction happens in the host process, before an event is queued. Whatever is
|
|
3
6
|
* removed here never reaches a socket, a log line or the Midline server.
|
|
4
7
|
*
|
|
@@ -124,7 +127,8 @@ export class Redactor {
|
|
|
124
127
|
if (typeof input === "bigint") return input.toString();
|
|
125
128
|
if (typeof input === "function" || typeof input === "symbol") return undefined;
|
|
126
129
|
if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input.toISOString();
|
|
127
|
-
|
|
130
|
+
// Buffer is a Uint8Array, so this also covers it without naming a Node-only global.
|
|
131
|
+
if (ArrayBuffer.isView(input)) {
|
|
128
132
|
return `[Binary ${(input as ArrayBufferView).byteLength} bytes]`;
|
|
129
133
|
}
|
|
130
134
|
if (typeof input !== "object") return undefined;
|
|
@@ -190,11 +194,13 @@ export class Redactor {
|
|
|
190
194
|
|
|
191
195
|
const type = (contentType || "").toLowerCase();
|
|
192
196
|
|
|
193
|
-
if (typeof raw === "object" && !
|
|
197
|
+
if (typeof raw === "object" && !ArrayBuffer.isView(raw)) {
|
|
194
198
|
return this.fit(this.value(raw), maxBytes);
|
|
195
199
|
}
|
|
196
200
|
|
|
197
|
-
const text =
|
|
201
|
+
const text = ArrayBuffer.isView(raw)
|
|
202
|
+
? new TextDecoder().decode(raw as ArrayBufferView)
|
|
203
|
+
: String(raw);
|
|
198
204
|
|
|
199
205
|
if (type.includes("json")) {
|
|
200
206
|
try {
|
|
@@ -214,18 +220,18 @@ export class Redactor {
|
|
|
214
220
|
|
|
215
221
|
private fit(value: unknown, maxBytes: number): { body?: unknown; truncated?: boolean } {
|
|
216
222
|
const serialized = JSON.stringify(value) ?? "";
|
|
217
|
-
if (
|
|
223
|
+
if (new TextEncoder().encode(serialized).length <= maxBytes) {
|
|
218
224
|
return { body: value };
|
|
219
225
|
}
|
|
220
226
|
return this.cut(serialized, maxBytes);
|
|
221
227
|
}
|
|
222
228
|
|
|
223
229
|
private cut(text: string, maxBytes: number): { body?: unknown; truncated?: boolean } {
|
|
224
|
-
const bytes =
|
|
230
|
+
const bytes = new TextEncoder().encode(text);
|
|
225
231
|
if (bytes.length <= maxBytes) {
|
|
226
232
|
return { body: text };
|
|
227
233
|
}
|
|
228
234
|
// Slicing bytes can split a multi-byte character; the replacement char is harmless here.
|
|
229
|
-
return { body: bytes.subarray(0, maxBytes)
|
|
235
|
+
return { body: new TextDecoder().decode(bytes.subarray(0, maxBytes)), truncated: true };
|
|
230
236
|
}
|
|
231
237
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** A PEM string, PEM bytes, a path to a PEM file, or several of those. */
|
|
2
2
|
export type CaInput = string | Buffer | Array<string | Buffer>;
|
|
3
3
|
|
|
4
|
-
export type EventType = "request" | "error" | "security" | "performance" | "custom";
|
|
4
|
+
export type EventType = "request" | "error" | "security" | "performance" | "custom" | "console";
|
|
5
5
|
export type EventSeverity = "low" | "medium" | "high" | "critical";
|
|
6
6
|
export type EventCategory = "application" | "infrastructure" | "security" | "performance" | "business";
|
|
7
7
|
|
|
@@ -62,6 +62,13 @@ export interface MidlineConfig {
|
|
|
62
62
|
redactHeaders?: string[];
|
|
63
63
|
/** What to capture beyond method/path/status/timing. Defaults to nothing. */
|
|
64
64
|
capture?: CaptureOptions;
|
|
65
|
+
/**
|
|
66
|
+
* Also send what the process prints — `console.log`, Nest's logger, anything
|
|
67
|
+
* written to stdout or stderr — as `console` events, one per line, redacted like
|
|
68
|
+
* any other text. Initialise the agent before creating the app to include its
|
|
69
|
+
* startup lines. Off by default. Env fallback: `MIDLINE_CAPTURE_CONSOLE`.
|
|
70
|
+
*/
|
|
71
|
+
captureConsole?: boolean;
|
|
65
72
|
|
|
66
73
|
/** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
|
|
67
74
|
enabled?: boolean;
|
|
@@ -141,5 +148,5 @@ export interface MidlineEvent {
|
|
|
141
148
|
errorCode?: string;
|
|
142
149
|
/** Set when the client disconnected before the response finished. */
|
|
143
150
|
aborted?: boolean;
|
|
144
|
-
integration?: "express" | "node-http" | "proxy" | "manual";
|
|
151
|
+
integration?: "express" | "node-http" | "proxy" | "console" | "manual";
|
|
145
152
|
}
|