midline-agent 0.4.1 → 0.6.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 +30 -0
- package/README.md +9 -6
- 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/cli.js +36 -1
- package/dist/esm/browser/client.js +16 -2
- package/dist/esm/browser/version.js +1 -1
- package/dist/sourcemaps.d.ts +26 -0
- package/dist/sourcemaps.js +118 -0
- package/package.json +25 -8
- package/src/browser/client.ts +16 -2
- package/src/browser/types.ts +9 -1
- package/src/browser/version.ts +1 -1
- package/src/cli.ts +35 -1
- package/src/sourcemaps.ts +99 -0
- package/test/browser.test.js +48 -2
- package/test/sourcemaps.test.js +71 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `midline-agent`, compiled from git history. Dates reflect commit dates, newest first.
|
|
4
|
+
|
|
5
|
+
## 2026-09-20
|
|
6
|
+
- feat: `midline-agent sourcemaps upload <dir> --release <name>` uploads a build's source maps to Midline
|
|
7
|
+
so stack traces show original code. Authenticates with a project server API key; browser keys are refused
|
|
8
|
+
(0.6.0)
|
|
9
|
+
|
|
10
|
+
## 2026-09-17
|
|
11
|
+
- feat: `midline-agent/browser` captures pageviews — on load and on every SPA route change — as the
|
|
12
|
+
basis for visitor counting and funnel views in the Midline dashboard (0.5.0)
|
|
13
|
+
- feat: every browser event carries a top-level `sessionId`, stable for the tab's lifetime
|
|
14
|
+
|
|
15
|
+
## 2026-09-14
|
|
16
|
+
- chore: bump version to 0.4.1
|
|
17
|
+
- chore: add socket.io dependencies and update configuration
|
|
18
|
+
|
|
19
|
+
## 2026-09-13
|
|
20
|
+
- feat: midline-agent/browser, a browser SDK in the same package (0.4.0)
|
|
21
|
+
- docs: console lines show on the Terminal page, and 5xx responses group into Issues
|
|
22
|
+
- docs: add README for midline-agent SDK
|
|
23
|
+
- feat!: midline-agent 0.2.0 — verified TLS only, proxy mode, safe delivery (breaking)
|
|
24
|
+
- feat: add midline-agent package with core types, configuration, and agent implementation
|
|
25
|
+
|
|
26
|
+
## 2026-09-12
|
|
27
|
+
- first commit
|
|
28
|
+
- feat: add midline-agent package for event monitoring and reporting
|
|
29
|
+
- Update agent API endpoint to api.usemidline.com
|
|
30
|
+
- docs: expand README with detailed NestJS, Node.js, and Express.js setup guides
|
package/README.md
CHANGED
|
@@ -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/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.6.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.6.0";
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
const agent_1 = require("./agent");
|
|
5
5
|
const config_1 = require("./config");
|
|
6
6
|
const proxy_1 = require("./proxy");
|
|
7
|
+
const sourcemaps_1 = require("./sourcemaps");
|
|
7
8
|
const USAGE = `Usage: midline-agent proxy [--target <url>] [--port <n>] [--host <addr>]
|
|
8
9
|
|
|
9
10
|
Forwards traffic to a destination API and reports every exchange to Midline.
|
|
@@ -17,6 +18,18 @@ Midline server:
|
|
|
17
18
|
MIDLINE_ENDPOINT default https://api.usemidline.com
|
|
18
19
|
MIDLINE_CUSTOM_CA extra CA for the Midline server only (PEM or file path)
|
|
19
20
|
TARGET_API_CA extra CA for the destination only (PEM or file path)
|
|
21
|
+
|
|
22
|
+
Usage: midline-agent sourcemaps upload <dir> --release <name> [--url-prefix <prefix>] [--dry-run]
|
|
23
|
+
|
|
24
|
+
Uploads every .map file under <dir> so stack traces from that release show your
|
|
25
|
+
original code. Run it in CI after your build, with the same release name your SDK
|
|
26
|
+
reports.
|
|
27
|
+
|
|
28
|
+
--release Release the build belongs to (required)
|
|
29
|
+
--url-prefix What the built files are served under (default "~", any origin)
|
|
30
|
+
--endpoint Midline server (MIDLINE_ENDPOINT)
|
|
31
|
+
--api-key A project SERVER key, pk_ keys are refused (MIDLINE_API_KEY)
|
|
32
|
+
--dry-run List what would be uploaded
|
|
20
33
|
`;
|
|
21
34
|
function flag(args, name) {
|
|
22
35
|
const index = args.indexOf(`--${name}`);
|
|
@@ -25,8 +38,30 @@ function flag(args, name) {
|
|
|
25
38
|
const inline = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
26
39
|
return inline?.slice(name.length + 3);
|
|
27
40
|
}
|
|
41
|
+
async function sourcemaps(args) {
|
|
42
|
+
const dir = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
|
|
43
|
+
if (args[1] !== "upload" || !dir || args.includes("--help") || args.includes("-h")) {
|
|
44
|
+
process.stdout.write(USAGE);
|
|
45
|
+
process.exitCode = args.includes("--help") || args.includes("-h") ? 0 : 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const result = await (0, sourcemaps_1.uploadSourceMaps)({
|
|
49
|
+
dir,
|
|
50
|
+
release: flag(args, "release") ?? "",
|
|
51
|
+
urlPrefix: flag(args, "url-prefix"),
|
|
52
|
+
endpoint: flag(args, "endpoint"),
|
|
53
|
+
apiKey: flag(args, "api-key"),
|
|
54
|
+
dryRun: args.includes("--dry-run"),
|
|
55
|
+
log: (line) => process.stdout.write(`midline sourcemaps: ${line}\n`),
|
|
56
|
+
});
|
|
57
|
+
process.stdout.write(`midline sourcemaps: ${result.uploaded.length} ${args.includes("--dry-run") ? "listed" : "uploaded"}, ${result.failed.length} failed\n`);
|
|
58
|
+
if (result.failed.length)
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
28
61
|
async function main() {
|
|
29
62
|
const args = process.argv.slice(2);
|
|
63
|
+
if (args[0] === "sourcemaps")
|
|
64
|
+
return sourcemaps(args);
|
|
30
65
|
if (args[0] !== "proxy" || args.includes("--help") || args.includes("-h")) {
|
|
31
66
|
process.stdout.write(USAGE);
|
|
32
67
|
process.exitCode = args[0] === "proxy" || args.length === 0 ? 0 : 1;
|
|
@@ -58,6 +93,6 @@ async function main() {
|
|
|
58
93
|
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
59
94
|
}
|
|
60
95
|
main().catch((err) => {
|
|
61
|
-
process.stderr.write(`midline proxy: ${err instanceof config_1.ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
96
|
+
process.stderr.write(`midline ${process.argv[2] === "sourcemaps" ? "sourcemaps" : "proxy"}: ${err instanceof config_1.ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
62
97
|
process.exit(1);
|
|
63
98
|
});
|
|
@@ -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.6.0";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface UploadOptions {
|
|
2
|
+
/** Directory containing the build output (searched recursively for `*.map`). */
|
|
3
|
+
dir: string;
|
|
4
|
+
/** The release the build belongs to: the same string passed as `release` to the SDK. */
|
|
5
|
+
release: string;
|
|
6
|
+
/** What the built files are served under. Default `~`, meaning "any origin, this path". */
|
|
7
|
+
urlPrefix?: string;
|
|
8
|
+
/** Midline server. Env fallback: `MIDLINE_ENDPOINT`. */
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
/** A project's **server** API key. Env fallback: `MIDLINE_API_KEY`. Browser keys are refused by the server. */
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
log?: (line: string) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface UploadResult {
|
|
16
|
+
uploaded: string[];
|
|
17
|
+
failed: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
error: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
/** Every `*.map` under `dir`, without descending into node_modules. */
|
|
23
|
+
export declare function findMaps(dir: string, depth?: number): Promise<string[]>;
|
|
24
|
+
/** `dist/assets/app-3f2a.js.map` -> `~/assets/app-3f2a.js`: the built file the map describes, as the server will match it. */
|
|
25
|
+
export declare function uploadName(dir: string, mapFile: string, urlPrefix?: string): string;
|
|
26
|
+
export declare function uploadSourceMaps(options: UploadOptions): Promise<UploadResult>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.findMaps = findMaps;
|
|
37
|
+
exports.uploadName = uploadName;
|
|
38
|
+
exports.uploadSourceMaps = uploadSourceMaps;
|
|
39
|
+
const promises_1 = require("fs/promises");
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const config_1 = require("./config");
|
|
42
|
+
const MAX_DEPTH = 20;
|
|
43
|
+
const CONCURRENCY = 4;
|
|
44
|
+
/** Every `*.map` under `dir`, without descending into node_modules. */
|
|
45
|
+
async function findMaps(dir, depth = 0) {
|
|
46
|
+
if (depth > MAX_DEPTH)
|
|
47
|
+
return [];
|
|
48
|
+
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
|
|
49
|
+
const found = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const full = path.join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (entry.name !== "node_modules")
|
|
54
|
+
found.push(...(await findMaps(full, depth + 1)));
|
|
55
|
+
}
|
|
56
|
+
else if (entry.isFile() && entry.name.endsWith(".map")) {
|
|
57
|
+
found.push(full);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found.sort();
|
|
61
|
+
}
|
|
62
|
+
/** `dist/assets/app-3f2a.js.map` -> `~/assets/app-3f2a.js`: the built file the map describes, as the server will match it. */
|
|
63
|
+
function uploadName(dir, mapFile, urlPrefix = "~") {
|
|
64
|
+
const relative = path.relative(dir, mapFile).split(path.sep).join("/").replace(/\.map$/, "");
|
|
65
|
+
return `${urlPrefix.replace(/\/+$/, "")}/${relative}`;
|
|
66
|
+
}
|
|
67
|
+
async function uploadSourceMaps(options) {
|
|
68
|
+
const log = options.log ?? (() => undefined);
|
|
69
|
+
if (!options.release?.trim())
|
|
70
|
+
throw new config_1.ConfigError("--release is required");
|
|
71
|
+
const apiKey = options.apiKey ?? (0, config_1.env)("MIDLINE_API_KEY");
|
|
72
|
+
if (!apiKey && !options.dryRun)
|
|
73
|
+
throw new config_1.ConfigError("Set MIDLINE_API_KEY (a project server key) or pass --api-key");
|
|
74
|
+
const origin = (0, config_1.resolveEndpointOrigin)(options.endpoint ?? (0, config_1.env)("MIDLINE_ENDPOINT") ?? config_1.DEFAULT_ENDPOINT);
|
|
75
|
+
const maps = await findMaps(options.dir).catch((error) => {
|
|
76
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
77
|
+
throw new config_1.ConfigError(`${options.dir} is not a directory`);
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
if (!maps.length)
|
|
81
|
+
throw new config_1.ConfigError(`No .map files found under ${options.dir}. Enable source maps in your build.`);
|
|
82
|
+
const result = { uploaded: [], failed: [] };
|
|
83
|
+
const queue = [...maps];
|
|
84
|
+
const worker = async () => {
|
|
85
|
+
for (let file = queue.shift(); file; file = queue.shift()) {
|
|
86
|
+
const name = uploadName(options.dir, file, options.urlPrefix);
|
|
87
|
+
if (options.dryRun) {
|
|
88
|
+
log(`would upload ${name}`);
|
|
89
|
+
result.uploaded.push(name);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const body = new FormData();
|
|
94
|
+
body.append("release", options.release);
|
|
95
|
+
body.append("name", name);
|
|
96
|
+
body.append("file", new Blob([await (0, promises_1.readFile)(file)]), path.basename(file));
|
|
97
|
+
const response = await fetch(new URL("/api/sourcemaps/upload", origin), {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "x-midline-key": apiKey },
|
|
100
|
+
body,
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const detail = await response.json().catch(() => undefined);
|
|
104
|
+
throw new Error(`${response.status} ${detail?.message ?? response.statusText}`);
|
|
105
|
+
}
|
|
106
|
+
log(`uploaded ${name}`);
|
|
107
|
+
result.uploaded.push(name);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
log(`failed ${name}: ${message}`);
|
|
112
|
+
result.failed.push({ name, error: message });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, maps.length) }, worker));
|
|
117
|
+
return result;
|
|
118
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "midline-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Midline
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Midline \u2014 request, error and security monitoring for Node, and error, network, Web Vitals and pageview/visitor monitoring for browsers",
|
|
5
|
+
"homepage": "https://usemidline.com",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"types": "dist/index.d.ts",
|
|
7
8
|
"exports": {
|
|
@@ -38,16 +39,32 @@
|
|
|
38
39
|
},
|
|
39
40
|
"keywords": [
|
|
40
41
|
"midline",
|
|
42
|
+
"api-monitoring",
|
|
43
|
+
"api-observability",
|
|
44
|
+
"apm",
|
|
45
|
+
"browser",
|
|
46
|
+
"console-capture",
|
|
47
|
+
"error-tracking",
|
|
48
|
+
"errors",
|
|
41
49
|
"express",
|
|
50
|
+
"express-middleware",
|
|
51
|
+
"frontend",
|
|
52
|
+
"http-logging",
|
|
53
|
+
"issue-tracking",
|
|
54
|
+
"log-redaction",
|
|
42
55
|
"monitoring",
|
|
56
|
+
"nestjs",
|
|
57
|
+
"nodejs",
|
|
58
|
+
"observability",
|
|
59
|
+
"proxy",
|
|
60
|
+
"real-user-monitoring",
|
|
61
|
+
"request",
|
|
62
|
+
"request-logging",
|
|
63
|
+
"reverse-proxy",
|
|
64
|
+
"rum",
|
|
43
65
|
"sdk",
|
|
44
66
|
"security",
|
|
45
|
-
"
|
|
46
|
-
"errors",
|
|
47
|
-
"proxy",
|
|
48
|
-
"browser",
|
|
49
|
-
"web-vitals",
|
|
50
|
-
"frontend"
|
|
67
|
+
"web-vitals"
|
|
51
68
|
],
|
|
52
69
|
"author": "Your Name",
|
|
53
70
|
"license": "MIT",
|
package/src/browser/client.ts
CHANGED
|
@@ -44,7 +44,7 @@ const CONSOLE_SEVERITY: Record<ConsoleLevel, EventSeverity> = {
|
|
|
44
44
|
/** Top-level fields the ingest API accepts from this SDK; it rejects the whole batch on anything else. */
|
|
45
45
|
const WIRE_FIELDS = new Set<string>([
|
|
46
46
|
"eventType", "route", "method", "statusCode", "responseTime", "severity", "category", "timestamp",
|
|
47
|
-
"service", "environment", "release", "userAgent", "traceId", "spanId", "payload", "metadata",
|
|
47
|
+
"service", "environment", "release", "userAgent", "traceId", "spanId", "sessionId", "payload", "metadata",
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
50
|
interface Resolved {
|
|
@@ -58,6 +58,7 @@ interface Resolved {
|
|
|
58
58
|
captureRequests: RequestCapture;
|
|
59
59
|
consoleLevels: ConsoleLevel[];
|
|
60
60
|
captureWebVitals: boolean;
|
|
61
|
+
capturePageviews: boolean;
|
|
61
62
|
tracePropagationTargets?: Array<string | RegExp>;
|
|
62
63
|
ignoreErrors: Array<string | RegExp>;
|
|
63
64
|
ignoreUrls: Array<string | RegExp>;
|
|
@@ -124,6 +125,7 @@ function resolve(config: MidlineBrowserConfig): Resolved {
|
|
|
124
125
|
captureRequests: config.captureRequests === undefined ? "failed" : config.captureRequests,
|
|
125
126
|
consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
|
|
126
127
|
captureWebVitals: config.captureWebVitals !== false,
|
|
128
|
+
capturePageviews: config.capturePageviews !== false,
|
|
127
129
|
tracePropagationTargets: config.tracePropagationTargets,
|
|
128
130
|
ignoreErrors: config.ignoreErrors ?? [],
|
|
129
131
|
ignoreUrls: config.ignoreUrls ?? [],
|
|
@@ -222,6 +224,9 @@ export class BrowserClient {
|
|
|
222
224
|
if (this.config.captureWebVitals) {
|
|
223
225
|
this.teardowns.push(observeVitals(this.win, (report) => this.handleVitals(report)));
|
|
224
226
|
}
|
|
227
|
+
if (this.config.capturePageviews) {
|
|
228
|
+
this.handlePageview(this.currentRoute(), this.win.document?.referrer || undefined);
|
|
229
|
+
}
|
|
225
230
|
|
|
226
231
|
// Registered after the vitals listener so its event is queued before this sends.
|
|
227
232
|
const onHide = () => {
|
|
@@ -399,6 +404,15 @@ export class BrowserClient {
|
|
|
399
404
|
// A new view is a new trace, so its API calls don't blur into the last one's.
|
|
400
405
|
this.traceId = randomHex(16);
|
|
401
406
|
this.breadcrumb("navigation", `${pathOf(from)} -> ${pathOf(to)}`);
|
|
407
|
+
// history.pushState/replaceState have already run by the time this fires, so
|
|
408
|
+
// win.location — and therefore currentRoute() — reflects the new view.
|
|
409
|
+
if (this.config.capturePageviews) this.handlePageview(this.currentRoute());
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private handlePageview(route: string, referrer?: string): void {
|
|
413
|
+
const event = this.base("pageview", route, "low", "application");
|
|
414
|
+
if (referrer) event.payload = { referrer: this.redactor.string(referrer, 2048) };
|
|
415
|
+
this.emit(event);
|
|
402
416
|
}
|
|
403
417
|
|
|
404
418
|
private handleVitals(report: VitalsReport): void {
|
|
@@ -438,7 +452,6 @@ export class BrowserClient {
|
|
|
438
452
|
sdk: "midline-agent/browser",
|
|
439
453
|
version: BROWSER_SDK_VERSION,
|
|
440
454
|
runtime: "browser",
|
|
441
|
-
sessionId: this.sessionId,
|
|
442
455
|
page: { url: this.pageUrl(), path: this.currentRoute() },
|
|
443
456
|
};
|
|
444
457
|
if (this.user && (this.user.id || this.user.username)) metadata.user = { ...this.user };
|
|
@@ -456,6 +469,7 @@ export class BrowserClient {
|
|
|
456
469
|
release: this.config.release,
|
|
457
470
|
userAgent: clamp(nav?.userAgent, 512),
|
|
458
471
|
traceId: this.traceId,
|
|
472
|
+
sessionId: this.sessionId,
|
|
459
473
|
metadata,
|
|
460
474
|
};
|
|
461
475
|
}
|
package/src/browser/types.ts
CHANGED
|
@@ -42,6 +42,12 @@ export interface MidlineBrowserConfig {
|
|
|
42
42
|
captureConsole?: boolean | ConsoleLevel[];
|
|
43
43
|
/** LCP, INP, CLS, FCP and TTFB, reported once when the page is first hidden. Default true. */
|
|
44
44
|
captureWebVitals?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* A `pageview` event on load and on every SPA route change (history push/replace/popstate),
|
|
47
|
+
* carrying the same per-tab `sessionId` as every other event. This is what a visitor-count or
|
|
48
|
+
* funnel view in the Midline dashboard is built from. Default true.
|
|
49
|
+
*/
|
|
50
|
+
capturePageviews?: boolean;
|
|
45
51
|
|
|
46
52
|
/**
|
|
47
53
|
* Requests that get a W3C `traceparent` header, so the backend's midline-agent
|
|
@@ -81,7 +87,7 @@ export interface MidlineBrowserConfig {
|
|
|
81
87
|
|
|
82
88
|
/** An event as it is sent to the ingest API. */
|
|
83
89
|
export interface BrowserEvent {
|
|
84
|
-
eventType: "error" | "request" | "console" | "performance" | "custom";
|
|
90
|
+
eventType: "error" | "request" | "console" | "performance" | "pageview" | "custom";
|
|
85
91
|
route: string;
|
|
86
92
|
method?: string;
|
|
87
93
|
statusCode?: number;
|
|
@@ -95,6 +101,8 @@ export interface BrowserEvent {
|
|
|
95
101
|
userAgent?: string;
|
|
96
102
|
traceId?: string;
|
|
97
103
|
spanId?: string;
|
|
104
|
+
/** Stable for the tab's lifetime (sessionStorage-backed). Same value on every event this SDK sends. */
|
|
105
|
+
sessionId?: string;
|
|
98
106
|
payload?: Record<string, unknown>;
|
|
99
107
|
metadata: Record<string, unknown>;
|
|
100
108
|
}
|
package/src/browser/version.ts
CHANGED
|
@@ -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.6.0";
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { MidlineAgent } from "./agent";
|
|
3
3
|
import { ConfigError } from "./config";
|
|
4
4
|
import { proxyAddress, resolveTarget, startMidlineProxy } from "./proxy";
|
|
5
|
+
import { uploadSourceMaps } from "./sourcemaps";
|
|
5
6
|
|
|
6
7
|
const USAGE = `Usage: midline-agent proxy [--target <url>] [--port <n>] [--host <addr>]
|
|
7
8
|
|
|
@@ -16,6 +17,18 @@ Midline server:
|
|
|
16
17
|
MIDLINE_ENDPOINT default https://api.usemidline.com
|
|
17
18
|
MIDLINE_CUSTOM_CA extra CA for the Midline server only (PEM or file path)
|
|
18
19
|
TARGET_API_CA extra CA for the destination only (PEM or file path)
|
|
20
|
+
|
|
21
|
+
Usage: midline-agent sourcemaps upload <dir> --release <name> [--url-prefix <prefix>] [--dry-run]
|
|
22
|
+
|
|
23
|
+
Uploads every .map file under <dir> so stack traces from that release show your
|
|
24
|
+
original code. Run it in CI after your build, with the same release name your SDK
|
|
25
|
+
reports.
|
|
26
|
+
|
|
27
|
+
--release Release the build belongs to (required)
|
|
28
|
+
--url-prefix What the built files are served under (default "~", any origin)
|
|
29
|
+
--endpoint Midline server (MIDLINE_ENDPOINT)
|
|
30
|
+
--api-key A project SERVER key, pk_ keys are refused (MIDLINE_API_KEY)
|
|
31
|
+
--dry-run List what would be uploaded
|
|
19
32
|
`;
|
|
20
33
|
|
|
21
34
|
function flag(args: string[], name: string): string | undefined {
|
|
@@ -25,8 +38,29 @@ function flag(args: string[], name: string): string | undefined {
|
|
|
25
38
|
return inline?.slice(name.length + 3);
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
async function sourcemaps(args: string[]): Promise<void> {
|
|
42
|
+
const dir = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
|
|
43
|
+
if (args[1] !== "upload" || !dir || args.includes("--help") || args.includes("-h")) {
|
|
44
|
+
process.stdout.write(USAGE);
|
|
45
|
+
process.exitCode = args.includes("--help") || args.includes("-h") ? 0 : 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const result = await uploadSourceMaps({
|
|
49
|
+
dir,
|
|
50
|
+
release: flag(args, "release") ?? "",
|
|
51
|
+
urlPrefix: flag(args, "url-prefix"),
|
|
52
|
+
endpoint: flag(args, "endpoint"),
|
|
53
|
+
apiKey: flag(args, "api-key"),
|
|
54
|
+
dryRun: args.includes("--dry-run"),
|
|
55
|
+
log: (line) => process.stdout.write(`midline sourcemaps: ${line}\n`),
|
|
56
|
+
});
|
|
57
|
+
process.stdout.write(`midline sourcemaps: ${result.uploaded.length} ${args.includes("--dry-run") ? "listed" : "uploaded"}, ${result.failed.length} failed\n`);
|
|
58
|
+
if (result.failed.length) process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
async function main(): Promise<void> {
|
|
29
62
|
const args = process.argv.slice(2);
|
|
63
|
+
if (args[0] === "sourcemaps") return sourcemaps(args);
|
|
30
64
|
if (args[0] !== "proxy" || args.includes("--help") || args.includes("-h")) {
|
|
31
65
|
process.stdout.write(USAGE);
|
|
32
66
|
process.exitCode = args[0] === "proxy" || args.length === 0 ? 0 : 1;
|
|
@@ -64,6 +98,6 @@ async function main(): Promise<void> {
|
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
main().catch((err) => {
|
|
67
|
-
process.stderr.write(`midline proxy: ${err instanceof ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
101
|
+
process.stderr.write(`midline ${process.argv[2] === "sourcemaps" ? "sourcemaps" : "proxy"}: ${err instanceof ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
68
102
|
process.exit(1);
|
|
69
103
|
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { DEFAULT_ENDPOINT, ConfigError, env, resolveEndpointOrigin } from "./config";
|
|
4
|
+
|
|
5
|
+
export interface UploadOptions {
|
|
6
|
+
/** Directory containing the build output (searched recursively for `*.map`). */
|
|
7
|
+
dir: string;
|
|
8
|
+
/** The release the build belongs to: the same string passed as `release` to the SDK. */
|
|
9
|
+
release: string;
|
|
10
|
+
/** What the built files are served under. Default `~`, meaning "any origin, this path". */
|
|
11
|
+
urlPrefix?: string;
|
|
12
|
+
/** Midline server. Env fallback: `MIDLINE_ENDPOINT`. */
|
|
13
|
+
endpoint?: string;
|
|
14
|
+
/** A project's **server** API key. Env fallback: `MIDLINE_API_KEY`. Browser keys are refused by the server. */
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
log?: (line: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UploadResult {
|
|
21
|
+
uploaded: string[];
|
|
22
|
+
failed: Array<{ name: string; error: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_DEPTH = 20;
|
|
26
|
+
const CONCURRENCY = 4;
|
|
27
|
+
|
|
28
|
+
/** Every `*.map` under `dir`, without descending into node_modules. */
|
|
29
|
+
export async function findMaps(dir: string, depth = 0): Promise<string[]> {
|
|
30
|
+
if (depth > MAX_DEPTH) return [];
|
|
31
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
32
|
+
const found: string[] = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (entry.name !== "node_modules") found.push(...(await findMaps(full, depth + 1)));
|
|
37
|
+
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
|
38
|
+
found.push(full);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return found.sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `dist/assets/app-3f2a.js.map` -> `~/assets/app-3f2a.js`: the built file the map describes, as the server will match it. */
|
|
45
|
+
export function uploadName(dir: string, mapFile: string, urlPrefix = "~"): string {
|
|
46
|
+
const relative = path.relative(dir, mapFile).split(path.sep).join("/").replace(/\.map$/, "");
|
|
47
|
+
return `${urlPrefix.replace(/\/+$/, "")}/${relative}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function uploadSourceMaps(options: UploadOptions): Promise<UploadResult> {
|
|
51
|
+
const log = options.log ?? (() => undefined);
|
|
52
|
+
if (!options.release?.trim()) throw new ConfigError("--release is required");
|
|
53
|
+
const apiKey = options.apiKey ?? env("MIDLINE_API_KEY");
|
|
54
|
+
if (!apiKey && !options.dryRun) throw new ConfigError("Set MIDLINE_API_KEY (a project server key) or pass --api-key");
|
|
55
|
+
const origin = resolveEndpointOrigin(options.endpoint ?? env("MIDLINE_ENDPOINT") ?? DEFAULT_ENDPOINT);
|
|
56
|
+
|
|
57
|
+
const maps = await findMaps(options.dir).catch((error: NodeJS.ErrnoException) => {
|
|
58
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new ConfigError(`${options.dir} is not a directory`);
|
|
59
|
+
throw error;
|
|
60
|
+
});
|
|
61
|
+
if (!maps.length) throw new ConfigError(`No .map files found under ${options.dir}. Enable source maps in your build.`);
|
|
62
|
+
|
|
63
|
+
const result: UploadResult = { uploaded: [], failed: [] };
|
|
64
|
+
const queue = [...maps];
|
|
65
|
+
|
|
66
|
+
const worker = async () => {
|
|
67
|
+
for (let file = queue.shift(); file; file = queue.shift()) {
|
|
68
|
+
const name = uploadName(options.dir, file, options.urlPrefix);
|
|
69
|
+
if (options.dryRun) {
|
|
70
|
+
log(`would upload ${name}`);
|
|
71
|
+
result.uploaded.push(name);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const body = new FormData();
|
|
76
|
+
body.append("release", options.release);
|
|
77
|
+
body.append("name", name);
|
|
78
|
+
body.append("file", new Blob([await readFile(file)]), path.basename(file));
|
|
79
|
+
const response = await fetch(new URL("/api/sourcemaps/upload", origin), {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "x-midline-key": apiKey as string },
|
|
82
|
+
body,
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const detail = await response.json().catch(() => undefined);
|
|
86
|
+
throw new Error(`${response.status} ${detail?.message ?? response.statusText}`);
|
|
87
|
+
}
|
|
88
|
+
log(`uploaded ${name}`);
|
|
89
|
+
result.uploaded.push(name);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
log(`failed ${name}: ${message}`);
|
|
93
|
+
result.failed.push({ name, error: message });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, maps.length) }, worker));
|
|
98
|
+
return result;
|
|
99
|
+
}
|
package/test/browser.test.js
CHANGED
|
@@ -75,7 +75,7 @@ function uncaught(win, error, extra = {}) {
|
|
|
75
75
|
|
|
76
76
|
async function withSdk(config, run, windowOptions) {
|
|
77
77
|
const env = fakeWindow(windowOptions);
|
|
78
|
-
Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false, ...config });
|
|
78
|
+
Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false, capturePageviews: false, ...config });
|
|
79
79
|
try {
|
|
80
80
|
await run(env);
|
|
81
81
|
} finally {
|
|
@@ -298,7 +298,7 @@ test("browser: navigation starts a new trace; close restores everything it wrapp
|
|
|
298
298
|
const originalPush = win.history.pushState;
|
|
299
299
|
const originalError = win.console.error;
|
|
300
300
|
|
|
301
|
-
Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false, captureConsole: true });
|
|
301
|
+
Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false, capturePageviews: false, captureConsole: true });
|
|
302
302
|
assert.notEqual(win.fetch, originalFetch);
|
|
303
303
|
|
|
304
304
|
Midline.captureMessage("before");
|
|
@@ -316,6 +316,52 @@ test("browser: navigation starts a new trace; close restores everything it wrapp
|
|
|
316
316
|
delete globalThis.window;
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
test("browser: pageviews fire on load and on SPA navigation, sharing one sessionId", async () => {
|
|
320
|
+
const env = fakeWindow();
|
|
321
|
+
const { win } = env;
|
|
322
|
+
win.document.referrer = "https://google.com/search";
|
|
323
|
+
|
|
324
|
+
Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false });
|
|
325
|
+
win.history.pushState({}, "", "/pricing");
|
|
326
|
+
win.history.replaceState({}, "", "/pricing/annual");
|
|
327
|
+
await Midline.flush();
|
|
328
|
+
|
|
329
|
+
const [initial, pushed, replaced] = env.sent();
|
|
330
|
+
assert.equal(env.sent().length, 3);
|
|
331
|
+
assert.equal(initial.eventType, "pageview");
|
|
332
|
+
assert.equal(initial.route, "/checkout");
|
|
333
|
+
assert.equal(initial.payload.referrer, "https://google.com/search");
|
|
334
|
+
assert.equal(pushed.eventType, "pageview");
|
|
335
|
+
assert.equal(pushed.route, "/pricing");
|
|
336
|
+
assert.equal(pushed.payload, undefined); // only the initial load carries a referrer
|
|
337
|
+
assert.equal(replaced.eventType, "pageview");
|
|
338
|
+
assert.equal(replaced.route, "/pricing/annual");
|
|
339
|
+
|
|
340
|
+
// One session for the whole tab, and every event — not just pageviews — carries it.
|
|
341
|
+
assert.match(initial.sessionId, /^[a-f0-9]{16,64}$/);
|
|
342
|
+
assert.ok([initial, pushed, replaced].every((event) => event.sessionId === initial.sessionId));
|
|
343
|
+
|
|
344
|
+
await Midline.close();
|
|
345
|
+
delete globalThis.window;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("browser: capturePageviews: false suppresses pageviews without disabling other capture", async () => {
|
|
349
|
+
const env = fakeWindow();
|
|
350
|
+
const { win } = env;
|
|
351
|
+
Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false, capturePageviews: false });
|
|
352
|
+
win.history.pushState({}, "", "/pricing");
|
|
353
|
+
Midline.captureMessage("still works");
|
|
354
|
+
await Midline.flush();
|
|
355
|
+
|
|
356
|
+
const events = env.sent();
|
|
357
|
+
assert.ok(events.every((event) => event.eventType !== "pageview"));
|
|
358
|
+
assert.equal(events.length, 1);
|
|
359
|
+
assert.equal(events[0].payload.message, "still works");
|
|
360
|
+
|
|
361
|
+
await Midline.close();
|
|
362
|
+
delete globalThis.window;
|
|
363
|
+
});
|
|
364
|
+
|
|
319
365
|
test("browser: pending events go out with keepalive when the page is hidden", async () => {
|
|
320
366
|
await withSdk({}, async ({ win, calls }) => {
|
|
321
367
|
Midline.captureMessage("leaving");
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const http = require("http");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { findMaps, uploadName, uploadSourceMaps } = require("../dist/sourcemaps");
|
|
10
|
+
|
|
11
|
+
function makeBuild() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "midline-maps-"));
|
|
13
|
+
fs.mkdirSync(path.join(dir, "assets"));
|
|
14
|
+
fs.mkdirSync(path.join(dir, "node_modules", "dep"), { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(dir, "assets", "app-1.js.map"), '{"version":3,"mappings":"AAAA"}');
|
|
16
|
+
fs.writeFileSync(path.join(dir, "main.js.map"), '{"version":3,"mappings":"AAAA"}');
|
|
17
|
+
fs.writeFileSync(path.join(dir, "assets", "app-1.js"), "//");
|
|
18
|
+
fs.writeFileSync(path.join(dir, "node_modules", "dep", "x.js.map"), "{}");
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("finds .map files, skipping node_modules, and names them by the built file they describe", async () => {
|
|
23
|
+
const dir = makeBuild();
|
|
24
|
+
const maps = await findMaps(dir);
|
|
25
|
+
assert.deepEqual(maps.map((m) => path.relative(dir, m)), [path.join("assets", "app-1.js.map"), "main.js.map"]);
|
|
26
|
+
assert.equal(uploadName(dir, maps[0]), "~/assets/app-1.js");
|
|
27
|
+
assert.equal(uploadName(dir, maps[1], "https://app.example.com/static/"), "https://app.example.com/static/main.js");
|
|
28
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("uploads each map with the key, release and name, and reports failures", async () => {
|
|
32
|
+
const dir = makeBuild();
|
|
33
|
+
const seen = [];
|
|
34
|
+
const server = http.createServer((req, res) => {
|
|
35
|
+
let body = "";
|
|
36
|
+
req.on("data", (c) => (body += c));
|
|
37
|
+
req.on("end", () => {
|
|
38
|
+
seen.push({ url: req.url, key: req.headers["x-midline-key"], body });
|
|
39
|
+
const failing = body.includes("main.js");
|
|
40
|
+
res.writeHead(failing ? 400 : 201, { "content-type": "application/json" });
|
|
41
|
+
res.end(JSON.stringify(failing ? { message: "That is not a version 3 source map" } : { ok: true }));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
45
|
+
const endpoint = `http://127.0.0.1:${server.address().port}`;
|
|
46
|
+
|
|
47
|
+
const lines = [];
|
|
48
|
+
const result = await uploadSourceMaps({ dir, release: "1.2.3", endpoint, apiKey: "ak_test", log: (l) => lines.push(l) });
|
|
49
|
+
server.close();
|
|
50
|
+
|
|
51
|
+
assert.equal(seen.length, 2);
|
|
52
|
+
assert.ok(seen.every((s) => s.url === "/api/sourcemaps/upload" && s.key === "ak_test"));
|
|
53
|
+
assert.ok(seen.some((s) => s.body.includes("~/assets/app-1.js") && s.body.includes("1.2.3")));
|
|
54
|
+
assert.deepEqual(result.uploaded, ["~/assets/app-1.js"]);
|
|
55
|
+
assert.equal(result.failed.length, 1);
|
|
56
|
+
assert.match(result.failed[0].error, /400 That is not a version 3 source map/);
|
|
57
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("dry run touches nothing, and misconfiguration fails clearly", async () => {
|
|
61
|
+
const dir = makeBuild();
|
|
62
|
+
const dry = await uploadSourceMaps({ dir, release: "1", dryRun: true });
|
|
63
|
+
assert.equal(dry.uploaded.length, 2);
|
|
64
|
+
await assert.rejects(uploadSourceMaps({ dir, release: "" }), /--release is required/);
|
|
65
|
+
await assert.rejects(uploadSourceMaps({ dir, release: "1", apiKey: "k", endpoint: "http://example.com" }), /https/);
|
|
66
|
+
await assert.rejects(uploadSourceMaps({ dir: os.tmpdir() + "/does-not-exist-midline", release: "1", apiKey: "k", endpoint: "https://api.example.com" }));
|
|
67
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "midline-empty-"));
|
|
68
|
+
await assert.rejects(uploadSourceMaps({ dir: empty, release: "1", apiKey: "k", endpoint: "https://api.example.com" }), /No \.map files/);
|
|
69
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
fs.rmSync(empty, { recursive: true, force: true });
|
|
71
|
+
});
|