@fixback/sdk-core 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/LICENSE +21 -0
- package/README.md +19 -0
- package/dist/annotation.js +18 -0
- package/dist/annotation.js.map +1 -0
- package/dist/backoff.js +79 -0
- package/dist/backoff.js.map +1 -0
- package/dist/breadcrumb.js +15 -0
- package/dist/breadcrumb.js.map +1 -0
- package/dist/fingerprint.js +108 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +3 -0
- package/dist/scrub.js +216 -0
- package/dist/scrub.js.map +1 -0
- package/dist/wire.js +16 -0
- package/dist/wire.js.map +1 -0
- package/package.json +50 -0
- package/src/annotation.ts +104 -0
- package/src/backoff.test.ts +94 -0
- package/src/backoff.ts +83 -0
- package/src/breadcrumb.ts +169 -0
- package/src/fingerprint.test.ts +96 -0
- package/src/fingerprint.ts +112 -0
- package/src/index.ts +63 -0
- package/src/scrub.test.ts +215 -0
- package/src/scrub.ts +226 -0
- package/src/wire.ts +116 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Annotation & vector-marks **wire types** (spec 0003 §B/§D) — the shared,
|
|
3
|
+
* runtime-agnostic shapes that ride on a report's content (ADR-0028).
|
|
4
|
+
*
|
|
5
|
+
* An Annotation is everything a Reporter marked on the page, as three
|
|
6
|
+
* **composable, optional** layers over one full masked screenshot: the picked
|
|
7
|
+
* `element` (from the element-picker), a drag-captured `region`, and vector
|
|
8
|
+
* `marks` — arrow / box / pen / text. Marks live in the **screenshot's coordinate
|
|
9
|
+
* space**, never baked into the PNG: the dashboard composites them at view time.
|
|
10
|
+
*
|
|
11
|
+
* This module carries only the pure value types; the SDK's geometry and assembly
|
|
12
|
+
* helpers (`normalizeRect`, `arrowHeadPoints`, `assembleAnnotation`) live in the
|
|
13
|
+
* consuming SDK. Keep these in lock-step with the server's ingest contract
|
|
14
|
+
* (spec §D/§H).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A point in screenshot (viewport) coordinate space. */
|
|
18
|
+
export interface Point {
|
|
19
|
+
readonly x: number;
|
|
20
|
+
readonly y: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A rectangle in screenshot coordinates — the same shape as an element's rect. */
|
|
24
|
+
export interface Rect {
|
|
25
|
+
readonly x: number;
|
|
26
|
+
readonly y: number;
|
|
27
|
+
readonly width: number;
|
|
28
|
+
readonly height: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The picked element's viewport rectangle, as the server's annotation `rect`. */
|
|
32
|
+
export interface ElementRect {
|
|
33
|
+
readonly x: number;
|
|
34
|
+
readonly y: number;
|
|
35
|
+
readonly width: number;
|
|
36
|
+
readonly height: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The picked element — the `element` layer of an {@link Annotation}: a stable CSS
|
|
41
|
+
* selector, a readable DOM path, the tag, and the bounding rect. Exactly the
|
|
42
|
+
* server's annotation `element` object shape.
|
|
43
|
+
*/
|
|
44
|
+
export interface SelectedElement {
|
|
45
|
+
readonly selector: string;
|
|
46
|
+
readonly domPath: string;
|
|
47
|
+
readonly tag: string;
|
|
48
|
+
readonly rect: ElementRect;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The draw tools the overlay offers, in toolbar order (spec §B, prototype). */
|
|
52
|
+
export type DrawTool = "arrow" | "box" | "pen" | "text";
|
|
53
|
+
|
|
54
|
+
/** Fields shared by every vector mark. */
|
|
55
|
+
interface MarkBase {
|
|
56
|
+
/** Stroke/fill colour, as a CSS colour string. */
|
|
57
|
+
readonly color: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A directional arrow from `(x0,y0)` to its tip at `(x1,y1)`. */
|
|
61
|
+
export interface ArrowMark extends MarkBase {
|
|
62
|
+
readonly type: "arrow";
|
|
63
|
+
readonly x0: number;
|
|
64
|
+
readonly y0: number;
|
|
65
|
+
readonly x1: number;
|
|
66
|
+
readonly y1: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A rectangle spanning the drag from `(x0,y0)` to `(x1,y1)`. */
|
|
70
|
+
export interface BoxMark extends MarkBase {
|
|
71
|
+
readonly type: "box";
|
|
72
|
+
readonly x0: number;
|
|
73
|
+
readonly y0: number;
|
|
74
|
+
readonly x1: number;
|
|
75
|
+
readonly y1: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A freehand polyline through `points` (in order). */
|
|
79
|
+
export interface PenMark extends MarkBase {
|
|
80
|
+
readonly type: "pen";
|
|
81
|
+
readonly points: ReadonlyArray<Point>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A text label anchored at `(x,y)` (its baseline-left, as SVG text). */
|
|
85
|
+
export interface TextMark extends MarkBase {
|
|
86
|
+
readonly type: "text";
|
|
87
|
+
readonly x: number;
|
|
88
|
+
readonly y: number;
|
|
89
|
+
readonly text: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A single vector mark, in screenshot coordinates. */
|
|
93
|
+
export type Mark = ArrowMark | BoxMark | PenMark | TextMark;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The structured Annotation carried on a report's content (spec §D): the three
|
|
97
|
+
* optional layers. Every field is optional — a report may carry any subset or
|
|
98
|
+
* none (a bare comment is a valid Send).
|
|
99
|
+
*/
|
|
100
|
+
export interface Annotation {
|
|
101
|
+
readonly element?: SelectedElement;
|
|
102
|
+
readonly region?: Rect;
|
|
103
|
+
readonly marks?: ReadonlyArray<Mark>;
|
|
104
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AutoReportBackoff,
|
|
5
|
+
DEFAULT_RETRY_AFTER_SECONDS,
|
|
6
|
+
parseRetryAfter,
|
|
7
|
+
} from "./backoff";
|
|
8
|
+
|
|
9
|
+
describe("parseRetryAfter", () => {
|
|
10
|
+
const NOW = 1_000_000;
|
|
11
|
+
|
|
12
|
+
it("reads the delta-seconds form", () => {
|
|
13
|
+
expect(parseRetryAfter("30", NOW)).toBe(30);
|
|
14
|
+
expect(parseRetryAfter(" 0 ", NOW)).toBe(0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("reads the HTTP-date form as whole seconds from now (rounded up)", () => {
|
|
18
|
+
// toUTCString carries whole seconds only, so the header parses to NOW + 45_000.
|
|
19
|
+
const when = new Date(NOW + 45_000).toUTCString();
|
|
20
|
+
expect(parseRetryAfter(when, NOW)).toBe(45);
|
|
21
|
+
// A sub-second remainder rounds up: from NOW - 500 the gap is 45_500 ms.
|
|
22
|
+
expect(parseRetryAfter(when, NOW - 500)).toBe(46);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("clamps a past HTTP-date to zero", () => {
|
|
26
|
+
const when = new Date(NOW - 10_000).toUTCString();
|
|
27
|
+
expect(parseRetryAfter(when, NOW)).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("falls back to the default when the header is absent, blank, or unparseable", () => {
|
|
31
|
+
expect(parseRetryAfter(null, NOW)).toBe(DEFAULT_RETRY_AFTER_SECONDS);
|
|
32
|
+
expect(parseRetryAfter(undefined, NOW)).toBe(DEFAULT_RETRY_AFTER_SECONDS);
|
|
33
|
+
expect(parseRetryAfter("", NOW)).toBe(DEFAULT_RETRY_AFTER_SECONDS);
|
|
34
|
+
expect(parseRetryAfter(" ", NOW)).toBe(DEFAULT_RETRY_AFTER_SECONDS);
|
|
35
|
+
expect(parseRetryAfter("soon", NOW)).toBe(DEFAULT_RETRY_AFTER_SECONDS);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("defaults to 60 seconds", () => {
|
|
39
|
+
expect(DEFAULT_RETRY_AFTER_SECONDS).toBe(60);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("AutoReportBackoff", () => {
|
|
44
|
+
function at(t: number): { clock: { t: number }; backoff: AutoReportBackoff } {
|
|
45
|
+
const clock = { t };
|
|
46
|
+
return { clock, backoff: new AutoReportBackoff(() => clock.t) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it("starts clear", () => {
|
|
50
|
+
const { backoff } = at(1_000);
|
|
51
|
+
expect(backoff.isPaused()).toBe(false);
|
|
52
|
+
expect(backoff.retryAfterSeconds()).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("opens a window from a Retry-After value and clears when it elapses", () => {
|
|
56
|
+
const { clock, backoff } = at(1_000);
|
|
57
|
+
|
|
58
|
+
expect(backoff.hold("30")).toBe(30);
|
|
59
|
+
expect(backoff.isPaused()).toBe(true);
|
|
60
|
+
expect(backoff.retryAfterSeconds()).toBe(30);
|
|
61
|
+
|
|
62
|
+
clock.t = 1_000 + 29_000;
|
|
63
|
+
expect(backoff.isPaused()).toBe(true);
|
|
64
|
+
|
|
65
|
+
clock.t = 1_000 + 30_000;
|
|
66
|
+
expect(backoff.isPaused()).toBe(false);
|
|
67
|
+
expect(backoff.retryAfterSeconds()).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("defaults to 60 seconds when the header is absent", () => {
|
|
71
|
+
const { clock, backoff } = at(1_000);
|
|
72
|
+
|
|
73
|
+
expect(backoff.hold(null)).toBe(60);
|
|
74
|
+
clock.t = 1_000 + 59_000;
|
|
75
|
+
expect(backoff.isPaused()).toBe(true);
|
|
76
|
+
clock.t = 1_000 + 60_000;
|
|
77
|
+
expect(backoff.isPaused()).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("extends the window but never shrinks it", () => {
|
|
81
|
+
const { clock, backoff } = at(1_000);
|
|
82
|
+
|
|
83
|
+
backoff.hold("60"); // until 61_000
|
|
84
|
+
clock.t = 1_000 + 10_000; // t = 11_000
|
|
85
|
+
backoff.hold("5"); // would end at 16_000 — earlier than 61_000, so ignored
|
|
86
|
+
clock.t = 1_000 + 20_000; // t = 21_000, still inside the original window
|
|
87
|
+
expect(backoff.isPaused()).toBe(true);
|
|
88
|
+
|
|
89
|
+
clock.t = 1_000 + 10_000; // back inside; extend past the original
|
|
90
|
+
backoff.hold("120"); // until 71_000
|
|
91
|
+
clock.t = 1_000 + 65_000; // t = 66_000 — past the original 61_000
|
|
92
|
+
expect(backoff.isPaused()).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/backoff.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side backpressure for **automatic** error reports (spec 0003 §E/§H,
|
|
3
|
+
* ticket #89) — the pure, runtime-agnostic backoff every SDK shares (ADR-0028).
|
|
4
|
+
* When ingest sheds `source: error` load it answers `429` with a `Retry-After`;
|
|
5
|
+
* the SDK honours it by holding a pause window during which further automatic
|
|
6
|
+
* reports are dropped without touching the network. **Manual** reports — a human
|
|
7
|
+
* clicking Send — never consult this gate.
|
|
8
|
+
*
|
|
9
|
+
* This is the transport's counterpart to the server's per-Project token bucket
|
|
10
|
+
* (`apps/api/src/ingest/auto-report-rate-limiter.ts`): one shared window per page,
|
|
11
|
+
* so a 429 from one auto-report shed applies to the auto-reports that follow it.
|
|
12
|
+
* The clock is injectable so the window is unit-tested deterministically, never on
|
|
13
|
+
* wall time — mirroring the server limiter's `Clock`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A source of the current time in epoch milliseconds — injectable for tests. */
|
|
17
|
+
export type Clock = () => number;
|
|
18
|
+
|
|
19
|
+
/** The hold window applied when a `429` carries no usable `Retry-After` (spec §E). */
|
|
20
|
+
export const DEFAULT_RETRY_AFTER_SECONDS = 60;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a `Retry-After` header into whole seconds to hold for. Handles both HTTP
|
|
24
|
+
* forms — a delta-seconds integer and an HTTP-date (measured from `now`, rounded up
|
|
25
|
+
* and clamped at zero) — and falls back to {@link DEFAULT_RETRY_AFTER_SECONDS} when
|
|
26
|
+
* the header is absent, blank, or unparseable. Ingest sends the delta-seconds form;
|
|
27
|
+
* the date form is handled for spec-completeness.
|
|
28
|
+
*/
|
|
29
|
+
export function parseRetryAfter(
|
|
30
|
+
header: string | null | undefined,
|
|
31
|
+
now: number,
|
|
32
|
+
): number {
|
|
33
|
+
if (header == null) return DEFAULT_RETRY_AFTER_SECONDS;
|
|
34
|
+
const value = header.trim();
|
|
35
|
+
if (value === "") return DEFAULT_RETRY_AFTER_SECONDS;
|
|
36
|
+
|
|
37
|
+
if (/^\d+$/.test(value)) {
|
|
38
|
+
return Number(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const when = Date.parse(value);
|
|
42
|
+
if (!Number.isNaN(when)) {
|
|
43
|
+
return Math.max(0, Math.ceil((when - now) / 1000));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return DEFAULT_RETRY_AFTER_SECONDS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A single pause window for automatic (`source: error`) reports. `hold` opens (or
|
|
51
|
+
* extends) it from a `429`'s `Retry-After`; `isPaused` reports whether it is still
|
|
52
|
+
* open. The default instance in the SDK's transport is shared across a page's
|
|
53
|
+
* reports so the hold persists across successive automatic submissions.
|
|
54
|
+
*/
|
|
55
|
+
export class AutoReportBackoff {
|
|
56
|
+
/** Epoch ms until which automatic reports are held; `0` when clear. */
|
|
57
|
+
private pausedUntil = 0;
|
|
58
|
+
|
|
59
|
+
constructor(private readonly now: Clock = Date.now) {}
|
|
60
|
+
|
|
61
|
+
/** Is the automatic-report pause window currently open? */
|
|
62
|
+
isPaused(): boolean {
|
|
63
|
+
return this.now() < this.pausedUntil;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whole seconds remaining in the pause window (`0` when clear). */
|
|
67
|
+
retryAfterSeconds(): number {
|
|
68
|
+
return Math.max(0, Math.ceil((this.pausedUntil - this.now()) / 1000));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Open (or extend) the window from a `429`'s `Retry-After` value, returning the
|
|
73
|
+
* seconds it will hold for. The window only ever grows — a shorter later hold
|
|
74
|
+
* never clips a longer one already in effect.
|
|
75
|
+
*/
|
|
76
|
+
hold(retryAfterHeader: string | null | undefined): number {
|
|
77
|
+
const now = this.now();
|
|
78
|
+
const seconds = parseRetryAfter(retryAfterHeader, now);
|
|
79
|
+
const until = now + seconds * 1000;
|
|
80
|
+
if (until > this.pausedUntil) this.pausedUntil = until;
|
|
81
|
+
return seconds;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trace **breadcrumb wire types** (spec 0003 §C, spec #122) — the shared,
|
|
3
|
+
* runtime-agnostic shapes for one entry of a captured trace stream (ADR-0028).
|
|
4
|
+
*
|
|
5
|
+
* A breadcrumb records recent activity — console, navigation, network metadata,
|
|
6
|
+
* masked user actions, and the failing error — that rides on a report as
|
|
7
|
+
* Evidence. This module carries only the value types; the SDK's ring buffer and
|
|
8
|
+
* DOM/Node instrumentation that produce them live in the consuming SDK.
|
|
9
|
+
* Everything private is kept out **at the source** by that instrumentation:
|
|
10
|
+
* `ui.input` records that an input changed, never its value; network crumbs carry
|
|
11
|
+
* method + URL + status only, never bodies.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Console-style severity a `console` crumb records. */
|
|
15
|
+
export type BreadcrumbLevel =
|
|
16
|
+
| "log"
|
|
17
|
+
| "info"
|
|
18
|
+
| "warn"
|
|
19
|
+
| "error"
|
|
20
|
+
| "assert"
|
|
21
|
+
| "debug";
|
|
22
|
+
|
|
23
|
+
/** The kind of activity a crumb records. */
|
|
24
|
+
export type BreadcrumbCategory =
|
|
25
|
+
| "console"
|
|
26
|
+
| "navigation"
|
|
27
|
+
| "fetch"
|
|
28
|
+
| "xhr"
|
|
29
|
+
| "beacon"
|
|
30
|
+
| "ui.click"
|
|
31
|
+
| "ui.input"
|
|
32
|
+
| "error";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which browser API issued a captured network request (spec #122 §D, ticket #139).
|
|
36
|
+
* Every network crumb's `category` is one of these too, so a stream router and the
|
|
37
|
+
* read model agree on what is a network entry.
|
|
38
|
+
*/
|
|
39
|
+
export type NetworkApi = "fetch" | "xhr" | "beacon";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A network request's failure classification (spec #122 §D, ticket #139). `ok` and
|
|
43
|
+
* the HTTP status classes come from a settled response; `network-error` / `timeout`
|
|
44
|
+
* / `aborted` from how a request failed; `opaque-cors` from a cross-origin response
|
|
45
|
+
* whose status is unreadable. The Network tab flags every non-`ok` outcome.
|
|
46
|
+
*/
|
|
47
|
+
export type NetworkOutcome =
|
|
48
|
+
| "ok"
|
|
49
|
+
| "http-4xx"
|
|
50
|
+
| "http-5xx"
|
|
51
|
+
| "network-error"
|
|
52
|
+
| "timeout"
|
|
53
|
+
| "aborted"
|
|
54
|
+
| "opaque-cors";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A crumb's structured detail. Deliberately narrow: there is **no** field for a
|
|
58
|
+
* request/response body or an input value, so those can never be recorded.
|
|
59
|
+
*/
|
|
60
|
+
export interface BreadcrumbData {
|
|
61
|
+
readonly url?: string;
|
|
62
|
+
readonly method?: string;
|
|
63
|
+
readonly status?: number;
|
|
64
|
+
/** A masked CSS selector for a `ui.*` target — never its text or value. */
|
|
65
|
+
readonly target?: string;
|
|
66
|
+
readonly from?: string;
|
|
67
|
+
readonly to?: string;
|
|
68
|
+
readonly errorType?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The type tag on a structured console argument (spec #122 §C, decision D7). A
|
|
73
|
+
* console call's arguments are preserved **type-tagged** rather than flattened to a
|
|
74
|
+
* string, so an object/array argument can be inspected in the Console tab rather
|
|
75
|
+
* than read as `[object Object]`: `string`/`number`/`bool`/`null` carry the value
|
|
76
|
+
* directly, `json` a depth-/byte-capped JSON-safe clone, and `error` an Error's
|
|
77
|
+
* `{ name, message, stack }`.
|
|
78
|
+
*/
|
|
79
|
+
export type ConsoleArgType =
|
|
80
|
+
| "string"
|
|
81
|
+
| "number"
|
|
82
|
+
| "bool"
|
|
83
|
+
| "null"
|
|
84
|
+
| "json"
|
|
85
|
+
| "error";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One structured console argument (spec #122 §C): a type tag plus a JSON-safe value.
|
|
89
|
+
* `v` is always serializable — a `json` arg is depth-, breadth-, and string-capped at
|
|
90
|
+
* assembly, and exotic values (bigint, symbol, function, circular refs) are rendered
|
|
91
|
+
* to safe text — so a console crumb can never carry an unserializable or unbounded
|
|
92
|
+
* value onto the wire.
|
|
93
|
+
*/
|
|
94
|
+
export interface ConsoleArg {
|
|
95
|
+
readonly t: ConsoleArgType;
|
|
96
|
+
readonly v: unknown;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A `file:line` source location (spec #122 §C) — where a console call was made. */
|
|
100
|
+
export interface SourceLocation {
|
|
101
|
+
readonly file: string;
|
|
102
|
+
readonly line: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One entry in a trace stream. Alongside its semantic fields every entry carries a
|
|
107
|
+
* stable {@link id} and a high-res monotonic {@link mono} timestamp — both stamped
|
|
108
|
+
* by the buffer on `add` — so entries from the three independent streams order and
|
|
109
|
+
* cross-link exactly (spec #122 §B, decision D9). `timestamp` stays epoch ms.
|
|
110
|
+
*/
|
|
111
|
+
export interface Breadcrumb {
|
|
112
|
+
readonly category: BreadcrumbCategory;
|
|
113
|
+
readonly message?: string;
|
|
114
|
+
readonly level?: BreadcrumbLevel;
|
|
115
|
+
/** Epoch milliseconds when the crumb was recorded. */
|
|
116
|
+
readonly timestamp: number;
|
|
117
|
+
/** A stable id, unique within the buffer — assigned on `add` when not already set. */
|
|
118
|
+
readonly id?: string;
|
|
119
|
+
/** A high-res monotonic timestamp (`performance.now()`) — assigned on `add`. */
|
|
120
|
+
readonly mono?: number;
|
|
121
|
+
readonly data?: BreadcrumbData;
|
|
122
|
+
/**
|
|
123
|
+
* For a `console` crumb (spec #122 §C): the call's arguments preserved as
|
|
124
|
+
* structured, type-tagged values (see {@link ConsoleArg}), so the Console tab can
|
|
125
|
+
* render each argument expandably instead of a flattened string. `message` stays
|
|
126
|
+
* the one-line preview. Absent on non-console crumbs.
|
|
127
|
+
*/
|
|
128
|
+
readonly args?: readonly ConsoleArg[];
|
|
129
|
+
/**
|
|
130
|
+
* For a `console` crumb (spec #122 §C): the `file:line` the call was made from,
|
|
131
|
+
* parsed best-effort from the call stack. Captured only for the levels that keep a
|
|
132
|
+
* source — `warn`/`error`/`assert` — and only when the stack yields a usable
|
|
133
|
+
* location; a chatty app's `log`/`info`/`debug` omit it. Absent on non-console crumbs.
|
|
134
|
+
*/
|
|
135
|
+
readonly source?: SourceLocation;
|
|
136
|
+
/**
|
|
137
|
+
* For an auto-captured `error` crumb only (spec #122 §F): the ids of the entries
|
|
138
|
+
* immediately preceding the throw — a causal pointer into the same trace, so a
|
|
139
|
+
* machine-filed crash names its lead-up. Absent on every other crumb.
|
|
140
|
+
*/
|
|
141
|
+
readonly causedBy?: readonly string[];
|
|
142
|
+
/**
|
|
143
|
+
* For a network crumb (`fetch` / `xhr` / `beacon`) — spec #122 §D, ticket #139.
|
|
144
|
+
* The rich request metadata the Network tab renders, lifted to the top level (the
|
|
145
|
+
* console-enrichment precedent) so the read model shapes each into a
|
|
146
|
+
* `NetworkTraceEntry`. **No field carries a request/response body or an arbitrary
|
|
147
|
+
* header** — `respSize` derives from the `content-length` response header only and
|
|
148
|
+
* `contentType` from `content-type`; nothing else is read. Absent on every other crumb.
|
|
149
|
+
*/
|
|
150
|
+
readonly api?: NetworkApi;
|
|
151
|
+
/** The request method (network crumb) — e.g. `GET`, `POST`. */
|
|
152
|
+
readonly method?: string;
|
|
153
|
+
/** The scrubbed request URL (network crumb) — query dropped, path PII redacted. */
|
|
154
|
+
readonly url?: string;
|
|
155
|
+
/** The final HTTP status (network crumb), when one was known; absent on a network error/beacon. */
|
|
156
|
+
readonly status?: number;
|
|
157
|
+
/** The HTTP status text (network crumb), when the response carried one. */
|
|
158
|
+
readonly statusText?: string;
|
|
159
|
+
/** Wall-clock duration of the request in ms (network crumb) — a `performance.now()` delta. */
|
|
160
|
+
readonly durationMs?: number;
|
|
161
|
+
/** Request body size in bytes (network crumb) — only when trivially known (string/Blob/ArrayBuffer), never by reading a stream. */
|
|
162
|
+
readonly reqSize?: number;
|
|
163
|
+
/** Response body size in bytes (network crumb) — from the `content-length` response header only. */
|
|
164
|
+
readonly respSize?: number;
|
|
165
|
+
/** Response content type (network crumb) — the `content-type` header's media type. */
|
|
166
|
+
readonly contentType?: string;
|
|
167
|
+
/** The request's failure classification (network crumb) — see {@link NetworkOutcome}. */
|
|
168
|
+
readonly outcome?: NetworkOutcome;
|
|
169
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { computeFingerprint, extractTopFrames, hashString, normalize } from "./fingerprint";
|
|
4
|
+
|
|
5
|
+
describe("normalize — collapsing the volatile parts of an error message", () => {
|
|
6
|
+
it("replaces UUIDs, URLs, hex, and long digit runs with stable placeholders", () => {
|
|
7
|
+
expect(normalize("failed for 550e8400-e29b-41d4-a716-446655440000")).toBe(
|
|
8
|
+
"failed for <uuid>",
|
|
9
|
+
);
|
|
10
|
+
expect(
|
|
11
|
+
normalize("GET https://api.example.com/users/42?token=abc failed"),
|
|
12
|
+
).toContain("<url>");
|
|
13
|
+
expect(normalize("bad pointer 0xdeadbeef")).toBe("bad pointer <hex>");
|
|
14
|
+
expect(normalize("chunk 3f9a1c2e4b7d8091 missing")).toBe("chunk <hex> missing");
|
|
15
|
+
expect(normalize("order 1234567 not found")).toBe("order <n> not found");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("keeps short numbers and stable text so distinct bugs stay distinct", () => {
|
|
19
|
+
expect(normalize("code 42 raised")).toBe("code 42 raised");
|
|
20
|
+
expect(normalize("Cannot read properties of undefined")).toBe(
|
|
21
|
+
"Cannot read properties of undefined",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns an empty string for a non-string or empty input", () => {
|
|
26
|
+
expect(normalize("")).toBe("");
|
|
27
|
+
expect(normalize(undefined as unknown as string)).toBe("");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("extractTopFrames — a compact top-of-stack signature", () => {
|
|
32
|
+
it("parses V8 frames to `function@basename:line:col`, origin stripped", () => {
|
|
33
|
+
const stack = [
|
|
34
|
+
"Error: boom",
|
|
35
|
+
" at pay (https://acme.app/assets/checkout.abc123.js:10:5)",
|
|
36
|
+
" at onClick (https://acme.app/assets/checkout.abc123.js:20:9)",
|
|
37
|
+
].join("\n");
|
|
38
|
+
expect(extractTopFrames(stack)).toBe(
|
|
39
|
+
"pay@checkout.abc123.js:10:5 < onClick@checkout.abc123.js:20:9",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("parses Firefox/Safari `fn@loc` frames", () => {
|
|
44
|
+
const stack = "pay@https://acme.app/checkout.js:10:5\n@https://acme.app/x.js:1:1";
|
|
45
|
+
expect(extractTopFrames(stack)).toBe("pay@checkout.js:10:5 < @x.js:1:1");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("caps at the frame limit and returns '' for no usable stack", () => {
|
|
49
|
+
const many = Array.from(
|
|
50
|
+
{ length: 10 },
|
|
51
|
+
(_v, i) => ` at fn${i} (https://acme.app/a.js:${i}:1)`,
|
|
52
|
+
).join("\n");
|
|
53
|
+
expect(extractTopFrames(many).split(" < ")).toHaveLength(5);
|
|
54
|
+
expect(extractTopFrames(undefined)).toBe("");
|
|
55
|
+
expect(extractTopFrames("")).toBe("");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("hashString — dependency-free FNV-1a", () => {
|
|
60
|
+
it("is deterministic and distinguishes distinct inputs", () => {
|
|
61
|
+
expect(hashString("a|b|c")).toBe(hashString("a|b|c"));
|
|
62
|
+
expect(hashString("a|b|c")).not.toBe(hashString("a|b|d"));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("computeFingerprint — the cross-surface grouping key", () => {
|
|
67
|
+
it("is stable for the same logical error", () => {
|
|
68
|
+
const stack = " at pay (https://acme.app/checkout.js:10:5)";
|
|
69
|
+
expect(computeFingerprint("TypeError", "boom", stack)).toBe(
|
|
70
|
+
computeFingerprint("TypeError", "boom", stack),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ignores the volatile parts of the message (same bug, changed ids)", () => {
|
|
75
|
+
const stack = " at pay (https://acme.app/checkout.js:10:5)";
|
|
76
|
+
expect(computeFingerprint("Error", "order 111111 failed", stack)).toBe(
|
|
77
|
+
computeFingerprint("Error", "order 999999 failed", stack),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("separates errors with different types or frames", () => {
|
|
82
|
+
const stack = " at pay (https://acme.app/checkout.js:10:5)";
|
|
83
|
+
expect(computeFingerprint("TypeError", "boom", stack)).not.toBe(
|
|
84
|
+
computeFingerprint("RangeError", "boom", stack),
|
|
85
|
+
);
|
|
86
|
+
expect(computeFingerprint("Error", "boom", stack)).not.toBe(
|
|
87
|
+
computeFingerprint("Error", "boom", " at other (https://acme.app/x.js:1:1)"),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("falls back to type + normalised message when there is no stack", () => {
|
|
92
|
+
expect(computeFingerprint("Error", "order 5 failed")).toBe(
|
|
93
|
+
computeFingerprint("Error", "order 5 failed", undefined),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-surface error **fingerprint** (research `sentry-error-capture-findings.md`
|
|
3
|
+
* §7.2; ADR-0027/0028) — the pure, runtime-agnostic key by which the same logical
|
|
4
|
+
* error groups together, computed the **same way** on every SDK.
|
|
5
|
+
*
|
|
6
|
+
* `computeFingerprint(type, value, stack)` hashes the error type, a normalised
|
|
7
|
+
* message, and a compact top-frames signature. It only has to be stable and
|
|
8
|
+
* well-distributed (the client key is a flood guard; the server does canonical
|
|
9
|
+
* cross-session clustering), so a dependency-free FNV-1a hash is right. The
|
|
10
|
+
* `normalize` shape and the frame limit are **starting points** from research
|
|
11
|
+
* (ticket #92), tunable — never a frozen magic set.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const UUID_RE =
|
|
15
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
16
|
+
const URL_RE = /\bhttps?:\/\/[^\s"')]+/gi;
|
|
17
|
+
const HEX_0X_RE = /\b0x[0-9a-f]+\b/gi;
|
|
18
|
+
const HEX_RUN_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
19
|
+
const DIGIT_RUN_RE = /\d{4,}/g;
|
|
20
|
+
|
|
21
|
+
/** How many top stack frames feed the fingerprint (research §7.2). */
|
|
22
|
+
const FINGERPRINT_FRAME_LIMIT = 5;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collapse the volatile parts of an error message so a changing string doesn't
|
|
26
|
+
* split one bug: UUIDs, URLs, `0x…` and long hex runs, and long digit runs are
|
|
27
|
+
* replaced with stable placeholders. Short numbers and stable text are kept so
|
|
28
|
+
* genuinely distinct bugs stay distinct. A small, dependency-free regex set —
|
|
29
|
+
* tunable per ticket #92, never a frozen magic set.
|
|
30
|
+
*/
|
|
31
|
+
export function normalize(value: string): string {
|
|
32
|
+
if (typeof value !== "string" || value.length === 0) return "";
|
|
33
|
+
return value
|
|
34
|
+
.replace(UUID_RE, "<uuid>")
|
|
35
|
+
.replace(URL_RE, "<url>")
|
|
36
|
+
.replace(HEX_0X_RE, "<hex>")
|
|
37
|
+
.replace(HEX_RUN_RE, "<hex>")
|
|
38
|
+
.replace(DIGIT_RUN_RE, "<n>")
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A dependency-free FNV-1a hash rendered in base-36. It only has to be stable and
|
|
44
|
+
* well-distributed within one session (the client key is a flood guard; the server
|
|
45
|
+
* does canonical cross-session clustering), so a non-cryptographic hash is right.
|
|
46
|
+
*/
|
|
47
|
+
export function hashString(input: string): string {
|
|
48
|
+
let h = 0x811c9dc5;
|
|
49
|
+
for (let i = 0; i < input.length; i++) {
|
|
50
|
+
h ^= input.charCodeAt(i);
|
|
51
|
+
h = Math.imul(h, 0x01000193);
|
|
52
|
+
}
|
|
53
|
+
return (h >>> 0).toString(36);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reduce a frame location to `basename:line:col`, dropping origin and query. */
|
|
57
|
+
function compactLocation(location: string): string {
|
|
58
|
+
const noQuery = location.replace(/\?[^:]*/, "");
|
|
59
|
+
const lastSlash = noQuery.lastIndexOf("/");
|
|
60
|
+
return lastSlash >= 0 ? noQuery.slice(lastSlash + 1) : noQuery;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Parse one stack line into a compact `function@basename:line:col` frame id. */
|
|
64
|
+
function parseFrame(line: string): string | null {
|
|
65
|
+
// V8: "at fn (loc)" | "at loc"
|
|
66
|
+
const v8Named = line.match(/^at\s+(.+?)\s+\((.+)\)$/);
|
|
67
|
+
if (v8Named) return `${v8Named[1] ?? ""}@${compactLocation(v8Named[2] ?? "")}`;
|
|
68
|
+
const v8Bare = line.match(/^at\s+(.+)$/);
|
|
69
|
+
if (v8Bare) return `@${compactLocation(v8Bare[1] ?? "")}`;
|
|
70
|
+
// Firefox / Safari: "fn@loc" | "@loc"
|
|
71
|
+
const at = line.indexOf("@");
|
|
72
|
+
if (at >= 0) {
|
|
73
|
+
const fn = line.slice(0, at);
|
|
74
|
+
return `${fn}@${compactLocation(line.slice(at + 1))}`;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract a compact, stable signature of the top in-app frames of a stack: up to
|
|
81
|
+
* {@link FINGERPRINT_FRAME_LIMIT} frames as `function@basename:line:col`, origin
|
|
82
|
+
* and cache-busting query stripped so a per-deploy asset hash doesn't matter within
|
|
83
|
+
* a session. Returns `""` when there is no usable stack (message-only fallback).
|
|
84
|
+
*/
|
|
85
|
+
export function extractTopFrames(
|
|
86
|
+
stack: string | undefined,
|
|
87
|
+
limit = FINGERPRINT_FRAME_LIMIT,
|
|
88
|
+
): string {
|
|
89
|
+
if (typeof stack !== "string" || stack.length === 0) return "";
|
|
90
|
+
const frames: string[] = [];
|
|
91
|
+
for (const raw of stack.split("\n")) {
|
|
92
|
+
const frame = parseFrame(raw.trim());
|
|
93
|
+
if (frame) {
|
|
94
|
+
frames.push(frame);
|
|
95
|
+
if (frames.length >= limit) break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return frames.join(" < ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The per-session fingerprint (research §7.2):
|
|
103
|
+
* `hash(errorType + "|" + normalize(value) + "|" + topFrames)`. Stack frames
|
|
104
|
+
* dominate when present; otherwise it falls back to type + normalized value.
|
|
105
|
+
*/
|
|
106
|
+
export function computeFingerprint(
|
|
107
|
+
type: string,
|
|
108
|
+
value: string,
|
|
109
|
+
stack?: string,
|
|
110
|
+
): string {
|
|
111
|
+
return hashString(`${type}|${normalize(value)}|${extractTopFrames(stack)}`);
|
|
112
|
+
}
|