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,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.observeVitals = observeVitals;
4
+ /** web.dev thresholds: at or under the first is good, over the second is poor. */
5
+ const THRESHOLDS = {
6
+ LCP: [2500, 4000],
7
+ INP: [200, 500],
8
+ CLS: [0.1, 0.25],
9
+ FCP: [1800, 3000],
10
+ TTFB: [800, 1800],
11
+ };
12
+ const rate = (name, value) => value <= THRESHOLDS[name][0] ? "good" : value <= THRESHOLDS[name][1] ? "needs-improvement" : "poor";
13
+ /**
14
+ * Core Web Vitals without a dependency, measured the way the web-vitals library
15
+ * does and reported once, when the page is first hidden (the last moment a
16
+ * report reliably gets out). Browsers that don't expose an entry type simply
17
+ * omit that metric: Safari has no LCP, INP or CLS.
18
+ */
19
+ function observeVitals(win, onReport) {
20
+ const Observer = win.PerformanceObserver;
21
+ const supported = Observer?.supportedEntryTypes ?? [];
22
+ const perf = win.performance;
23
+ const doc = win.document;
24
+ const values = {};
25
+ const observers = [];
26
+ let activationStart = 0;
27
+ let navigationType;
28
+ try {
29
+ const navigation = perf?.getEntriesByType?.("navigation")?.[0];
30
+ if (navigation) {
31
+ activationStart = navigation.activationStart ?? 0;
32
+ navigationType = navigation.type;
33
+ if (typeof navigation.responseStart === "number" && navigation.responseStart > 0) {
34
+ values.TTFB = Math.max(0, navigation.responseStart - activationStart);
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ // no navigation timing
40
+ }
41
+ const observe = (type, handle, extra = {}) => {
42
+ if (!Observer || !supported.includes(type))
43
+ return;
44
+ try {
45
+ const observer = new Observer((list) => handle(list.getEntries()));
46
+ observer.observe({ type, buffered: true, ...extra });
47
+ observers.push({ observer, handle });
48
+ }
49
+ catch {
50
+ // unsupported option in this browser
51
+ }
52
+ };
53
+ observe("paint", (entries) => {
54
+ for (const entry of entries) {
55
+ if (entry.name === "first-contentful-paint")
56
+ values.FCP = Math.max(0, entry.startTime - activationStart);
57
+ }
58
+ });
59
+ observe("largest-contentful-paint", (entries) => {
60
+ const last = entries[entries.length - 1];
61
+ if (last)
62
+ values.LCP = Math.max(0, last.startTime - activationStart);
63
+ });
64
+ // CLS is the largest session window: shifts less than 1s apart, at most 5s long.
65
+ let windowValue = 0;
66
+ let windowStart = 0;
67
+ let windowLast = 0;
68
+ observe("layout-shift", (entries) => {
69
+ for (const entry of entries) {
70
+ if (entry.hadRecentInput || typeof entry.value !== "number")
71
+ continue;
72
+ if (windowValue && entry.startTime - windowLast < 1000 && entry.startTime - windowStart < 5000) {
73
+ windowValue += entry.value;
74
+ }
75
+ else {
76
+ windowValue = entry.value;
77
+ windowStart = entry.startTime;
78
+ }
79
+ windowLast = entry.startTime;
80
+ values.CLS = Math.max(values.CLS ?? 0, windowValue);
81
+ }
82
+ });
83
+ // INP: the slowest interaction, or near the 98th percentile on busy pages.
84
+ const interactions = new Map();
85
+ const recordInteractions = (entries) => {
86
+ for (const entry of entries) {
87
+ if (!entry.interactionId)
88
+ continue;
89
+ interactions.set(entry.interactionId, Math.max(interactions.get(entry.interactionId) ?? 0, entry.duration));
90
+ }
91
+ };
92
+ observe("event", recordInteractions, { durationThreshold: 40 });
93
+ observe("first-input", recordInteractions);
94
+ let reported = false;
95
+ const report = () => {
96
+ if (reported)
97
+ return;
98
+ reported = true;
99
+ for (const { observer, handle } of observers) {
100
+ try {
101
+ handle(observer.takeRecords());
102
+ observer.disconnect();
103
+ }
104
+ catch {
105
+ // already disconnected
106
+ }
107
+ }
108
+ if (interactions.size) {
109
+ const durations = [...interactions.values()].sort((a, b) => b - a);
110
+ values.INP = durations[Math.min(durations.length - 1, Math.floor(durations.length / 50))];
111
+ }
112
+ const vitals = {};
113
+ for (const name of Object.keys(values)) {
114
+ const raw = values[name];
115
+ if (typeof raw !== "number" || !Number.isFinite(raw))
116
+ continue;
117
+ const value = name === "CLS" ? Math.round(raw * 10000) / 10000 : Math.round(raw);
118
+ vitals[name] = { value, rating: rate(name, value) };
119
+ }
120
+ if (Object.keys(vitals).length)
121
+ onReport({ vitals, navigationType });
122
+ };
123
+ const onVisibility = () => {
124
+ if (doc?.visibilityState === "hidden")
125
+ report();
126
+ };
127
+ doc?.addEventListener("visibilitychange", onVisibility, true);
128
+ win.addEventListener("pagehide", report, true);
129
+ return () => {
130
+ doc?.removeEventListener("visibilitychange", onVisibility, true);
131
+ win.removeEventListener("pagehide", report, true);
132
+ for (const { observer } of observers)
133
+ observer.disconnect();
134
+ };
135
+ }
package/dist/cli.js CHANGED
File without changes
package/dist/config.d.ts CHANGED
@@ -27,6 +27,7 @@ export interface ResolvedConfig {
27
27
  redactFields: string[];
28
28
  redactHeaders: string[];
29
29
  capture: ResolvedCapture;
30
+ captureConsole: boolean;
30
31
  onError?: (message: string) => void;
31
32
  debug: boolean;
32
33
  flushIntervalMs: number;
package/dist/config.js CHANGED
@@ -216,6 +216,7 @@ function resolveConfig(config) {
216
216
  redactFields: [...(config.redactFields ?? []), ...(config.maskFields ?? [])],
217
217
  redactHeaders: config.redactHeaders ?? [],
218
218
  capture: resolveCapture(config.capture),
219
+ captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
219
220
  onError: config.onError,
220
221
  debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
221
222
  flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60000),
@@ -0,0 +1,46 @@
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
+ export type ConsoleStream = "stdout" | "stderr";
10
+ export type ConsoleLevel = "info" | "warn" | "error";
11
+ export interface ConsoleLine {
12
+ stream: ConsoleStream;
13
+ text: string;
14
+ level: ConsoleLevel;
15
+ /** When the line's first chunk was written, not when it was sent. */
16
+ timestamp: string;
17
+ }
18
+ /** Longest line kept. A log line past this is almost always a dumped payload. */
19
+ export declare const MAX_LINE_CHARS = 4096;
20
+ /**
21
+ * Runs `fn` with capture paused. The agent writes its own diagnostics through
22
+ * this, so a warning about delivery can't become an event that needs delivering.
23
+ */
24
+ export declare function withoutConsoleCapture<T>(fn: () => T): T;
25
+ export declare class ConsoleCapture {
26
+ private readonly onLine;
27
+ private readonly originals;
28
+ private readonly wrappers;
29
+ private readonly pending;
30
+ private busy;
31
+ private stopped;
32
+ constructor(onLine: (line: ConsoleLine) => void);
33
+ install(): void;
34
+ /** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
35
+ uninstall(): void;
36
+ /**
37
+ * Emits lines still waiting for a newline. Without `force`, only the ones that
38
+ * were already unfinished at the previous call, so a line being written in
39
+ * pieces isn't cut in half by a timer tick.
40
+ */
41
+ flushPending(force?: boolean): void;
42
+ private take;
43
+ private emit;
44
+ }
45
+ /** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
46
+ export declare function levelOf(stream: ConsoleStream, text: string): ConsoleLevel;
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ /**
3
+ * Console capture: what the host process prints — `console.log`, Nest's logger,
4
+ * pino, anything that reaches stdout or stderr — copied into Midline line by line.
5
+ *
6
+ * The streams are never changed. The original `write` runs first with the
7
+ * caller's own arguments and its return value is handed straight back, so
8
+ * back-pressure, callbacks and the terminal output are exactly what they were.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.ConsoleCapture = exports.MAX_LINE_CHARS = void 0;
12
+ exports.withoutConsoleCapture = withoutConsoleCapture;
13
+ exports.levelOf = levelOf;
14
+ /** Longest line kept. A log line past this is almost always a dumped payload. */
15
+ exports.MAX_LINE_CHARS = 4096;
16
+ const STREAMS = ["stdout", "stderr"];
17
+ /** CSI (colours, cursor moves), OSC (titles, hyperlinks) and the remaining two-byte escapes. */
18
+ const ANSI_ESCAPE = /\x1B\[[0-?]*[ -\/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B[@-Z\\-_]/g;
19
+ // Level words are matched in capitals only: lower-case "error" turns up in plenty of harmless lines.
20
+ const ERROR_MARKERS = [/\b(?:ERROR|FATAL|CRITICAL)\b/, /"level"\s*:\s*"?(?:error|fatal|critical|50|60)\b/i];
21
+ const WARN_MARKERS = [/\bWARN(?:ING)?\b/, /"level"\s*:\s*"?(?:warn|warning|40)\b/i];
22
+ let suppressed = 0;
23
+ /**
24
+ * Runs `fn` with capture paused. The agent writes its own diagnostics through
25
+ * this, so a warning about delivery can't become an event that needs delivering.
26
+ */
27
+ function withoutConsoleCapture(fn) {
28
+ suppressed += 1;
29
+ try {
30
+ return fn();
31
+ }
32
+ finally {
33
+ suppressed -= 1;
34
+ }
35
+ }
36
+ class ConsoleCapture {
37
+ constructor(onLine) {
38
+ this.onLine = onLine;
39
+ this.originals = new Map();
40
+ this.wrappers = new Map();
41
+ this.pending = {
42
+ stdout: { text: "", at: "", stale: false },
43
+ stderr: { text: "", at: "", stale: false },
44
+ };
45
+ this.busy = false;
46
+ this.stopped = false;
47
+ }
48
+ install() {
49
+ for (const stream of STREAMS) {
50
+ const target = process[stream];
51
+ const original = target.write;
52
+ const capture = this;
53
+ const wrapper = function (...args) {
54
+ const result = original.apply(this, args);
55
+ capture.take(stream, args[0], args[1]);
56
+ return result;
57
+ };
58
+ this.originals.set(stream, original);
59
+ this.wrappers.set(stream, wrapper);
60
+ target.write = wrapper;
61
+ }
62
+ }
63
+ /** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
64
+ uninstall() {
65
+ this.stopped = true;
66
+ for (const stream of STREAMS) {
67
+ const wrapper = this.wrappers.get(stream);
68
+ if (wrapper && process[stream].write === wrapper) {
69
+ process[stream].write = this.originals.get(stream);
70
+ }
71
+ }
72
+ this.wrappers.clear();
73
+ this.originals.clear();
74
+ }
75
+ /**
76
+ * Emits lines still waiting for a newline. Without `force`, only the ones that
77
+ * were already unfinished at the previous call, so a line being written in
78
+ * pieces isn't cut in half by a timer tick.
79
+ */
80
+ flushPending(force = false) {
81
+ if (this.stopped || this.busy)
82
+ return;
83
+ this.busy = true;
84
+ try {
85
+ for (const stream of STREAMS) {
86
+ const pending = this.pending[stream];
87
+ if (!pending.text)
88
+ continue;
89
+ if (force || pending.stale) {
90
+ this.pending[stream] = { text: "", at: "", stale: false };
91
+ this.emit(stream, pending.text, pending.at);
92
+ }
93
+ else {
94
+ pending.stale = true;
95
+ }
96
+ }
97
+ }
98
+ catch {
99
+ // Best-effort, like the rest of capture.
100
+ }
101
+ finally {
102
+ this.busy = false;
103
+ }
104
+ }
105
+ take(stream, chunk, encoding) {
106
+ // `busy` also stops a loop if whatever handles a line prints something itself.
107
+ if (this.stopped || this.busy || suppressed > 0)
108
+ return;
109
+ this.busy = true;
110
+ try {
111
+ const text = decode(chunk, encoding);
112
+ if (!text)
113
+ return;
114
+ const now = new Date().toISOString();
115
+ const pending = this.pending[stream];
116
+ const lines = (pending.text + text).split("\n");
117
+ let rest = lines.pop() ?? "";
118
+ let at = pending.text ? pending.at : now;
119
+ for (const line of lines) {
120
+ this.emit(stream, line, at);
121
+ at = now;
122
+ }
123
+ if (rest.length > exports.MAX_LINE_CHARS) {
124
+ this.emit(stream, rest, at);
125
+ rest = "";
126
+ }
127
+ this.pending[stream] = { text: rest, at: rest ? at : "", stale: false };
128
+ }
129
+ catch {
130
+ // The write itself already happened; a line we couldn't read is just not sent.
131
+ }
132
+ finally {
133
+ this.busy = false;
134
+ }
135
+ }
136
+ emit(stream, raw, at) {
137
+ let text = raw.replace(ANSI_ESCAPE, "").replace(/\r$/, "");
138
+ // A carriage return redraws the line; what's left after the last one is what the terminal shows.
139
+ text = text.slice(text.lastIndexOf("\r") + 1);
140
+ if (!text.trim())
141
+ return;
142
+ if (text.length > exports.MAX_LINE_CHARS)
143
+ text = text.slice(0, exports.MAX_LINE_CHARS);
144
+ this.onLine({ stream, text, level: levelOf(stream, text), timestamp: at });
145
+ }
146
+ }
147
+ exports.ConsoleCapture = ConsoleCapture;
148
+ /** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
149
+ function levelOf(stream, text) {
150
+ const head = text.slice(0, 256);
151
+ if (ERROR_MARKERS.some((pattern) => pattern.test(head)))
152
+ return "error";
153
+ if (WARN_MARKERS.some((pattern) => pattern.test(head)))
154
+ return "warn";
155
+ return stream === "stderr" ? "warn" : "info";
156
+ }
157
+ function decode(chunk, encoding) {
158
+ if (typeof chunk === "string") {
159
+ return typeof encoding === "string" && !/^utf-?8$/i.test(encoding) && Buffer.isEncoding(encoding)
160
+ ? Buffer.from(chunk, encoding).toString("utf8")
161
+ : chunk;
162
+ }
163
+ if (chunk instanceof Uint8Array) {
164
+ return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString("utf8");
165
+ }
166
+ return "";
167
+ }