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.
Files changed (53) hide show
  1. package/README.md +115 -2
  2. package/browser/package.json +8 -0
  3. package/dist/agent.d.ts +14 -1
  4. package/dist/agent.js +106 -20
  5. package/dist/browser/client.d.ts +59 -0
  6. package/dist/browser/client.js +608 -0
  7. package/dist/browser/index.d.ts +34 -0
  8. package/dist/browser/index.js +65 -0
  9. package/dist/browser/instrument.d.ts +39 -0
  10. package/dist/browser/instrument.js +217 -0
  11. package/dist/browser/transport.d.ts +43 -0
  12. package/dist/browser/transport.js +168 -0
  13. package/dist/browser/types.d.ts +94 -0
  14. package/dist/browser/types.js +2 -0
  15. package/dist/browser/version.d.ts +2 -0
  16. package/dist/browser/version.js +5 -0
  17. package/dist/browser/vitals.d.ts +16 -0
  18. package/dist/browser/vitals.js +135 -0
  19. package/dist/cli.js +0 -0
  20. package/dist/config.d.ts +1 -0
  21. package/dist/config.js +1 -0
  22. package/dist/console.d.ts +46 -0
  23. package/dist/console.js +167 -0
  24. package/dist/esm/browser/client.js +601 -0
  25. package/dist/esm/browser/index.js +52 -0
  26. package/dist/esm/browser/instrument.js +210 -0
  27. package/dist/esm/browser/transport.js +164 -0
  28. package/dist/esm/browser/types.js +1 -0
  29. package/dist/esm/browser/version.js +2 -0
  30. package/dist/esm/browser/vitals.js +132 -0
  31. package/dist/esm/package.json +1 -0
  32. package/dist/esm/redact.js +224 -0
  33. package/dist/esm/types.js +1 -0
  34. package/dist/redact.d.ts +3 -0
  35. package/dist/redact.js +12 -6
  36. package/dist/types.d.ts +9 -2
  37. package/package.json +27 -4
  38. package/scripts/mark-esm.js +6 -0
  39. package/src/agent.ts +111 -15
  40. package/src/browser/client.ts +686 -0
  41. package/src/browser/index.ts +74 -0
  42. package/src/browser/instrument.ts +275 -0
  43. package/src/browser/transport.ts +184 -0
  44. package/src/browser/types.ts +105 -0
  45. package/src/browser/version.ts +2 -0
  46. package/src/browser/vitals.ts +149 -0
  47. package/src/config.ts +2 -0
  48. package/src/console.ts +182 -0
  49. package/src/redact.ts +12 -6
  50. package/src/types.ts +9 -2
  51. package/test/browser.test.js +328 -0
  52. package/test/console.test.js +182 -0
  53. 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
- if (Buffer.isBuffer(input) || ArrayBuffer.isView(input)) {
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" && !Buffer.isBuffer(raw)) {
197
+ if (typeof raw === "object" && !ArrayBuffer.isView(raw)) {
194
198
  return this.fit(this.value(raw), maxBytes);
195
199
  }
196
200
 
197
- const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
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 (Buffer.byteLength(serialized) <= maxBytes) {
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 = Buffer.from(text);
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).toString("utf8"), truncated: true };
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
  }