midline-agent 0.1.8 → 0.2.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/src/agent.ts CHANGED
@@ -1,125 +1,757 @@
1
- import fetch from "cross-fetch";
2
- import { MidlineConfig, MidlineEvent } from "./types";
1
+ import { ConfigError, ResolvedCapture, ResolvedConfig, envFlag, resolveConfig } from "./config";
2
+ import type { RequestContext } from "./context";
3
+ import { Redactor } from "./redact";
4
+ import { Transport } from "./transport";
5
+ import { CapturedMessage, EventCategory, EventSeverity, MidlineConfig, MidlineEvent } from "./types";
3
6
 
7
+ export const SDK_VERSION: string = (() => {
8
+ try {
9
+ return require("../package.json").version;
10
+ } catch {
11
+ return "unknown";
12
+ }
13
+ })();
14
+
15
+ const MAX_REQUEST_BYTES = 512 * 1024;
16
+ const MIN_REQUEST_BYTES = 16 * 1024;
17
+ const MAX_QUEUE_BYTES = 16 * 1024 * 1024;
18
+
19
+ /** Verification failures. Retrying is still right: they clear once the server is fixed. */
20
+ const TLS_ERROR_REASONS: Record<string, string> = {
21
+ DEPTH_ZERO_SELF_SIGNED_CERT: "presented a self-signed certificate",
22
+ SELF_SIGNED_CERT_IN_CHAIN: "presented a chain that ends in an untrusted self-signed CA",
23
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: "presented a certificate whose issuer could not be verified (missing intermediate, or a private CA)",
24
+ UNABLE_TO_GET_ISSUER_CERT: "presented a certificate whose issuer could not be found",
25
+ UNABLE_TO_GET_ISSUER_CERT_LOCALLY: "presented a certificate whose issuer is not in the trust store (missing intermediate, or a private CA)",
26
+ CERT_HAS_EXPIRED: "presented an expired certificate",
27
+ CERT_NOT_YET_VALID: "presented a certificate that is not valid yet (check this machine's clock)",
28
+ CERT_REVOKED: "presented a revoked certificate",
29
+ CERT_UNTRUSTED: "presented an untrusted certificate",
30
+ CERT_REJECTED: "presented a rejected certificate",
31
+ ERR_TLS_CERT_ALTNAME_INVALID: "presented a certificate issued for a different hostname",
32
+ HOSTNAME_MISMATCH: "presented a certificate issued for a different hostname",
33
+ };
34
+
35
+ interface QueuedEvent {
36
+ wire: Record<string, unknown>;
37
+ bytes: number;
38
+ }
39
+
40
+ type Outcome =
41
+ | { kind: "ok" }
42
+ | { kind: "retry"; retryAfterMs?: number }
43
+ | { kind: "stop" }
44
+ | { kind: "tooLarge" }
45
+ | { kind: "rejected"; detail: string };
46
+
47
+ /** Raw material for a request event, handed over by the middleware or the proxy. */
48
+ export interface HttpExchange {
49
+ integration: NonNullable<MidlineEvent["integration"]>;
50
+ context: RequestContext;
51
+ method?: string;
52
+ /** Path plus query string, as received. */
53
+ url: string;
54
+ statusCode?: number;
55
+ durationMs: number;
56
+ ip?: string;
57
+ userAgent?: string;
58
+ routeTemplate?: string;
59
+ request?: ExchangeMessage;
60
+ response?: ExchangeMessage;
61
+ destination?: { url: string };
62
+ errorCode?: string;
63
+ errorMessage?: string;
64
+ aborted?: boolean;
65
+ capture?: ResolvedCapture;
66
+ }
67
+
68
+ export interface ExchangeMessage {
69
+ headers?: Record<string, unknown>;
70
+ contentType?: string;
71
+ /** Only present when body capture is on. Parsed object or raw bytes. */
72
+ body?: unknown;
73
+ bodyBytes?: number;
74
+ /** The tap stopped reading before the end of the body. */
75
+ truncated?: boolean;
76
+ }
77
+
78
+ /**
79
+ * Ships request/error events to the Midline server.
80
+ *
81
+ * The contract with the host application is that the agent is never allowed to
82
+ * affect it: an unreachable, untrusted, slow or misconfigured Midline server costs a
83
+ * single explanatory log line and bounded buffered memory — never a crash, never a
84
+ * stalled request, never a disabled certificate check.
85
+ */
4
86
  export class MidlineAgent {
5
- private static config: MidlineConfig;
6
- private static queue: MidlineEvent[] = [];
7
- private static timer: NodeJS.Timeout;
8
-
9
- static init(config: MidlineConfig) {
10
- this.config = {
11
- endpoint: "https://api.usemidline.com/api/api-monitor/events",
12
- ...config,
13
- // Deliberately after the spread. `password` and `token` are always
14
- // masked; a caller-supplied list extends that set rather than replacing
15
- // it, which is what happened while this sat above `...config`.
16
- maskFields: [...new Set(["password", "token", ...(config.maskFields || [])])],
17
- };
87
+ private static defaultAgent: MidlineAgent | null = null;
88
+
89
+ /** Creates the process-wide agent used by `midlineMiddleware()` and the static helpers. */
90
+ static init(config: MidlineConfig = {}): MidlineAgent {
91
+ MidlineAgent.defaultAgent?.close();
92
+ MidlineAgent.defaultAgent = new MidlineAgent(config);
93
+ return MidlineAgent.defaultAgent;
94
+ }
95
+
96
+ static get current(): MidlineAgent | null {
97
+ return MidlineAgent.defaultAgent;
98
+ }
18
99
 
19
- this.startQueue();
100
+ static addEvent(event: MidlineEvent): void {
101
+ MidlineAgent.defaultAgent?.addEvent(event);
20
102
  }
21
103
 
22
- static addEvent(event: MidlineEvent) {
23
- if (!this.config) {
104
+ static flush(): Promise<void> {
105
+ return MidlineAgent.defaultAgent?.flush() ?? Promise.resolve();
106
+ }
107
+
108
+ static shutdown(timeoutMs?: number): Promise<void> {
109
+ return MidlineAgent.defaultAgent?.shutdown(timeoutMs) ?? Promise.resolve();
110
+ }
111
+
112
+ private readonly config: ResolvedConfig | null = null;
113
+ private readonly redactor: Redactor;
114
+ private readonly transport: Transport | null = null;
115
+ private readonly onErrorHook?: (message: string) => void;
116
+ private readonly debugLogs: boolean;
117
+
118
+ private queue: QueuedEvent[] = [];
119
+ private queueBytes = 0;
120
+ private timer: NodeJS.Timeout | null = null;
121
+ private drainPromise: Promise<void> | null = null;
122
+ private failures = 0;
123
+ private retryAfter = 0;
124
+ private stopped = false;
125
+ private lastNotice = "";
126
+ private dropped = 0;
127
+ private maxRequestBytes = MAX_REQUEST_BYTES;
128
+ private batchLimit = Number.MAX_SAFE_INTEGER;
129
+
130
+ constructor(config: MidlineConfig = {}) {
131
+ this.onErrorHook = config.onError;
132
+ this.debugLogs = config.debug ?? envFlag("MIDLINE_DEBUG") ?? false;
133
+ this.redactor = new Redactor([...(config.redactFields ?? []), ...(config.maskFields ?? [])], config.redactHeaders);
134
+
135
+ if ((config.enabled ?? envFlag("MIDLINE_ENABLED")) === false) {
136
+ this.stopped = true;
24
137
  return;
25
138
  }
26
- this.queue.push(event);
27
- }
28
-
29
- private static transformEvent(event: MidlineEvent): any {
30
- const isError = event.type === "error";
31
-
32
- // Build the payload structure matching the expected API format
33
- const transformed: any = {
34
- apiKey: this.config.apiKey,
35
- eventType: event.type,
36
- route: event.path,
37
- method: event.method || "GET",
38
- statusCode: event.statusCode || (isError ? 500 : 200),
39
- responseTime: event.duration || 0,
40
- timestamp: event.timestamp,
41
- ip: event.ip,
42
- userAgent: event.userAgent,
43
- service: this.config.serviceName,
44
- metadata: {
45
- source: "sdk",
46
- version: "0.1.3",
47
- integrationType: "express"
48
- }
139
+
140
+ let resolved: ResolvedConfig;
141
+ try {
142
+ resolved = resolveConfig(config);
143
+ } catch (err) {
144
+ this.stopped = true;
145
+ const detail = err instanceof ConfigError ? err.message : String((err as Error)?.message ?? err);
146
+ this.log("error", `midline: ${detail} monitoring is off.`);
147
+ return;
148
+ }
149
+
150
+ if (!resolved.apiKey) {
151
+ this.stopped = true;
152
+ this.log("warn", "midline: no apiKey (or MIDLINE_API_KEY) — monitoring is off.");
153
+ return;
154
+ }
155
+
156
+ this.config = resolved;
157
+ this.transport = new Transport(resolved.ingestUrl, {
158
+ ca: resolved.ca,
159
+ connectTimeoutMs: resolved.connectTimeoutMs,
160
+ timeoutMs: resolved.timeoutMs,
161
+ userAgent: `midline-agent/${SDK_VERSION} node/${process.version}`,
162
+ });
163
+
164
+ this.timer = setInterval(() => {
165
+ void this.drain(false);
166
+ }, resolved.flushIntervalMs);
167
+ // Telemetry must never be the reason a process refuses to exit.
168
+ this.timer.unref?.();
169
+ }
170
+
171
+ /** False when the agent is off: no key, bad config, disabled, rejected key, or closed. */
172
+ get active(): boolean {
173
+ return !this.stopped && this.config !== null;
174
+ }
175
+
176
+ get capture(): ResolvedCapture | null {
177
+ return this.config?.capture ?? null;
178
+ }
179
+
180
+ get queued(): number {
181
+ return this.queue.length;
182
+ }
183
+
184
+ addEvent(event: MidlineEvent): void {
185
+ if (!this.active) return;
186
+ try {
187
+ this.enqueue(this.toWire(event, false));
188
+ } catch (err) {
189
+ this.report("event-build", `midline: could not build an event (${(err as Error)?.message}); skipped it.`);
190
+ }
191
+ }
192
+
193
+ /** Records one HTTP exchange. Captured parts are redacted here, before queueing. */
194
+ recordHttp(exchange: HttpExchange): void {
195
+ if (!this.active) return;
196
+ try {
197
+ const capture = exchange.capture ?? this.config!.capture;
198
+ const queryIndex = exchange.url.indexOf("?");
199
+ const path = queryIndex === -1 ? exchange.url : exchange.url.slice(0, queryIndex);
200
+ const search = queryIndex === -1 ? "" : exchange.url.slice(queryIndex + 1);
201
+ const failed = Boolean(exchange.errorCode);
202
+
203
+ const event: MidlineEvent = {
204
+ type: failed ? "error" : "request",
205
+ path: path || "/",
206
+ method: exchange.method,
207
+ statusCode: exchange.statusCode,
208
+ duration: exchange.durationMs,
209
+ ip: exchange.ip,
210
+ userAgent: exchange.userAgent,
211
+ requestId: exchange.context.requestId,
212
+ correlationId: exchange.context.correlationId,
213
+ traceId: exchange.context.traceId,
214
+ spanId: exchange.context.spanId,
215
+ routeTemplate: exchange.routeTemplate,
216
+ integration: exchange.integration,
217
+ destination: exchange.destination,
218
+ errorCode: exchange.errorCode,
219
+ message: exchange.errorMessage,
220
+ aborted: exchange.aborted || undefined,
221
+ severity: failed ? "high" : undefined,
222
+ category: failed && exchange.integration === "proxy" ? "infrastructure" : undefined,
223
+ request: this.captureMessage(capture, exchange.request, search),
224
+ response: this.captureMessage(capture, exchange.response),
225
+ };
226
+ this.enqueue(this.toWire(event, true));
227
+ } catch (err) {
228
+ this.report("event-build", `midline: could not record a request (${(err as Error)?.message}); skipped it.`);
229
+ }
230
+ }
231
+
232
+ /** Sends whatever is buffered now, ignoring backoff, and keeps the process alive until done. */
233
+ async flush(): Promise<void> {
234
+ if (!this.active) return;
235
+ this.retryAfter = 0;
236
+ if (this.drainPromise) {
237
+ await this.drainPromise;
238
+ }
239
+ await this.drain(true);
240
+ }
241
+
242
+ /** Flushes with a deadline, then closes. For graceful shutdown. */
243
+ async shutdown(timeoutMs = 5000): Promise<void> {
244
+ if (this.active) {
245
+ let timeout: NodeJS.Timeout | undefined;
246
+ await Promise.race([
247
+ this.flush().catch(() => undefined),
248
+ new Promise<void>((resolve) => {
249
+ timeout = setTimeout(resolve, timeoutMs);
250
+ }),
251
+ ]);
252
+ if (timeout) clearTimeout(timeout);
253
+ }
254
+ this.close();
255
+ }
256
+
257
+ /** Stops the timer, drops the buffer and releases sockets. Safe to call more than once. */
258
+ close(): void {
259
+ this.stopped = true;
260
+ if (this.timer) {
261
+ clearInterval(this.timer);
262
+ this.timer = null;
263
+ }
264
+ this.queue = [];
265
+ this.queueBytes = 0;
266
+ this.transport?.destroy();
267
+ }
268
+
269
+ private captureMessage(capture: ResolvedCapture, message: ExchangeMessage | undefined, search?: string): CapturedMessage | undefined {
270
+ if (!message && !search) return undefined;
271
+ const out: CapturedMessage = {};
272
+
273
+ if (capture.headers && message?.headers) {
274
+ out.headers = this.redactor.headers(message.headers);
275
+ }
276
+ if (capture.query && search) {
277
+ out.query = this.redactor.query(search);
278
+ }
279
+ if (message && message.body !== undefined) {
280
+ Object.assign(out, this.redactor.body(message.body, message.contentType, capture.maxBodyBytes));
281
+ if (message.truncated) out.truncated = true;
282
+ }
283
+ if (message?.bodyBytes !== undefined && (out.body !== undefined || out.omitted)) {
284
+ out.bodyBytes = message.bodyBytes;
285
+ }
286
+ return Object.keys(out).length ? out : undefined;
287
+ }
288
+
289
+ private redactManualMessage(message: CapturedMessage | undefined): CapturedMessage | undefined {
290
+ if (!message) return undefined;
291
+ const maxBodyBytes = this.config!.capture.maxBodyBytes || 4096;
292
+ return {
293
+ ...message,
294
+ headers: this.redactor.headers(message.headers),
295
+ query: message.query ? (this.redactor.value(message.query) as CapturedMessage["query"]) : undefined,
296
+ ...(message.body !== undefined ? this.redactor.body(message.body, undefined, maxBodyBytes) : {}),
49
297
  };
298
+ }
50
299
 
51
- // Add environment, host, region if configured
52
- if (this.config.environment) {
53
- transformed.environment = this.config.environment;
300
+ /**
301
+ * Maps an event onto the ingest schema. Everything beyond the long-standing
302
+ * top-level fields travels in `metadata` and `payload`, which every Midline
303
+ * server version accepts — so upgrading the agent never gets events rejected by a
304
+ * server that hasn't been upgraded yet.
305
+ */
306
+ private toWire(event: MidlineEvent, preRedacted: boolean): Record<string, unknown> {
307
+ const config = this.config!;
308
+ const type = event.type;
309
+ const statusCode = Number.isInteger(event.statusCode) && event.statusCode! >= 100 && event.statusCode! <= 599
310
+ ? event.statusCode
311
+ : type === "error" ? 500 : undefined;
312
+ const { severity, category } = classify(event, statusCode);
313
+ const route = this.redactor.string(stripQuery(event.path) || "/", 2048);
314
+
315
+ const payload: Record<string, unknown> = {};
316
+ if (type === "error" || event.message) {
317
+ payload.error = event.message ? this.redactor.string(event.message, 1024) : type === "error" ? "Unknown error" : undefined;
318
+ }
319
+ if (event.stack) payload.stack = this.redactor.string(event.stack, 16 * 1024);
320
+ if (event.breadcrumbs?.length) {
321
+ payload.breadcrumbs = event.breadcrumbs.slice(-20).map((crumb) => ({
322
+ type: String(crumb.type).slice(0, 32),
323
+ message: this.redactor.string(String(crumb.message), 256),
324
+ timestamp: crumb.timestamp,
325
+ }));
54
326
  }
55
- if (this.config.host) {
56
- transformed.host = this.config.host;
327
+ if (type === "error") {
328
+ payload.context = { route, method: event.method };
329
+ }
330
+ const request = preRedacted ? event.request : this.redactManualMessage(event.request);
331
+ const response = preRedacted ? event.response : this.redactManualMessage(event.response);
332
+ if (request) payload.request = request;
333
+ if (response) payload.response = response;
334
+ if (event.destination) payload.destination = { url: this.redactor.string(event.destination.url, 2048) };
335
+ if (event.errorCode) payload.code = String(event.errorCode).slice(0, 64);
336
+
337
+ const metadata: Record<string, unknown> = {
338
+ source: "sdk",
339
+ sdk: "midline-agent",
340
+ version: SDK_VERSION,
341
+ integrationType: event.integration ?? "manual",
342
+ };
343
+ if (event.requestId) metadata.requestId = event.requestId;
344
+ if (event.correlationId) metadata.correlationId = event.correlationId;
345
+ if (event.routeTemplate) metadata.routeTemplate = event.routeTemplate.slice(0, 512);
346
+ if (event.aborted) metadata.aborted = true;
347
+
348
+ const wire: Record<string, unknown> = {
349
+ apiKey: config.apiKey,
350
+ eventType: type,
351
+ route,
352
+ method: (event.method || "GET").toUpperCase().slice(0, 16),
353
+ statusCode,
354
+ responseTime: Math.min(Math.max(0, Math.round(Number(event.duration) || 0)), 86_400_000),
355
+ timestamp: validTimestamp(event.timestamp),
356
+ // Clamped to the server's validation limits: one over-long field would
357
+ // otherwise get every event rejected.
358
+ ip: clamp(event.ip, 64),
359
+ userAgent: clamp(event.userAgent, 512),
360
+ service: clamp(config.serviceName, 128),
361
+ environment: clamp(config.environment, 128),
362
+ host: clamp(config.host, 256),
363
+ region: clamp(config.region, 64),
364
+ release: clamp(config.release, 128),
365
+ severity,
366
+ category,
367
+ traceId: clamp(event.traceId, 128),
368
+ spanId: clamp(event.spanId, 128),
369
+ ruleId: clamp(event.ruleId, 128),
370
+ threatDetected: typeof event.threatDetected === "boolean" ? event.threatDetected : undefined,
371
+ metadata,
372
+ payload: Object.keys(payload).length ? payload : undefined,
373
+ };
374
+
375
+ for (const key of Object.keys(wire)) {
376
+ if (wire[key] === undefined) delete wire[key];
57
377
  }
58
- if (this.config.region) {
59
- transformed.region = this.config.region;
378
+ return wire;
379
+ }
380
+
381
+ private enqueue(wire: Record<string, unknown>): void {
382
+ const config = this.config!;
383
+ let bytes = Buffer.byteLength(JSON.stringify(wire));
384
+
385
+ if (bytes > config.maxEventBytes) {
386
+ bytes = shrink(wire, config.maxEventBytes);
387
+ if (bytes > config.maxEventBytes) {
388
+ this.dropped += 1;
389
+ this.report("event-too-large", `midline: an event was larger than maxEventBytes (${config.maxEventBytes}) even without bodies; dropped it.`);
390
+ return;
391
+ }
60
392
  }
61
- if (this.config.release) {
62
- transformed.release = this.config.release;
393
+
394
+ this.queue.push({ wire, bytes });
395
+ this.queueBytes += bytes;
396
+ this.trimQueue();
397
+ }
398
+
399
+ /** Oldest events go first — during an outage the most recent minute is worth more than the first. */
400
+ private trimQueue(): void {
401
+ const config = this.config!;
402
+ while (this.queue.length > config.maxQueueSize || (this.queueBytes > MAX_QUEUE_BYTES && this.queue.length > 1)) {
403
+ const removed = this.queue.shift()!;
404
+ this.queueBytes -= removed.bytes;
405
+ this.dropped += 1;
63
406
  }
407
+ }
64
408
 
65
- // For errors, add error-specific fields
66
- if (isError) {
67
- transformed.severity = "critical";
68
- transformed.category = "application";
69
- transformed.payload = {
70
- error: event.message || "Unknown error",
71
- stack: event.stack,
72
- context: {
73
- route: event.path,
74
- method: event.method
75
- },
76
- breadcrumbs: event.breadcrumbs
77
- };
78
- } else {
79
- // For requests, use appropriate category and severity
80
- transformed.category = "performance";
81
-
82
- // Map status codes to valid severity levels: low, medium, high, critical
83
- if (event.statusCode) {
84
- if (event.statusCode >= 500) {
85
- transformed.severity = "high";
86
- transformed.category = "application"; // Server errors are application issues
87
- } else if (event.statusCode >= 400) {
88
- transformed.severity = "medium";
89
- transformed.category = "application"; // Client errors are application issues
409
+ private requeue(batch: QueuedEvent[]): void {
410
+ this.queue.unshift(...batch);
411
+ this.queueBytes += batch.reduce((sum, item) => sum + item.bytes, 0);
412
+ this.trimQueue();
413
+ }
414
+
415
+ private takeBatch(): QueuedEvent[] {
416
+ const config = this.config!;
417
+ const batch: QueuedEvent[] = [];
418
+ let bytes = 0;
419
+ const limit = Math.min(config.maxBatchSize, this.batchLimit);
420
+ while (this.queue.length && batch.length < limit) {
421
+ const next = this.queue[0];
422
+ if (batch.length > 0 && bytes + next.bytes > this.maxRequestBytes) break;
423
+ batch.push(this.queue.shift()!);
424
+ bytes += next.bytes;
425
+ this.queueBytes -= next.bytes;
426
+ }
427
+ return batch;
428
+ }
429
+
430
+ private drain(keepProcessAlive: boolean): Promise<void> {
431
+ if (this.drainPromise) return this.drainPromise;
432
+ if (!this.active || !this.queue.length || Date.now() < this.retryAfter) {
433
+ return Promise.resolve();
434
+ }
435
+ this.drainPromise = this.runDrain(keepProcessAlive).finally(() => {
436
+ this.drainPromise = null;
437
+ });
438
+ return this.drainPromise;
439
+ }
440
+
441
+ private async runDrain(keepProcessAlive: boolean): Promise<void> {
442
+ while (this.queue.length && this.active) {
443
+ // Taken off the queue while in flight, so overflow trimming can't remove
444
+ // events that are mid-send and then be confused about what was accepted.
445
+ const batch = this.takeBatch();
446
+ const outcome = await this.send(batch, keepProcessAlive);
447
+
448
+ if (outcome.kind === "ok") {
449
+ this.onSuccess();
450
+ continue;
451
+ }
452
+ if (outcome.kind === "stop") {
453
+ this.disable();
454
+ return;
455
+ }
456
+ if (outcome.kind === "tooLarge") {
457
+ if (batch.length > 1) {
458
+ // Halve by count as well as bytes: a byte floor alone would resend the
459
+ // same batch of small events forever. The server's limit doesn't move,
460
+ // so the smaller size sticks.
461
+ const batchBytes = batch.reduce((sum, item) => sum + item.bytes, 0);
462
+ this.batchLimit = Math.max(1, Math.floor(batch.length / 2));
463
+ this.maxRequestBytes = Math.max(MIN_REQUEST_BYTES, Math.min(this.maxRequestBytes, Math.floor(batchBytes / 2)));
464
+ this.requeue(batch);
90
465
  } else {
91
- transformed.severity = "low";
466
+ this.dropped += 1;
467
+ this.report("http-413", "midline: the Midline server rejected an event as too large (HTTP 413); dropped it.");
92
468
  }
469
+ continue;
470
+ }
471
+ if (outcome.kind === "rejected") {
472
+ if (batch.length === 1) {
473
+ this.dropped += 1;
474
+ this.report(`rejected:${outcome.detail}`, `midline: the Midline server rejected an event (${outcome.detail}); dropped it.`);
475
+ continue;
476
+ }
477
+ // One malformed event fails validation for the whole batch. Send them one
478
+ // at a time so it only costs that event.
479
+ const isolated = await this.sendIndividually(batch, keepProcessAlive);
480
+ if (!isolated) return;
481
+ continue;
482
+ }
483
+
484
+ this.requeue(batch);
485
+ this.onFailure(outcome.retryAfterMs);
486
+ return;
487
+ }
488
+ }
489
+
490
+ /** Returns false if delivery should stop for this drain. */
491
+ private async sendIndividually(batch: QueuedEvent[], keepProcessAlive: boolean): Promise<boolean> {
492
+ for (let index = 0; index < batch.length; index++) {
493
+ const outcome = await this.send([batch[index]], keepProcessAlive);
494
+ if (outcome.kind === "ok") {
495
+ this.onSuccess();
496
+ } else if (outcome.kind === "rejected" || outcome.kind === "tooLarge") {
497
+ this.dropped += 1;
498
+ const detail = outcome.kind === "rejected" ? outcome.detail : "HTTP 413";
499
+ this.report(`rejected:${detail}`, `midline: the Midline server rejected an event (${detail}); dropped it.`);
500
+ } else if (outcome.kind === "stop") {
501
+ this.disable();
502
+ return false;
93
503
  } else {
94
- transformed.severity = "low";
504
+ this.requeue(batch.slice(index));
505
+ this.onFailure(outcome.retryAfterMs);
506
+ return false;
95
507
  }
96
508
  }
509
+ return true;
510
+ }
97
511
 
98
- return transformed;
99
- }
100
-
101
- private static startQueue() {
102
- this.timer = setInterval(async () => {
103
- if (!this.queue.length) return;
104
- const batch = [...this.queue];
105
- this.queue = [];
106
-
107
- // Transform events to match the expected API format
108
- const transformedBatch = batch.map(event => this.transformEvent(event));
109
- // Send each event individually (or as a batch if API supports it)
110
- for (const event of transformedBatch) {
111
- try {
112
- const response = await fetch(this.config.endpoint!, {
113
- method: "POST",
114
- headers: {
115
- "Content-Type": "application/json"
116
- },
117
- body: JSON.stringify(event)
118
- });
119
- } catch (err) {
120
- console.error("MidlineAgent send error:", err);
121
- }
512
+ private async send(batch: QueuedEvent[], keepProcessAlive: boolean): Promise<Outcome> {
513
+ const config = this.config!;
514
+ const body = JSON.stringify({ events: batch.map((item) => item.wire) });
515
+
516
+ let result;
517
+ try {
518
+ result = await this.transport!.post(config.batchUrl, body, { "x-api-key": config.apiKey }, keepProcessAlive);
519
+ } catch (err) {
520
+ this.report(`transport:${errorCode(err)}`, this.describe(err, batch.length));
521
+ return { kind: "retry" };
522
+ }
523
+
524
+ const { status } = result;
525
+ if (status >= 200 && status < 300) {
526
+ // Servers that predate 401-on-bad-key answer 201 and count the rejects.
527
+ const summary = parseJson(result.body);
528
+ const failed = typeof summary?.failed === "number" ? summary.failed : 0;
529
+ if (failed >= batch.length && batch.length > 0) {
530
+ this.log("error", "midline: the Midline server did not accept this API key. Monitoring is now off — retrying wouldn't help. Check apiKey / MIDLINE_API_KEY.");
531
+ return { kind: "stop" };
532
+ }
533
+ if (failed > 0) {
534
+ this.dropped += failed;
535
+ this.report("partial", `midline: the Midline server rejected ${failed} of ${batch.length} events.`);
536
+ }
537
+ return { kind: "ok" };
538
+ }
539
+ if (status === 401 || status === 403) {
540
+ this.log(
541
+ "error",
542
+ `midline: the Midline server rejected the API key (HTTP ${status}). ` +
543
+ "Monitoring is now off — retrying wouldn't help. Check apiKey / MIDLINE_API_KEY.",
544
+ );
545
+ return { kind: "stop" };
546
+ }
547
+ if (status === 413) {
548
+ return { kind: "tooLarge" };
549
+ }
550
+ if (status === 408 || status === 429 || status >= 500) {
551
+ const retryAfterMs = parseRetryAfter(result.headers["retry-after"]);
552
+ this.report(`http-${status}`, `midline: the Midline server returned HTTP ${status}; events are buffered and will be retried.`);
553
+ return { kind: "retry", retryAfterMs };
554
+ }
555
+ if (status >= 300 && status < 400) {
556
+ // Never followed: that would hand the API key to wherever the redirect points.
557
+ const location = String(result.headers.location ?? "").slice(0, 200);
558
+ this.report(
559
+ `http-${status}`,
560
+ `midline: the Midline endpoint redirected (HTTP ${status}${location ? ` to ${location}` : ""}). ` +
561
+ "Redirects are not followed; set MIDLINE_ENDPOINT to the final URL.",
562
+ );
563
+ return { kind: "retry" };
564
+ }
565
+ return { kind: "rejected", detail: `HTTP ${status}${serverMessage(result.body)}` };
566
+ }
567
+
568
+ /** One line per distinct fault, not one per failed event. */
569
+ private report(signature: string, message: string): void {
570
+ if (this.lastNotice === signature && !this.debugLogs) {
571
+ return;
572
+ }
573
+ this.lastNotice = signature;
574
+ this.log("warn", message);
575
+ }
576
+
577
+ private describe(err: unknown, inFlight: number): string {
578
+ const config = this.config!;
579
+ const origin = config.ingestUrl.origin;
580
+ const code = errorCode(err);
581
+ const buffered = ` ${this.queue.length + inFlight} event(s) buffered; your application is unaffected.`;
582
+
583
+ if (TLS_ERROR_REASONS[code] || code.startsWith("ERR_SSL") || code === "EPROTO") {
584
+ const reason = TLS_ERROR_REASONS[code] ?? "failed the TLS handshake";
585
+ return (
586
+ `midline: ${origin} ${reason} (${code}), so the connection was refused. Certificate verification stays on. ` +
587
+ (config.hasCustomCa
588
+ ? "The configured ca / MIDLINE_CUSTOM_CA did not validate it either. "
589
+ : "If this is the Midline server, its HTTPS certificate needs fixing on the server; for a self-hosted server behind a private CA, set ca / MIDLINE_CUSTOM_CA. ") +
590
+ `Delivery resumes on its own once a trusted certificate is served.${buffered}`
591
+ );
592
+ }
593
+ if (code === "ETIMEDOUT") {
594
+ return `midline: ${origin} did not answer within ${config.timeoutMs}ms; retrying with backoff.${buffered}`;
595
+ }
596
+ if (code === "ECONNECT_TIMEOUT") {
597
+ return `midline: could not connect to ${origin} within ${config.connectTimeoutMs}ms; retrying with backoff.${buffered}`;
598
+ }
599
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
600
+ return `midline: cannot resolve ${config.ingestUrl.hostname} (${code}) — check MIDLINE_ENDPOINT and DNS; retrying with backoff.${buffered}`;
601
+ }
602
+ if (code === "ECONNREFUSED") {
603
+ return `midline: ${origin} refused the connection; retrying with backoff.${buffered}`;
604
+ }
605
+ if (code === "ECONNRESET" || code === "EPIPE") {
606
+ return `midline: the connection to ${origin} was reset; retrying with backoff.${buffered}`;
607
+ }
608
+ return `midline: could not reach ${origin} (${code || (err as Error)?.message || "unknown error"}); retrying with backoff.${buffered}`;
609
+ }
610
+
611
+ private onSuccess(): void {
612
+ if (this.failures > 0) {
613
+ const lost = this.dropped;
614
+ this.log("info", `midline: delivery to the Midline server recovered${lost ? `; ${lost} event(s) were dropped meanwhile` : ""}.`);
615
+ this.dropped = 0;
616
+ // Only a recovered outage resets de-duplication; a stream of individually
617
+ // rejected events between successes should still log once.
618
+ this.lastNotice = "";
619
+ }
620
+ this.failures = 0;
621
+ this.retryAfter = 0;
622
+ }
623
+
624
+ private onFailure(retryAfterMs?: number): void {
625
+ const config = this.config!;
626
+ this.failures += 1;
627
+ // Exponential backoff, capped, with jitter so a fleet of instances doesn't
628
+ // retry in lockstep against a server that is coming back up.
629
+ const ceiling = Math.min(config.flushIntervalMs * 2 ** Math.min(this.failures, 12), config.maxRetryDelayMs);
630
+ const backoff = Math.round(ceiling * (0.5 + Math.random() * 0.5));
631
+ const wait = retryAfterMs !== undefined ? Math.min(Math.max(retryAfterMs, backoff), config.maxRetryDelayMs) : backoff;
632
+ this.retryAfter = Date.now() + wait;
633
+ }
634
+
635
+ /** Unrecoverable configuration problem: go quiet instead of looping forever. */
636
+ private disable(): void {
637
+ this.close();
638
+ }
639
+
640
+ private log(level: "info" | "warn" | "error", message: string): void {
641
+ if (this.onErrorHook) {
642
+ try {
643
+ this.onErrorHook(message);
644
+ } catch {
645
+ // A broken logging hook must not become the host application's problem.
122
646
  }
123
- }, 1500);
647
+ if (!this.debugLogs) return;
648
+ }
649
+ if (level === "error") console.error(message);
650
+ else if (level === "warn") console.warn(message);
651
+ else console.info(message);
652
+ }
653
+ }
654
+
655
+ function classify(event: MidlineEvent, statusCode: number | undefined): { severity: EventSeverity; category: EventCategory } {
656
+ if (event.severity && event.category) {
657
+ return { severity: event.severity, category: event.category };
124
658
  }
659
+ let severity: EventSeverity = "low";
660
+ let category: EventCategory = "performance";
661
+
662
+ if (event.type === "error") {
663
+ severity = "critical";
664
+ category = "application";
665
+ } else if (event.type === "security") {
666
+ severity = "high";
667
+ category = "security";
668
+ } else if (event.type === "custom") {
669
+ category = "business";
670
+ } else if (statusCode && statusCode >= 500) {
671
+ severity = "high";
672
+ category = "application";
673
+ } else if (statusCode && statusCode >= 400) {
674
+ severity = "medium";
675
+ category = "application";
676
+ }
677
+ return { severity: event.severity ?? severity, category: event.category ?? category };
678
+ }
679
+
680
+ /** Drops the heaviest optional parts until the event fits. Returns the new size. */
681
+ function shrink(wire: Record<string, unknown>, maxBytes: number): number {
682
+ const payload = wire.payload as Record<string, any> | undefined;
683
+ const size = () => Buffer.byteLength(JSON.stringify(wire));
684
+ if (!payload) return size();
685
+
686
+ const steps: Array<() => void> = [
687
+ () => markOmitted(payload.response, "body"),
688
+ () => markOmitted(payload.request, "body"),
689
+ () => {
690
+ if (typeof payload.stack === "string") payload.stack = payload.stack.slice(0, 2048);
691
+ },
692
+ () => delete payload.breadcrumbs,
693
+ () => markOmitted(payload.response, "headers"),
694
+ () => markOmitted(payload.request, "headers"),
695
+ () => markOmitted(payload.request, "query"),
696
+ ];
697
+ let bytes = size();
698
+ for (const step of steps) {
699
+ if (bytes <= maxBytes) break;
700
+ step();
701
+ bytes = size();
702
+ }
703
+ return bytes;
704
+ }
705
+
706
+ function markOmitted(message: Record<string, any> | undefined, field: string): void {
707
+ if (message && message[field] !== undefined) {
708
+ delete message[field];
709
+ message.omitted = "event size limit";
710
+ }
711
+ }
712
+
713
+ function clamp(value: unknown, max: number): string | undefined {
714
+ if (value === undefined || value === null || value === "") return undefined;
715
+ return String(value).slice(0, max);
716
+ }
717
+
718
+ function stripQuery(path: string): string {
719
+ const index = path.search(/[?#]/);
720
+ return index === -1 ? path : path.slice(0, index);
721
+ }
722
+
723
+ function validTimestamp(value: string | undefined): string {
724
+ if (value) {
725
+ const parsed = Date.parse(value);
726
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
727
+ }
728
+ return new Date().toISOString();
729
+ }
730
+
731
+ function errorCode(err: unknown): string {
732
+ const e = err as any;
733
+ return String(e?.code ?? e?.cause?.code ?? e?.errno ?? e?.name ?? "");
734
+ }
735
+
736
+ function parseJson(text: string): any {
737
+ try {
738
+ return JSON.parse(text);
739
+ } catch {
740
+ return undefined;
741
+ }
742
+ }
743
+
744
+ function serverMessage(body: string): string {
745
+ const message = parseJson(body)?.message;
746
+ const text = Array.isArray(message) ? message.join("; ") : typeof message === "string" ? message : "";
747
+ return text ? `: ${text.slice(0, 300)}` : "";
748
+ }
749
+
750
+ function parseRetryAfter(header: string | string[] | undefined): number | undefined {
751
+ const value = Array.isArray(header) ? header[0] : header;
752
+ if (!value) return undefined;
753
+ const seconds = Number(value);
754
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
755
+ const date = Date.parse(value);
756
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
125
757
  }