midline-agent 0.4.0 → 0.5.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/CHANGELOG.md +25 -0
- package/README.md +10 -7
- package/dist/agent.d.ts +9 -1
- package/dist/agent.js +44 -68
- package/dist/browser/client.d.ts +1 -0
- package/dist/browser/client.js +16 -2
- package/dist/browser/types.d.ts +9 -1
- package/dist/browser/version.d.ts +1 -1
- package/dist/browser/version.js +1 -1
- package/dist/config.d.ts +8 -7
- package/dist/config.js +12 -24
- package/dist/esm/browser/client.js +16 -2
- package/dist/esm/browser/version.js +1 -1
- package/dist/socket-transport.d.ts +58 -0
- package/dist/socket-transport.js +157 -0
- package/package.json +6 -2
- package/src/agent.ts +46 -73
- package/src/browser/client.ts +16 -2
- package/src/browser/types.ts +9 -1
- package/src/browser/version.ts +1 -1
- package/src/config.ts +12 -23
- package/src/socket-transport.ts +188 -0
- package/test/agent.test.js +47 -51
- package/test/browser.test.js +48 -2
- package/test/console.test.js +11 -10
- package/test/helpers.js +54 -1
- package/test/middleware.test.js +5 -5
- package/test/proxy.test.js +4 -4
- package/src/transport.ts +0 -125
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `midline-agent`, compiled from git history. Dates reflect commit dates, newest first.
|
|
4
|
+
|
|
5
|
+
## 2026-09-17
|
|
6
|
+
- feat: `midline-agent/browser` captures pageviews — on load and on every SPA route change — as the
|
|
7
|
+
basis for visitor counting and funnel views in the Midline dashboard (0.5.0)
|
|
8
|
+
- feat: every browser event carries a top-level `sessionId`, stable for the tab's lifetime
|
|
9
|
+
|
|
10
|
+
## 2026-09-14
|
|
11
|
+
- chore: bump version to 0.4.1
|
|
12
|
+
- chore: add socket.io dependencies and update configuration
|
|
13
|
+
|
|
14
|
+
## 2026-09-13
|
|
15
|
+
- feat: midline-agent/browser, a browser SDK in the same package (0.4.0)
|
|
16
|
+
- docs: console lines show on the Terminal page, and 5xx responses group into Issues
|
|
17
|
+
- docs: add README for midline-agent SDK
|
|
18
|
+
- feat!: midline-agent 0.2.0 — verified TLS only, proxy mode, safe delivery (breaking)
|
|
19
|
+
- feat: add midline-agent package with core types, configuration, and agent implementation
|
|
20
|
+
|
|
21
|
+
## 2026-09-12
|
|
22
|
+
- first commit
|
|
23
|
+
- feat: add midline-agent package for event monitoring and reporting
|
|
24
|
+
- Update agent API endpoint to api.usemidline.com
|
|
25
|
+
- docs: expand README with detailed NestJS, Node.js, and Express.js setup guides
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# midline-agent
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**SDK for Midline** — request and error monitoring with security and threat detection.
|
|
4
4
|
It also ships **`midline-agent/browser`** for web apps: see [Browser apps](#browser-apps).
|
|
5
5
|
|
|
6
6
|
Midline itself is not tied to Node. Every event lands on the same stream through a plain JSON
|
|
@@ -378,9 +378,10 @@ app.get("/x", (req, res) => { logger.info({ requestId: getRequestContext(req)?.r
|
|
|
378
378
|
## Browser apps
|
|
379
379
|
|
|
380
380
|
`midline-agent/browser` monitors a web app from the page: uncaught errors and unhandled rejections,
|
|
381
|
-
failed `fetch` and `XMLHttpRequest` calls, Web Vitals (LCP, INP, CLS, FCP, TTFB)
|
|
382
|
-
|
|
383
|
-
Next.js or no framework at all, because it
|
|
381
|
+
failed `fetch` and `XMLHttpRequest` calls, Web Vitals (LCP, INP, CLS, FCP, TTFB), pageviews for
|
|
382
|
+
visitor counting and funnel views, and, if you ask, console output. It has no dependencies, no Node
|
|
383
|
+
code, and works under React, Vue, Angular, Svelte, Next.js or no framework at all, because it
|
|
384
|
+
instruments the page rather than a framework.
|
|
384
385
|
|
|
385
386
|
```ts
|
|
386
387
|
import * as Midline from "midline-agent/browser";
|
|
@@ -424,6 +425,7 @@ the browser will block the call's preflight.
|
|
|
424
425
|
| `captureErrors` | `true` | Uncaught errors and unhandled rejections. |
|
|
425
426
|
| `captureRequests` | `"failed"` | `"failed"` (4xx, 5xx, network errors), `"all"`, or `false`. Every call is a breadcrumb either way. |
|
|
426
427
|
| `captureWebVitals` | `true` | Reported once, when the page is first hidden. |
|
|
428
|
+
| `capturePageviews` | `true` | A `pageview` event on load and on every SPA route change (`pushState`/`replaceState`/`popstate`), tagged with a per-tab `sessionId` — what the dashboard's visitor and funnel views are built from. |
|
|
427
429
|
| `captureConsole` | `false` | `true` for `error` and `warn`, or a list of levels. Lines appear on the Terminal page. Opt-in because wrapped console calls show the SDK as their source in devtools. |
|
|
428
430
|
| `tracePropagationTargets` | same origin | Strings match as URL prefixes (or path prefixes starting with `/`); RegExps match the full URL. |
|
|
429
431
|
| `ignoreErrors`, `ignoreUrls` | `[]` | Strings match as substrings. |
|
|
@@ -446,7 +448,7 @@ No bundler? Load the ES module build directly:
|
|
|
446
448
|
|
|
447
449
|
```html
|
|
448
450
|
<script type="module">
|
|
449
|
-
import * as Midline from "https://cdn.jsdelivr.net/npm/midline-agent@0.
|
|
451
|
+
import * as Midline from "https://cdn.jsdelivr.net/npm/midline-agent@0.5.0/dist/esm/browser/index.js";
|
|
450
452
|
Midline.init({ apiKey: "pk_…", service: "web" });
|
|
451
453
|
</script>
|
|
452
454
|
```
|
|
@@ -476,8 +478,8 @@ Authenticate with the `X-API-Key` header. An `apiKey` field in the body is still
|
|
|
476
478
|
|
|
477
479
|
| Field | Required | Notes |
|
|
478
480
|
| --- | --- | --- |
|
|
479
|
-
| `eventType` | yes | `request` · `error` · `security` · `performance` · `custom` |
|
|
480
|
-
| `route` | yes | Path only, e.g. `/v1/charges` |
|
|
481
|
+
| `eventType` | yes | `request` · `error` · `security` · `performance` · `pageview` · `custom` |
|
|
482
|
+
| `route` | yes | Path only, e.g. `/v1/charges`. For `pageview`, the page path. |
|
|
481
483
|
| `method` | no | HTTP method |
|
|
482
484
|
| `statusCode` | no | 100–599 |
|
|
483
485
|
| `responseTime` | no | Milliseconds |
|
|
@@ -486,6 +488,7 @@ Authenticate with the `X-API-Key` header. An `apiKey` field in the body is still
|
|
|
486
488
|
| `service` / `environment` / `release` | no | Where it came from |
|
|
487
489
|
| `timestamp` | no | ISO 8601; clamped to server time if implausibly far off |
|
|
488
490
|
| `requestId` / `correlationId` / `traceId` | no | For joining events across services |
|
|
491
|
+
| `sessionId` | no | A stable id for one visitor's session (e.g. a browser tab). Send the same value on every event from that session and Midline can build visitor counts and funnels from `pageview` events. Keep it opaque — never an email or a real user id. |
|
|
489
492
|
| `payload` | no | Free-form context — error message, stack, captured request/response |
|
|
490
493
|
| `metadata` | no | Free-form, e.g. SDK name and version |
|
|
491
494
|
|
package/dist/agent.d.ts
CHANGED
|
@@ -80,7 +80,15 @@ export declare class MidlineAgent {
|
|
|
80
80
|
recordHttp(exchange: HttpExchange): void;
|
|
81
81
|
private recordConsole;
|
|
82
82
|
private takeConsoleToken;
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Sends whatever is buffered now, ignoring backoff.
|
|
85
|
+
*
|
|
86
|
+
* The transport's connection is always unref'd (telemetry alone must never
|
|
87
|
+
* keep an otherwise-idle process alive), so this relies on something else in
|
|
88
|
+
* the process keeping the event loop open until the await resolves — true for
|
|
89
|
+
* the overwhelmingly common case (a running server), not guaranteed for a
|
|
90
|
+
* bare script whose last statement is `await agent.flush()`.
|
|
91
|
+
*/
|
|
84
92
|
flush(): Promise<void>;
|
|
85
93
|
/** Flushes with a deadline, then closes. For graceful shutdown. */
|
|
86
94
|
shutdown(timeoutMs?: number): Promise<void>;
|
package/dist/agent.js
CHANGED
|
@@ -4,7 +4,7 @@ exports.MidlineAgent = exports.SDK_VERSION = void 0;
|
|
|
4
4
|
const config_1 = require("./config");
|
|
5
5
|
const console_1 = require("./console");
|
|
6
6
|
const redact_1 = require("./redact");
|
|
7
|
-
const
|
|
7
|
+
const socket_transport_1 = require("./socket-transport");
|
|
8
8
|
exports.SDK_VERSION = (() => {
|
|
9
9
|
try {
|
|
10
10
|
return require("../package.json").version;
|
|
@@ -104,15 +104,19 @@ class MidlineAgent {
|
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
this.config = resolved;
|
|
107
|
-
this.transport = new
|
|
107
|
+
this.transport = new socket_transport_1.SocketTransport(resolved.socketOrigin, {
|
|
108
|
+
apiKey: resolved.apiKey,
|
|
108
109
|
ca: resolved.ca,
|
|
109
110
|
connectTimeoutMs: resolved.connectTimeoutMs,
|
|
110
111
|
timeoutMs: resolved.timeoutMs,
|
|
112
|
+
reconnectionDelayMs: resolved.flushIntervalMs,
|
|
113
|
+
reconnectionDelayMaxMs: resolved.maxRetryDelayMs,
|
|
114
|
+
idleDisconnectMs: Math.max(5 * resolved.flushIntervalMs, 30000),
|
|
111
115
|
userAgent: `midline-agent/${exports.SDK_VERSION} node/${process.version}`,
|
|
112
116
|
});
|
|
113
117
|
this.timer = setInterval(() => {
|
|
114
118
|
this.consoleCapture?.flushPending();
|
|
115
|
-
void this.drain(
|
|
119
|
+
void this.drain();
|
|
116
120
|
}, resolved.flushIntervalMs);
|
|
117
121
|
// Telemetry must never be the reason a process refuses to exit.
|
|
118
122
|
this.timer.unref?.();
|
|
@@ -212,7 +216,15 @@ class MidlineAgent {
|
|
|
212
216
|
this.consoleTokens -= 1;
|
|
213
217
|
return true;
|
|
214
218
|
}
|
|
215
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Sends whatever is buffered now, ignoring backoff.
|
|
221
|
+
*
|
|
222
|
+
* The transport's connection is always unref'd (telemetry alone must never
|
|
223
|
+
* keep an otherwise-idle process alive), so this relies on something else in
|
|
224
|
+
* the process keeping the event loop open until the await resolves — true for
|
|
225
|
+
* the overwhelmingly common case (a running server), not guaranteed for a
|
|
226
|
+
* bare script whose last statement is `await agent.flush()`.
|
|
227
|
+
*/
|
|
216
228
|
async flush() {
|
|
217
229
|
if (!this.active)
|
|
218
230
|
return;
|
|
@@ -221,7 +233,7 @@ class MidlineAgent {
|
|
|
221
233
|
if (this.drainPromise) {
|
|
222
234
|
await this.drainPromise;
|
|
223
235
|
}
|
|
224
|
-
await this.drain(
|
|
236
|
+
await this.drain();
|
|
225
237
|
}
|
|
226
238
|
/** Flushes with a deadline, then closes. For graceful shutdown. */
|
|
227
239
|
async shutdown(timeoutMs = 5000) {
|
|
@@ -417,23 +429,23 @@ class MidlineAgent {
|
|
|
417
429
|
}
|
|
418
430
|
return batch;
|
|
419
431
|
}
|
|
420
|
-
drain(
|
|
432
|
+
drain() {
|
|
421
433
|
if (this.drainPromise)
|
|
422
434
|
return this.drainPromise;
|
|
423
435
|
if (!this.active || !this.queue.length || Date.now() < this.retryAfter) {
|
|
424
436
|
return Promise.resolve();
|
|
425
437
|
}
|
|
426
|
-
this.drainPromise = this.runDrain(
|
|
438
|
+
this.drainPromise = this.runDrain().finally(() => {
|
|
427
439
|
this.drainPromise = null;
|
|
428
440
|
});
|
|
429
441
|
return this.drainPromise;
|
|
430
442
|
}
|
|
431
|
-
async runDrain(
|
|
443
|
+
async runDrain() {
|
|
432
444
|
while (this.queue.length && this.active) {
|
|
433
445
|
// Taken off the queue while in flight, so overflow trimming can't remove
|
|
434
446
|
// events that are mid-send and then be confused about what was accepted.
|
|
435
447
|
const batch = this.takeBatch();
|
|
436
|
-
const outcome = await this.send(batch
|
|
448
|
+
const outcome = await this.send(batch);
|
|
437
449
|
if (outcome.kind === "ok") {
|
|
438
450
|
this.onSuccess();
|
|
439
451
|
continue;
|
|
@@ -454,7 +466,7 @@ class MidlineAgent {
|
|
|
454
466
|
}
|
|
455
467
|
else {
|
|
456
468
|
this.dropped += 1;
|
|
457
|
-
this.report("
|
|
469
|
+
this.report("too-large", "midline: the Midline server rejected an event as too large; dropped it.");
|
|
458
470
|
}
|
|
459
471
|
continue;
|
|
460
472
|
}
|
|
@@ -468,7 +480,7 @@ class MidlineAgent {
|
|
|
468
480
|
}
|
|
469
481
|
// One malformed event fails validation for the whole batch. Send them one
|
|
470
482
|
// at a time so it only costs that event.
|
|
471
|
-
const isolated = await this.sendIndividually(batch
|
|
483
|
+
const isolated = await this.sendIndividually(batch);
|
|
472
484
|
if (!isolated)
|
|
473
485
|
return;
|
|
474
486
|
continue;
|
|
@@ -479,16 +491,16 @@ class MidlineAgent {
|
|
|
479
491
|
}
|
|
480
492
|
}
|
|
481
493
|
/** Returns false if delivery should stop for this drain. */
|
|
482
|
-
async sendIndividually(batch
|
|
494
|
+
async sendIndividually(batch) {
|
|
483
495
|
for (let index = 0; index < batch.length; index++) {
|
|
484
496
|
if (this.consoleUnsupported && batch[index].wire.eventType === "console")
|
|
485
497
|
continue;
|
|
486
|
-
const outcome = await this.send([batch[index]]
|
|
498
|
+
const outcome = await this.send([batch[index]]);
|
|
487
499
|
if (outcome.kind === "ok") {
|
|
488
500
|
this.onSuccess();
|
|
489
501
|
}
|
|
490
502
|
else if (outcome.kind === "rejected" || outcome.kind === "tooLarge") {
|
|
491
|
-
const detail = outcome.kind === "rejected" ? outcome.detail : "
|
|
503
|
+
const detail = outcome.kind === "rejected" ? outcome.detail : "too_large";
|
|
492
504
|
if (outcome.kind === "rejected" && this.refusedConsole(batch[index], detail))
|
|
493
505
|
continue;
|
|
494
506
|
this.dropped += 1;
|
|
@@ -506,53 +518,40 @@ class MidlineAgent {
|
|
|
506
518
|
}
|
|
507
519
|
return true;
|
|
508
520
|
}
|
|
509
|
-
async send(batch
|
|
510
|
-
|
|
511
|
-
const body = JSON.stringify({ events: batch.map((item) => item.wire) });
|
|
512
|
-
let result;
|
|
521
|
+
async send(batch) {
|
|
522
|
+
let ack;
|
|
513
523
|
try {
|
|
514
|
-
|
|
524
|
+
ack = await this.transport.send(batch.map((item) => item.wire));
|
|
515
525
|
}
|
|
516
526
|
catch (err) {
|
|
517
527
|
this.report(`transport:${errorCode(err)}`, this.describe(err, batch.length));
|
|
518
528
|
return { kind: "retry" };
|
|
519
529
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const summary = parseJson(result.body);
|
|
524
|
-
const failed = typeof summary?.failed === "number" ? summary.failed : 0;
|
|
525
|
-
if (failed >= batch.length && batch.length > 0) {
|
|
530
|
+
if (ack.ok) {
|
|
531
|
+
// Servers that predate 401-on-bad-key ack ok and count the rejects.
|
|
532
|
+
if (ack.rejected >= batch.length && batch.length > 0) {
|
|
526
533
|
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.");
|
|
527
534
|
return { kind: "stop" };
|
|
528
535
|
}
|
|
529
|
-
if (
|
|
530
|
-
this.dropped +=
|
|
531
|
-
this.report("partial", `midline: the Midline server rejected ${
|
|
536
|
+
if (ack.rejected > 0) {
|
|
537
|
+
this.dropped += ack.rejected;
|
|
538
|
+
this.report("partial", `midline: the Midline server rejected ${ack.rejected} of ${batch.length} events.`);
|
|
532
539
|
}
|
|
533
540
|
return { kind: "ok" };
|
|
534
541
|
}
|
|
535
|
-
if (
|
|
536
|
-
this.log("error", `midline: the Midline server rejected the API key (
|
|
542
|
+
if (ack.code === "unauthorized" || ack.code === "forbidden") {
|
|
543
|
+
this.log("error", `midline: the Midline server rejected the API key (${ack.code}). ` +
|
|
537
544
|
"Monitoring is now off — retrying wouldn't help. Check apiKey / MIDLINE_API_KEY.");
|
|
538
545
|
return { kind: "stop" };
|
|
539
546
|
}
|
|
540
|
-
if (
|
|
547
|
+
if (ack.code === "too_large") {
|
|
541
548
|
return { kind: "tooLarge" };
|
|
542
549
|
}
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return { kind: "retry", retryAfterMs };
|
|
550
|
+
if (ack.code === "rate_limited") {
|
|
551
|
+
this.report("rate_limited", `midline: the Midline server is rate-limiting this project; events are buffered and will be retried.`);
|
|
552
|
+
return { kind: "retry", retryAfterMs: ack.retryAfterMs };
|
|
547
553
|
}
|
|
548
|
-
|
|
549
|
-
// Never followed: that would hand the API key to wherever the redirect points.
|
|
550
|
-
const location = String(result.headers.location ?? "").slice(0, 200);
|
|
551
|
-
this.report(`http-${status}`, `midline: the Midline endpoint redirected (HTTP ${status}${location ? ` to ${location}` : ""}). ` +
|
|
552
|
-
"Redirects are not followed; set MIDLINE_ENDPOINT to the final URL.");
|
|
553
|
-
return { kind: "retry" };
|
|
554
|
-
}
|
|
555
|
-
return { kind: "rejected", detail: `HTTP ${status}${serverMessage(result.body)}` };
|
|
554
|
+
return { kind: "rejected", detail: `${ack.code}: ${ack.message}` };
|
|
556
555
|
}
|
|
557
556
|
/**
|
|
558
557
|
* True when a rejected event is a console line the server has no event type for,
|
|
@@ -582,7 +581,7 @@ class MidlineAgent {
|
|
|
582
581
|
}
|
|
583
582
|
describe(err, inFlight) {
|
|
584
583
|
const config = this.config;
|
|
585
|
-
const origin = config.
|
|
584
|
+
const origin = config.socketOrigin.origin;
|
|
586
585
|
const code = errorCode(err);
|
|
587
586
|
const buffered = ` ${this.queue.length + inFlight} event(s) buffered; your application is unaffected.`;
|
|
588
587
|
if (TLS_ERROR_REASONS[code] || code.startsWith("ERR_SSL") || code === "EPROTO") {
|
|
@@ -600,7 +599,7 @@ class MidlineAgent {
|
|
|
600
599
|
return `midline: could not connect to ${origin} within ${config.connectTimeoutMs}ms; retrying with backoff.${buffered}`;
|
|
601
600
|
}
|
|
602
601
|
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
603
|
-
return `midline: cannot resolve ${config.
|
|
602
|
+
return `midline: cannot resolve ${config.socketOrigin.hostname} (${code}) — check MIDLINE_ENDPOINT and DNS; retrying with backoff.${buffered}`;
|
|
604
603
|
}
|
|
605
604
|
if (code === "ECONNREFUSED") {
|
|
606
605
|
return `midline: ${origin} refused the connection; retrying with backoff.${buffered}`;
|
|
@@ -745,26 +744,3 @@ function errorCode(err) {
|
|
|
745
744
|
const e = err;
|
|
746
745
|
return String(e?.code ?? e?.cause?.code ?? e?.errno ?? e?.name ?? "");
|
|
747
746
|
}
|
|
748
|
-
function parseJson(text) {
|
|
749
|
-
try {
|
|
750
|
-
return JSON.parse(text);
|
|
751
|
-
}
|
|
752
|
-
catch {
|
|
753
|
-
return undefined;
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
function serverMessage(body) {
|
|
757
|
-
const message = parseJson(body)?.message;
|
|
758
|
-
const text = Array.isArray(message) ? message.join("; ") : typeof message === "string" ? message : "";
|
|
759
|
-
return text ? `: ${text.slice(0, 300)}` : "";
|
|
760
|
-
}
|
|
761
|
-
function parseRetryAfter(header) {
|
|
762
|
-
const value = Array.isArray(header) ? header[0] : header;
|
|
763
|
-
if (!value)
|
|
764
|
-
return undefined;
|
|
765
|
-
const seconds = Number(value);
|
|
766
|
-
if (Number.isFinite(seconds))
|
|
767
|
-
return Math.max(0, seconds * 1000);
|
|
768
|
-
const date = Date.parse(value);
|
|
769
|
-
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
770
|
-
}
|
package/dist/browser/client.d.ts
CHANGED
package/dist/browser/client.js
CHANGED
|
@@ -26,7 +26,7 @@ const CONSOLE_SEVERITY = {
|
|
|
26
26
|
/** Top-level fields the ingest API accepts from this SDK; it rejects the whole batch on anything else. */
|
|
27
27
|
const WIRE_FIELDS = new Set([
|
|
28
28
|
"eventType", "route", "method", "statusCode", "responseTime", "severity", "category", "timestamp",
|
|
29
|
-
"service", "environment", "release", "userAgent", "traceId", "spanId", "payload", "metadata",
|
|
29
|
+
"service", "environment", "release", "userAgent", "traceId", "spanId", "sessionId", "payload", "metadata",
|
|
30
30
|
]);
|
|
31
31
|
class BrowserConfigError extends Error {
|
|
32
32
|
}
|
|
@@ -81,6 +81,7 @@ function resolve(config) {
|
|
|
81
81
|
captureRequests: config.captureRequests === undefined ? "failed" : config.captureRequests,
|
|
82
82
|
consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
|
|
83
83
|
captureWebVitals: config.captureWebVitals !== false,
|
|
84
|
+
capturePageviews: config.capturePageviews !== false,
|
|
84
85
|
tracePropagationTargets: config.tracePropagationTargets,
|
|
85
86
|
ignoreErrors: config.ignoreErrors ?? [],
|
|
86
87
|
ignoreUrls: config.ignoreUrls ?? [],
|
|
@@ -169,6 +170,9 @@ class BrowserClient {
|
|
|
169
170
|
if (this.config.captureWebVitals) {
|
|
170
171
|
this.teardowns.push((0, vitals_js_1.observeVitals)(this.win, (report) => this.handleVitals(report)));
|
|
171
172
|
}
|
|
173
|
+
if (this.config.capturePageviews) {
|
|
174
|
+
this.handlePageview(this.currentRoute(), this.win.document?.referrer || undefined);
|
|
175
|
+
}
|
|
172
176
|
// Registered after the vitals listener so its event is queued before this sends.
|
|
173
177
|
const onHide = () => {
|
|
174
178
|
if (this.win.document?.visibilityState === "hidden")
|
|
@@ -327,6 +331,16 @@ class BrowserClient {
|
|
|
327
331
|
// A new view is a new trace, so its API calls don't blur into the last one's.
|
|
328
332
|
this.traceId = randomHex(16);
|
|
329
333
|
this.breadcrumb("navigation", `${pathOf(from)} -> ${pathOf(to)}`);
|
|
334
|
+
// history.pushState/replaceState have already run by the time this fires, so
|
|
335
|
+
// win.location — and therefore currentRoute() — reflects the new view.
|
|
336
|
+
if (this.config.capturePageviews)
|
|
337
|
+
this.handlePageview(this.currentRoute());
|
|
338
|
+
}
|
|
339
|
+
handlePageview(route, referrer) {
|
|
340
|
+
const event = this.base("pageview", route, "low", "application");
|
|
341
|
+
if (referrer)
|
|
342
|
+
event.payload = { referrer: this.redactor.string(referrer, 2048) };
|
|
343
|
+
this.emit(event);
|
|
330
344
|
}
|
|
331
345
|
handleVitals(report) {
|
|
332
346
|
const poor = Object.values(report.vitals).some((vital) => vital?.rating === "poor");
|
|
@@ -356,7 +370,6 @@ class BrowserClient {
|
|
|
356
370
|
sdk: "midline-agent/browser",
|
|
357
371
|
version: version_js_1.BROWSER_SDK_VERSION,
|
|
358
372
|
runtime: "browser",
|
|
359
|
-
sessionId: this.sessionId,
|
|
360
373
|
page: { url: this.pageUrl(), path: this.currentRoute() },
|
|
361
374
|
};
|
|
362
375
|
if (this.user && (this.user.id || this.user.username))
|
|
@@ -375,6 +388,7 @@ class BrowserClient {
|
|
|
375
388
|
release: this.config.release,
|
|
376
389
|
userAgent: clamp(nav?.userAgent, 512),
|
|
377
390
|
traceId: this.traceId,
|
|
391
|
+
sessionId: this.sessionId,
|
|
378
392
|
metadata,
|
|
379
393
|
};
|
|
380
394
|
}
|
package/dist/browser/types.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface MidlineBrowserConfig {
|
|
|
36
36
|
captureConsole?: boolean | ConsoleLevel[];
|
|
37
37
|
/** LCP, INP, CLS, FCP and TTFB, reported once when the page is first hidden. Default true. */
|
|
38
38
|
captureWebVitals?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* A `pageview` event on load and on every SPA route change (history push/replace/popstate),
|
|
41
|
+
* carrying the same per-tab `sessionId` as every other event. This is what a visitor-count or
|
|
42
|
+
* funnel view in the Midline dashboard is built from. Default true.
|
|
43
|
+
*/
|
|
44
|
+
capturePageviews?: boolean;
|
|
39
45
|
/**
|
|
40
46
|
* Requests that get a W3C `traceparent` header, so the backend's midline-agent
|
|
41
47
|
* links its request to this page. Strings match as URL prefixes (or path prefixes
|
|
@@ -71,7 +77,7 @@ export interface MidlineBrowserConfig {
|
|
|
71
77
|
}
|
|
72
78
|
/** An event as it is sent to the ingest API. */
|
|
73
79
|
export interface BrowserEvent {
|
|
74
|
-
eventType: "error" | "request" | "console" | "performance" | "custom";
|
|
80
|
+
eventType: "error" | "request" | "console" | "performance" | "pageview" | "custom";
|
|
75
81
|
route: string;
|
|
76
82
|
method?: string;
|
|
77
83
|
statusCode?: number;
|
|
@@ -85,6 +91,8 @@ export interface BrowserEvent {
|
|
|
85
91
|
userAgent?: string;
|
|
86
92
|
traceId?: string;
|
|
87
93
|
spanId?: string;
|
|
94
|
+
/** Stable for the tab's lifetime (sessionStorage-backed). Same value on every event this SDK sends. */
|
|
95
|
+
sessionId?: string;
|
|
88
96
|
payload?: Record<string, unknown>;
|
|
89
97
|
metadata: Record<string, unknown>;
|
|
90
98
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
|
|
2
|
-
export declare const BROWSER_SDK_VERSION = "0.
|
|
2
|
+
export declare const BROWSER_SDK_VERSION = "0.5.0";
|
package/dist/browser/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BROWSER_SDK_VERSION = void 0;
|
|
4
4
|
/** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
|
|
5
|
-
exports.BROWSER_SDK_VERSION = "0.
|
|
5
|
+
exports.BROWSER_SDK_VERSION = "0.5.0";
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
50
|
+
* and `https://gateway.internal/midline` all resolve to the same origin.
|
|
50
51
|
*/
|
|
51
|
-
export declare function
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
95
|
+
* and `https://gateway.internal/midline` all resolve to the same origin.
|
|
95
96
|
*/
|
|
96
|
-
function
|
|
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.
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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"),
|
|
@@ -21,7 +21,7 @@ const CONSOLE_SEVERITY = {
|
|
|
21
21
|
/** Top-level fields the ingest API accepts from this SDK; it rejects the whole batch on anything else. */
|
|
22
22
|
const WIRE_FIELDS = new Set([
|
|
23
23
|
"eventType", "route", "method", "statusCode", "responseTime", "severity", "category", "timestamp",
|
|
24
|
-
"service", "environment", "release", "userAgent", "traceId", "spanId", "payload", "metadata",
|
|
24
|
+
"service", "environment", "release", "userAgent", "traceId", "spanId", "sessionId", "payload", "metadata",
|
|
25
25
|
]);
|
|
26
26
|
export class BrowserConfigError extends Error {
|
|
27
27
|
}
|
|
@@ -75,6 +75,7 @@ function resolve(config) {
|
|
|
75
75
|
captureRequests: config.captureRequests === undefined ? "failed" : config.captureRequests,
|
|
76
76
|
consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
|
|
77
77
|
captureWebVitals: config.captureWebVitals !== false,
|
|
78
|
+
capturePageviews: config.capturePageviews !== false,
|
|
78
79
|
tracePropagationTargets: config.tracePropagationTargets,
|
|
79
80
|
ignoreErrors: config.ignoreErrors ?? [],
|
|
80
81
|
ignoreUrls: config.ignoreUrls ?? [],
|
|
@@ -163,6 +164,9 @@ export class BrowserClient {
|
|
|
163
164
|
if (this.config.captureWebVitals) {
|
|
164
165
|
this.teardowns.push(observeVitals(this.win, (report) => this.handleVitals(report)));
|
|
165
166
|
}
|
|
167
|
+
if (this.config.capturePageviews) {
|
|
168
|
+
this.handlePageview(this.currentRoute(), this.win.document?.referrer || undefined);
|
|
169
|
+
}
|
|
166
170
|
// Registered after the vitals listener so its event is queued before this sends.
|
|
167
171
|
const onHide = () => {
|
|
168
172
|
if (this.win.document?.visibilityState === "hidden")
|
|
@@ -321,6 +325,16 @@ export class BrowserClient {
|
|
|
321
325
|
// A new view is a new trace, so its API calls don't blur into the last one's.
|
|
322
326
|
this.traceId = randomHex(16);
|
|
323
327
|
this.breadcrumb("navigation", `${pathOf(from)} -> ${pathOf(to)}`);
|
|
328
|
+
// history.pushState/replaceState have already run by the time this fires, so
|
|
329
|
+
// win.location — and therefore currentRoute() — reflects the new view.
|
|
330
|
+
if (this.config.capturePageviews)
|
|
331
|
+
this.handlePageview(this.currentRoute());
|
|
332
|
+
}
|
|
333
|
+
handlePageview(route, referrer) {
|
|
334
|
+
const event = this.base("pageview", route, "low", "application");
|
|
335
|
+
if (referrer)
|
|
336
|
+
event.payload = { referrer: this.redactor.string(referrer, 2048) };
|
|
337
|
+
this.emit(event);
|
|
324
338
|
}
|
|
325
339
|
handleVitals(report) {
|
|
326
340
|
const poor = Object.values(report.vitals).some((vital) => vital?.rating === "poor");
|
|
@@ -350,7 +364,6 @@ export class BrowserClient {
|
|
|
350
364
|
sdk: "midline-agent/browser",
|
|
351
365
|
version: BROWSER_SDK_VERSION,
|
|
352
366
|
runtime: "browser",
|
|
353
|
-
sessionId: this.sessionId,
|
|
354
367
|
page: { url: this.pageUrl(), path: this.currentRoute() },
|
|
355
368
|
};
|
|
356
369
|
if (this.user && (this.user.id || this.user.username))
|
|
@@ -369,6 +382,7 @@ export class BrowserClient {
|
|
|
369
382
|
release: this.config.release,
|
|
370
383
|
userAgent: clamp(nav?.userAgent, 512),
|
|
371
384
|
traceId: this.traceId,
|
|
385
|
+
sessionId: this.sessionId,
|
|
372
386
|
metadata,
|
|
373
387
|
};
|
|
374
388
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
|
|
2
|
-
export const BROWSER_SDK_VERSION = "0.
|
|
2
|
+
export const BROWSER_SDK_VERSION = "0.5.0";
|