midline-agent 0.3.0 → 0.4.1

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 (56) hide show
  1. package/README.md +90 -5
  2. package/browser/package.json +8 -0
  3. package/dist/agent.d.ts +9 -1
  4. package/dist/agent.js +44 -68
  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 +8 -7
  21. package/dist/config.js +12 -24
  22. package/dist/esm/browser/client.js +601 -0
  23. package/dist/esm/browser/index.js +52 -0
  24. package/dist/esm/browser/instrument.js +210 -0
  25. package/dist/esm/browser/transport.js +164 -0
  26. package/dist/esm/browser/types.js +1 -0
  27. package/dist/esm/browser/version.js +2 -0
  28. package/dist/esm/browser/vitals.js +132 -0
  29. package/dist/esm/package.json +1 -0
  30. package/dist/esm/redact.js +224 -0
  31. package/dist/esm/types.js +1 -0
  32. package/dist/redact.d.ts +3 -0
  33. package/dist/redact.js +12 -6
  34. package/dist/socket-transport.d.ts +58 -0
  35. package/dist/socket-transport.js +157 -0
  36. package/package.json +31 -4
  37. package/scripts/mark-esm.js +6 -0
  38. package/src/agent.ts +46 -73
  39. package/src/browser/client.ts +686 -0
  40. package/src/browser/index.ts +74 -0
  41. package/src/browser/instrument.ts +275 -0
  42. package/src/browser/transport.ts +184 -0
  43. package/src/browser/types.ts +105 -0
  44. package/src/browser/version.ts +2 -0
  45. package/src/browser/vitals.ts +149 -0
  46. package/src/config.ts +12 -23
  47. package/src/redact.ts +12 -6
  48. package/src/socket-transport.ts +188 -0
  49. package/test/agent.test.js +47 -51
  50. package/test/browser.test.js +328 -0
  51. package/test/console.test.js +11 -10
  52. package/test/helpers.js +54 -1
  53. package/test/middleware.test.js +5 -5
  54. package/test/proxy.test.js +4 -4
  55. package/tsconfig.esm.json +14 -0
  56. package/src/transport.ts +0 -125
@@ -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
@@ -1,6 +1,5 @@
1
1
  import { CaInput, CaptureOptions, MidlineConfig } from "./types";
2
2
  export declare const DEFAULT_ENDPOINT = "https://api.usemidline.com";
3
- export declare const INGEST_PATH = "/api/api-monitor/events";
4
3
  /** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
5
4
  export declare class ConfigError extends Error {
6
5
  constructor(message: string);
@@ -15,8 +14,8 @@ export interface ResolvedCapture {
15
14
  export interface ResolvedConfig {
16
15
  apiKey: string;
17
16
  serviceName?: string;
18
- ingestUrl: URL;
19
- batchUrl: URL;
17
+ /** The Midline server's origin; the ingest gateway lives at `${socketOrigin}/ingest`. */
18
+ socketOrigin: URL;
20
19
  /** Full trust store for the Midline endpoint, or undefined for Node's default. */
21
20
  ca?: Array<string | Buffer>;
22
21
  hasCustomCa: boolean;
@@ -43,12 +42,14 @@ export declare function envFlag(name: string): boolean | undefined;
43
42
  export declare function envInt(name: string): number | undefined;
44
43
  export declare function isLoopback(hostname: string): boolean;
45
44
  /**
46
- * Turns whatever the user configured into the ingest URL.
47
- *
45
+ * Turns whatever the user configured into the Midline server's origin — the
46
+ * agent connects to `${origin}/ingest` over socket.io, so only the origin
47
+ * matters now. Tolerates an old-style full ingest URL some existing
48
+ * `MIDLINE_ENDPOINT` values still carry (from before the socket.io transport):
48
49
  * `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
49
- * and `https://gateway.internal/midline` all work; the last is treated as a prefix.
50
+ * and `https://gateway.internal/midline` all resolve to the same origin.
50
51
  */
51
- export declare function resolveIngestUrl(endpoint: string): URL;
52
+ export declare function resolveEndpointOrigin(endpoint: string): URL;
52
53
  /**
53
54
  * Loads extra CAs. Every entry must contain at least one parseable certificate, so a
54
55
  * typo in a path fails loudly at startup instead of silently trusting nothing.
package/dist/config.js CHANGED
@@ -33,12 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.ConfigError = exports.INGEST_PATH = exports.DEFAULT_ENDPOINT = void 0;
36
+ exports.ConfigError = exports.DEFAULT_ENDPOINT = void 0;
37
37
  exports.env = env;
38
38
  exports.envFlag = envFlag;
39
39
  exports.envInt = envInt;
40
40
  exports.isLoopback = isLoopback;
41
- exports.resolveIngestUrl = resolveIngestUrl;
41
+ exports.resolveEndpointOrigin = resolveEndpointOrigin;
42
42
  exports.loadCa = loadCa;
43
43
  exports.trustStore = trustStore;
44
44
  exports.resolveCapture = resolveCapture;
@@ -47,7 +47,6 @@ const fs_1 = require("fs");
47
47
  const crypto_1 = require("crypto");
48
48
  const tls = __importStar(require("tls"));
49
49
  exports.DEFAULT_ENDPOINT = "https://api.usemidline.com";
50
- exports.INGEST_PATH = "/api/api-monitor/events";
51
50
  /** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
52
51
  class ConfigError extends Error {
53
52
  constructor(message) {
@@ -88,12 +87,14 @@ function isLoopback(hostname) {
88
87
  /^127(\.\d{1,3}){3}$/.test(host));
89
88
  }
90
89
  /**
91
- * Turns whatever the user configured into the ingest URL.
92
- *
90
+ * Turns whatever the user configured into the Midline server's origin — the
91
+ * agent connects to `${origin}/ingest` over socket.io, so only the origin
92
+ * matters now. Tolerates an old-style full ingest URL some existing
93
+ * `MIDLINE_ENDPOINT` values still carry (from before the socket.io transport):
93
94
  * `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
94
- * and `https://gateway.internal/midline` all work; the last is treated as a prefix.
95
+ * and `https://gateway.internal/midline` all resolve to the same origin.
95
96
  */
96
- function resolveIngestUrl(endpoint) {
97
+ function resolveEndpointOrigin(endpoint) {
97
98
  let url;
98
99
  try {
99
100
  url = new URL(endpoint);
@@ -111,19 +112,7 @@ function resolveIngestUrl(endpoint) {
111
112
  if (url.username || url.password) {
112
113
  throw new ConfigError("endpoint must not contain credentials; pass the key as apiKey");
113
114
  }
114
- url.search = "";
115
- url.hash = "";
116
- const path = url.pathname.replace(/\/+$/, "");
117
- if (path.endsWith(`${exports.INGEST_PATH}/batch`)) {
118
- url.pathname = path.slice(0, -"/batch".length);
119
- }
120
- else if (path.endsWith(exports.INGEST_PATH)) {
121
- url.pathname = path;
122
- }
123
- else {
124
- url.pathname = `${path}${exports.INGEST_PATH}`;
125
- }
126
- return url;
115
+ return new URL(url.origin);
127
116
  }
128
117
  /**
129
118
  * Loads extra CAs. Every entry must contain at least one parseable certificate, so a
@@ -197,16 +186,15 @@ function resolveCapture(capture) {
197
186
  }
198
187
  function resolveConfig(config) {
199
188
  const apiKey = (config.apiKey ?? env("MIDLINE_API_KEY") ?? "").trim();
200
- const ingestUrl = resolveIngestUrl(config.endpoint || env("MIDLINE_ENDPOINT") || exports.DEFAULT_ENDPOINT);
189
+ const socketOrigin = resolveEndpointOrigin(config.endpoint || env("MIDLINE_ENDPOINT") || exports.DEFAULT_ENDPOINT);
201
190
  const extraCa = loadCa(config.ca ?? env("MIDLINE_CUSTOM_CA"), "ca / MIDLINE_CUSTOM_CA");
202
- if (extraCa && ingestUrl.protocol !== "https:") {
191
+ if (extraCa && socketOrigin.protocol !== "https:") {
203
192
  throw new ConfigError("ca / MIDLINE_CUSTOM_CA is set but the endpoint is not https://");
204
193
  }
205
194
  return {
206
195
  apiKey,
207
196
  serviceName: config.serviceName ?? env("MIDLINE_SERVICE_NAME"),
208
- ingestUrl,
209
- batchUrl: new URL(`${ingestUrl.pathname}/batch`, ingestUrl),
197
+ socketOrigin,
210
198
  ca: trustStore(extraCa),
211
199
  hasCustomCa: Boolean(extraCa),
212
200
  environment: config.environment ?? env("MIDLINE_ENVIRONMENT"),