@fixback/node 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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/client.d.ts +79 -0
  4. package/dist/client.js +220 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/config.d.ts +71 -0
  7. package/dist/config.js +68 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/context.d.ts +53 -0
  10. package/dist/context.js +97 -0
  11. package/dist/context.js.map +1 -0
  12. package/dist/express.d.ts +42 -0
  13. package/dist/express.js +51 -0
  14. package/dist/express.js.map +1 -0
  15. package/dist/http.d.ts +53 -0
  16. package/dist/http.js +75 -0
  17. package/dist/http.js.map +1 -0
  18. package/dist/index.d.ts +23 -0
  19. package/dist/index.js +38 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/nestjs.d.ts +44 -0
  22. package/dist/nestjs.js +114 -0
  23. package/dist/nestjs.js.map +1 -0
  24. package/dist/package.json +3 -0
  25. package/dist/process.d.ts +52 -0
  26. package/dist/process.js +124 -0
  27. package/dist/process.js.map +1 -0
  28. package/dist/stack.d.ts +33 -0
  29. package/dist/stack.js +142 -0
  30. package/dist/stack.js.map +1 -0
  31. package/dist/transport.d.ts +90 -0
  32. package/dist/transport.js +172 -0
  33. package/dist/transport.js.map +1 -0
  34. package/dist/version.d.ts +13 -0
  35. package/dist/version.js +17 -0
  36. package/dist/version.js.map +1 -0
  37. package/dist/wire.d.ts +108 -0
  38. package/dist/wire.js +15 -0
  39. package/dist/wire.js.map +1 -0
  40. package/package.json +91 -0
  41. package/src/client.test.ts +272 -0
  42. package/src/client.ts +262 -0
  43. package/src/config.test.ts +79 -0
  44. package/src/config.ts +131 -0
  45. package/src/context.test.ts +99 -0
  46. package/src/context.ts +124 -0
  47. package/src/express.test.ts +112 -0
  48. package/src/express.ts +85 -0
  49. package/src/fingerprint-parity.test.ts +86 -0
  50. package/src/http.ts +83 -0
  51. package/src/index.ts +51 -0
  52. package/src/nestjs.test.ts +146 -0
  53. package/src/nestjs.ts +123 -0
  54. package/src/process.test.ts +154 -0
  55. package/src/process.ts +153 -0
  56. package/src/stack.test.ts +89 -0
  57. package/src/stack.ts +153 -0
  58. package/src/transport.test.ts +187 -0
  59. package/src/transport.ts +227 -0
  60. package/src/version.test.ts +10 -0
  61. package/src/version.ts +13 -0
  62. package/src/wire.ts +116 -0
@@ -0,0 +1,187 @@
1
+ import { AutoReportBackoff } from "@fixback/sdk-core";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { Transport } from "./transport";
5
+ import type { FetchLike } from "./transport";
6
+
7
+ interface FakeResponse {
8
+ readonly ok?: boolean;
9
+ readonly status?: number;
10
+ readonly headers?: Record<string, string>;
11
+ readonly throw?: boolean;
12
+ }
13
+
14
+ interface RecordedCall {
15
+ url: string;
16
+ headers: Record<string, string>;
17
+ body: { errors: Array<Record<string, unknown>> };
18
+ }
19
+
20
+ function fakeFetch(responses: FakeResponse[]): { fetchImpl: FetchLike; calls: RecordedCall[] } {
21
+ const calls: RecordedCall[] = [];
22
+ const fetchImpl: FetchLike = async (url, init) => {
23
+ calls.push({
24
+ url,
25
+ headers: init.headers ?? {},
26
+ body: JSON.parse(String(init.body)) as RecordedCall["body"],
27
+ });
28
+ const next = responses.shift() ?? { ok: true, status: 201 };
29
+ if (next.throw) throw new Error("network down");
30
+ return {
31
+ ok: next.ok ?? true,
32
+ status: next.status ?? 201,
33
+ headers: { get: (name: string) => next.headers?.[name.toLowerCase()] ?? null },
34
+ json: async () => ({}),
35
+ };
36
+ };
37
+ return { fetchImpl, calls };
38
+ }
39
+
40
+ const baseConfig = {
41
+ endpoint: "https://api.test/api/errors",
42
+ secretKey: "sk_secret",
43
+ maxBatchSize: 100,
44
+ maxQueueSize: 1000,
45
+ flushIntervalMs: 0,
46
+ timeoutMs: 0,
47
+ };
48
+
49
+ afterEach(() => {
50
+ vi.useRealTimers();
51
+ });
52
+
53
+ describe("Transport", () => {
54
+ it("posts a JSON batch to the errors endpoint with a Bearer secret-key header", async () => {
55
+ const { fetchImpl, calls } = fakeFetch([{ ok: true, status: 201 }]);
56
+ const transport = new Transport(baseConfig, { fetchImpl });
57
+
58
+ transport.enqueue({ errorSignature: "fp-1", handled: true });
59
+ transport.enqueue({ errorSignature: "fp-2", handled: false });
60
+ await transport.flush();
61
+
62
+ expect(calls).toHaveLength(1);
63
+ expect(calls[0]!.url).toBe("https://api.test/api/errors");
64
+ expect(calls[0]!.headers.authorization).toBe("Bearer sk_secret");
65
+ expect(calls[0]!.headers["content-type"]).toContain("application/json");
66
+ expect(calls[0]!.body.errors).toHaveLength(2);
67
+ expect(calls[0]!.body.errors.map((e) => e.errorSignature)).toEqual(["fp-1", "fp-2"]);
68
+ });
69
+
70
+ it("coalesces same-fingerprint errors into one item, summing occurrences", async () => {
71
+ const { fetchImpl, calls } = fakeFetch([{ ok: true, status: 201 }]);
72
+ const transport = new Transport(baseConfig, { fetchImpl });
73
+
74
+ transport.enqueue({ errorSignature: "loop" });
75
+ transport.enqueue({ errorSignature: "loop" });
76
+ transport.enqueue({ errorSignature: "loop", occurrences: 3 });
77
+ await transport.flush();
78
+
79
+ expect(calls[0]!.body.errors).toHaveLength(1);
80
+ expect(calls[0]!.body.errors[0]!.occurrences).toBe(5);
81
+ });
82
+
83
+ it("keeps the first occurrence's evidence when coalescing", async () => {
84
+ const { fetchImpl, calls } = fakeFetch([{ ok: true, status: 201 }]);
85
+ const transport = new Transport(baseConfig, { fetchImpl });
86
+ transport.enqueue({ errorSignature: "loop", handled: false, server: { route: "/a" } });
87
+ transport.enqueue({ errorSignature: "loop", handled: false, server: { route: "/b" } });
88
+ await transport.flush();
89
+ expect(calls[0]!.body.errors[0]!.server).toEqual({ route: "/a" });
90
+ expect(calls[0]!.body.errors[0]!.occurrences).toBe(2);
91
+ });
92
+
93
+ it("flushes nothing when the buffer is empty", async () => {
94
+ const { fetchImpl, calls } = fakeFetch([]);
95
+ const transport = new Transport(baseConfig, { fetchImpl });
96
+ await transport.flush();
97
+ expect(calls).toHaveLength(0);
98
+ });
99
+
100
+ it("opens a backoff window on 429 + Retry-After and sheds captures until it clears", async () => {
101
+ let nowMs = 0;
102
+ const backoff = new AutoReportBackoff(() => nowMs);
103
+ const { fetchImpl, calls } = fakeFetch([
104
+ { ok: false, status: 429, headers: { "retry-after": "30" } },
105
+ { ok: true, status: 201 },
106
+ ]);
107
+ const transport = new Transport(baseConfig, { fetchImpl, backoff });
108
+
109
+ transport.enqueue({ errorSignature: "flood" });
110
+ await transport.flush(); // 429 → window opens, this batch is shed
111
+ expect(calls).toHaveLength(1);
112
+
113
+ // While the window is open, further captures are dropped without a request.
114
+ transport.enqueue({ errorSignature: "flood-2" });
115
+ await transport.flush();
116
+ expect(calls).toHaveLength(1);
117
+
118
+ // Once the window clears, capture resumes.
119
+ nowMs = 31_000;
120
+ transport.enqueue({ errorSignature: "after" });
121
+ await transport.flush();
122
+ expect(calls).toHaveLength(2);
123
+ expect(calls[1]!.body.errors[0]!.errorSignature).toBe("after");
124
+ });
125
+
126
+ it("fails quiet when fetch throws — flush never rejects and never throws to the caller", async () => {
127
+ const { fetchImpl } = fakeFetch([{ throw: true }]);
128
+ const transport = new Transport(baseConfig, { fetchImpl });
129
+ transport.enqueue({ errorSignature: "x" });
130
+ await expect(transport.flush()).resolves.toBeUndefined();
131
+ });
132
+
133
+ it("fails quiet when the endpoint refuses (non-2xx that is not 429)", async () => {
134
+ const { fetchImpl, calls } = fakeFetch([{ ok: false, status: 500 }]);
135
+ const transport = new Transport(baseConfig, { fetchImpl });
136
+ transport.enqueue({ errorSignature: "x" });
137
+ await expect(transport.flush()).resolves.toBeUndefined();
138
+ expect(calls).toHaveLength(1);
139
+ });
140
+
141
+ it("auto-flushes once the buffer reaches maxBatchSize", async () => {
142
+ const { fetchImpl, calls } = fakeFetch([{ ok: true, status: 201 }]);
143
+ const transport = new Transport({ ...baseConfig, maxBatchSize: 2 }, { fetchImpl });
144
+ transport.enqueue({ errorSignature: "a" });
145
+ transport.enqueue({ errorSignature: "b" }); // reaches the cap → auto-flush fires
146
+ await transport.flush(); // await the in-flight drain
147
+ expect(calls).toHaveLength(1);
148
+ expect(calls[0]!.body.errors).toHaveLength(2);
149
+ });
150
+
151
+ it("bounds the buffer at maxQueueSize under a sustained flood", async () => {
152
+ const { fetchImpl, calls } = fakeFetch([{ ok: true, status: 201 }]);
153
+ // flushIntervalMs 0 so nothing auto-sends; maxBatchSize high so the cap, not the
154
+ // batch trigger, is what bounds the buffer.
155
+ const transport = new Transport(
156
+ { ...baseConfig, maxQueueSize: 3, maxBatchSize: 1000 },
157
+ { fetchImpl },
158
+ );
159
+ for (let i = 0; i < 50; i++) transport.enqueue({ errorSignature: `fp-${i}` });
160
+ await transport.flush();
161
+ // At most maxQueueSize distinct items were retained.
162
+ expect(calls[0]!.body.errors.length).toBeLessThanOrEqual(3);
163
+ });
164
+
165
+ it("auto-flushes on the flush interval, on an unref'd timer", async () => {
166
+ vi.useFakeTimers();
167
+ const { fetchImpl, calls } = fakeFetch([{ ok: true, status: 201 }]);
168
+ const transport = new Transport({ ...baseConfig, flushIntervalMs: 5000 }, { fetchImpl });
169
+ transport.enqueue({ errorSignature: "timed" });
170
+ expect(calls).toHaveLength(0);
171
+ await vi.advanceTimersByTimeAsync(5000);
172
+ expect(calls).toHaveLength(1);
173
+ await transport.close();
174
+ });
175
+
176
+ it("close() drains the remaining buffer and stops the timer", async () => {
177
+ vi.useFakeTimers();
178
+ const { fetchImpl, calls } = fakeFetch([{ ok: true, status: 201 }]);
179
+ const transport = new Transport({ ...baseConfig, flushIntervalMs: 5000 }, { fetchImpl });
180
+ transport.enqueue({ errorSignature: "z" });
181
+ await transport.close();
182
+ expect(calls).toHaveLength(1);
183
+ // After close the timer no longer fires.
184
+ await vi.advanceTimersByTimeAsync(20_000);
185
+ expect(calls).toHaveLength(1);
186
+ });
187
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The **batched secret-key transport** (ADR-0026): the SDK buffers captured errors
3
+ * and flushes them to `POST /api/errors` as a JSON batch authenticated with the
4
+ * Project secret key as an `Authorization: Bearer` token.
5
+ *
6
+ * - **Batched:** a server emits many errors, so they buffer and flush together
7
+ * (on the batch cap, on an interval, on demand, and on `close`).
8
+ * - **Coalesced:** repeats of one fingerprint in a flush window fold into a single
9
+ * item with a summed occurrence count — the client half of the crash-loop defence
10
+ * (the server folds across flushes, ADR-0027).
11
+ * - **Backed off:** a `429` opens the shared-core {@link AutoReportBackoff} window
12
+ * (honouring `Retry-After`); while it is open captures are shed without a request.
13
+ * - **Fail-quiet (story 34):** an unreachable, timing-out, or refusing endpoint never
14
+ * throws to the host and never grows the buffer without bound (`maxQueueSize`).
15
+ */
16
+
17
+ import { AutoReportBackoff, type CapturedFrame } from "@fixback/sdk-core";
18
+
19
+ import type { ServerContext, ServerErrorPayload } from "./wire";
20
+
21
+ /** The response slice the transport reads. Satisfied by the global `fetch` Response. */
22
+ export interface FetchResponseLike {
23
+ readonly ok: boolean;
24
+ readonly status: number;
25
+ readonly headers?: { get(name: string): string | null } | null;
26
+ }
27
+
28
+ /** The request init the transport sends — a subset of the standard `RequestInit`. */
29
+ export interface FetchRequestInit {
30
+ readonly method: string;
31
+ readonly headers?: Record<string, string>;
32
+ readonly body?: string;
33
+ }
34
+
35
+ /** A `fetch`-shaped function, injectable for tests. */
36
+ export type FetchLike = (url: string, init: FetchRequestInit) => Promise<FetchResponseLike>;
37
+
38
+ /**
39
+ * The narrow transport surface the client depends on — buffer one error, flush, and
40
+ * close. {@link Transport} is the real implementation; tests inject a fake recorder.
41
+ */
42
+ export interface CaptureTransport {
43
+ enqueue(payload: ServerErrorPayload): void;
44
+ flush(): Promise<void>;
45
+ close(): Promise<void>;
46
+ }
47
+
48
+ /** The transport's configuration (a slice of the resolved SDK config). */
49
+ export interface TransportConfig {
50
+ readonly endpoint: string;
51
+ readonly secretKey: string;
52
+ readonly maxBatchSize: number;
53
+ readonly maxQueueSize: number;
54
+ readonly flushIntervalMs: number;
55
+ readonly timeoutMs: number;
56
+ }
57
+
58
+ /** Injectable collaborators, defaulted to the real implementations. */
59
+ export interface TransportDeps {
60
+ readonly fetchImpl?: FetchLike;
61
+ readonly backoff?: AutoReportBackoff;
62
+ }
63
+
64
+ /** A buffered error with a mutable occurrence tally (bumped on coalesce). */
65
+ interface BufferedError {
66
+ errorSignature: string;
67
+ handled?: boolean;
68
+ environment?: string;
69
+ release?: string;
70
+ occurrences?: number;
71
+ errorFrames?: readonly CapturedFrame[];
72
+ server?: ServerContext;
73
+ }
74
+
75
+ /** The global `fetch`, adapted to {@link FetchLike}, or `undefined` when unavailable. */
76
+ function resolveFetch(): FetchLike | undefined {
77
+ const g = globalThis as unknown as {
78
+ fetch?: (input: string, init?: unknown) => Promise<unknown>;
79
+ };
80
+ if (typeof g.fetch !== "function") return undefined;
81
+ const bound = g.fetch.bind(globalThis);
82
+ return (url, init) => bound(url, init) as Promise<FetchResponseLike>;
83
+ }
84
+
85
+ /** Read a response header defensively — `null` when unavailable or on any throw. */
86
+ function safeHeader(response: FetchResponseLike, name: string): string | null {
87
+ try {
88
+ return response.headers?.get?.(name) ?? null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export class Transport implements CaptureTransport {
95
+ private readonly config: TransportConfig;
96
+ private readonly fetchImpl: FetchLike | undefined;
97
+ private readonly backoff: AutoReportBackoff;
98
+ /** Insertion-ordered buffer keyed by fingerprint, so a coalesce is O(1). */
99
+ private readonly buffer = new Map<string, BufferedError>();
100
+ private timer: ReturnType<typeof setInterval> | undefined;
101
+ private inFlight: Promise<void> | null = null;
102
+ private closed = false;
103
+
104
+ constructor(config: TransportConfig, deps: TransportDeps = {}) {
105
+ this.config = config;
106
+ this.fetchImpl = deps.fetchImpl ?? resolveFetch();
107
+ this.backoff = deps.backoff ?? new AutoReportBackoff();
108
+ if (config.flushIntervalMs > 0) {
109
+ this.timer = setInterval(() => {
110
+ void this.flush();
111
+ }, config.flushIntervalMs);
112
+ // Never keep the process alive for the flush timer.
113
+ const handle = this.timer as { unref?: () => void };
114
+ if (typeof handle.unref === "function") handle.unref();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Buffer one captured error for the next flush. Coalesces onto an existing
120
+ * fingerprint (summing occurrences, keeping the first occurrence's evidence);
121
+ * drops the capture while the backoff window is open or the buffer is full; and
122
+ * triggers an immediate flush once the batch cap is reached.
123
+ */
124
+ enqueue(payload: ServerErrorPayload): void {
125
+ const signature = payload.errorSignature;
126
+ if (this.closed || typeof signature !== "string" || signature.length === 0) return;
127
+ // Shed while the 429 window is open — the client half of the flood defence.
128
+ if (this.backoff.isPaused()) return;
129
+
130
+ const existing = this.buffer.get(signature);
131
+ if (existing) {
132
+ existing.occurrences = (existing.occurrences ?? 1) + (payload.occurrences ?? 1);
133
+ } else {
134
+ if (this.buffer.size >= this.config.maxQueueSize) return; // bounded — drop the newest
135
+ this.buffer.set(signature, { ...payload });
136
+ }
137
+
138
+ if (this.buffer.size >= this.config.maxBatchSize) void this.flush();
139
+ }
140
+
141
+ /** Flush the buffer to ingest. Never rejects (fail-quiet). Coalesces concurrent calls. */
142
+ flush(): Promise<void> {
143
+ if (this.inFlight) return this.inFlight;
144
+ this.inFlight = this.drain().finally(() => {
145
+ this.inFlight = null;
146
+ });
147
+ return this.inFlight;
148
+ }
149
+
150
+ /** Stop the flush timer and drain what remains. Safe to call more than once. */
151
+ async close(): Promise<void> {
152
+ this.closed = true;
153
+ if (this.timer !== undefined) {
154
+ clearInterval(this.timer);
155
+ this.timer = undefined;
156
+ }
157
+ await this.flush();
158
+ }
159
+
160
+ /** Send batches until the buffer drains, the window opens, or a send fails. */
161
+ private async drain(): Promise<void> {
162
+ while (this.buffer.size > 0) {
163
+ if (this.backoff.isPaused()) return;
164
+ const ok = await this.send(this.takeBatch());
165
+ if (!ok) return; // 429 (held) or unreachable/refused — stop; the rest waits or is dropped
166
+ }
167
+ }
168
+
169
+ /** Remove and return up to `maxBatchSize` buffered errors, oldest first. */
170
+ private takeBatch(): BufferedError[] {
171
+ const batch: BufferedError[] = [];
172
+ for (const [signature, entry] of this.buffer) {
173
+ batch.push(entry);
174
+ this.buffer.delete(signature);
175
+ if (batch.length >= this.config.maxBatchSize) break;
176
+ }
177
+ return batch;
178
+ }
179
+
180
+ /**
181
+ * POST one batch. Returns `true` on acceptance; on a `429` it opens the backoff
182
+ * window from `Retry-After` and returns `false`; any other failure (unreachable,
183
+ * timeout, refusal) returns `false` without throwing.
184
+ */
185
+ private async send(batch: readonly ServerErrorPayload[]): Promise<boolean> {
186
+ const doFetch = this.fetchImpl;
187
+ if (!doFetch || batch.length === 0) return false;
188
+
189
+ let response: FetchResponseLike;
190
+ try {
191
+ response = await this.withTimeout(
192
+ doFetch(this.config.endpoint, {
193
+ method: "POST",
194
+ headers: {
195
+ authorization: `Bearer ${this.config.secretKey}`,
196
+ "content-type": "application/json",
197
+ },
198
+ body: JSON.stringify({ errors: batch }),
199
+ }),
200
+ );
201
+ } catch {
202
+ return false; // unreachable or timed out — fail quiet
203
+ }
204
+
205
+ if (!response.ok) {
206
+ if (response.status === 429) {
207
+ this.backoff.hold(safeHeader(response, "retry-after"));
208
+ return false;
209
+ }
210
+ return false; // refused — fail quiet
211
+ }
212
+ return true;
213
+ }
214
+
215
+ /** Race a request against the configured timeout (`0` disables it). */
216
+ private withTimeout(request: Promise<FetchResponseLike>): Promise<FetchResponseLike> {
217
+ const ms = this.config.timeoutMs;
218
+ if (ms <= 0) return request;
219
+ let timer: ReturnType<typeof setTimeout> | undefined;
220
+ const timeout = new Promise<never>((_, reject) => {
221
+ timer = setTimeout(() => reject(new Error("fixback: request timed out")), ms);
222
+ });
223
+ return Promise.race([request, timeout]).finally(() => {
224
+ if (timer !== undefined) clearTimeout(timer);
225
+ });
226
+ }
227
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import pkg from "../package.json";
4
+ import { NODE_SDK_VERSION } from "./version";
5
+
6
+ describe("NODE_SDK_VERSION", () => {
7
+ it("matches package.json's version (synced by scripts/sync-sdk-version.mjs)", () => {
8
+ expect(NODE_SDK_VERSION).toBe(pkg.version);
9
+ });
10
+ });
package/src/version.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The backend SDK's own version string, reported to ingest as
3
+ * `environment.sdkVersion`. Kept as an inlined constant rather than a runtime
4
+ * `package.json` import so the published build stays self-contained (the same
5
+ * posture as `packages/sdk/src/version.ts` and `packages/expo/src/version.ts`).
6
+ *
7
+ * It must equal `package.json`'s `version`. Two things keep it there: the
8
+ * Changesets `version` step syncs it automatically (`scripts/sync-sdk-version.mjs`,
9
+ * wired into `version-packages`), and `version.test.ts` fails the build if the two
10
+ * ever drift. Do not hand-edit this line to a value other than `package.json`'s
11
+ * version.
12
+ */
13
+ export const NODE_SDK_VERSION = "0.2.0";
package/src/wire.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The `@fixback/node` **transport contract** — the value shapes this SDK sends to
3
+ * the secret-key server-error ingest endpoint, and the {@link FixbackErrorEvent} the
4
+ * `beforeSend` hook sees before a capture leaves the process.
5
+ *
6
+ * `@fixback/node` **vendors** this contract (the ticket #47 convention, reaffirmed by
7
+ * ADR-0028: the shared core carries pure logic and value shapes, not the ingest
8
+ * envelope). It is kept in lock-step with the server endpoint that parses it —
9
+ * `POST /api/errors` (`apps/api/src/errors/errors.controller.ts`, ADR-0026/0027).
10
+ * `CapturedFrame` (the structured stack-frame shape) is the one type reused from the
11
+ * shared core, so the browser and node SDKs describe a frame identically.
12
+ */
13
+
14
+ import type { CapturedFrame } from "@fixback/sdk-core";
15
+
16
+ /** A severity for {@link FixbackErrorEvent} messages — Sentry's level vocabulary. */
17
+ export type Severity = "fatal" | "error" | "warning" | "info" | "debug";
18
+
19
+ /**
20
+ * The **server context** captured for a backend error (ADR-0025, spec #224).
21
+ * Private-by-default: the route **pattern** (never the concrete path with values),
22
+ * the HTTP method, the resolved status, a correlation id, and an **app-supplied**
23
+ * user ref only. Never request/response bodies, never `Authorization`/`Cookie`
24
+ * headers, never env vars, never query-string values.
25
+ */
26
+ export interface ServerContext {
27
+ /** The HTTP method (`GET`, `POST`, …). */
28
+ readonly method?: string;
29
+ /** The route **pattern** (`/users/:id`) — never the concrete path with values. */
30
+ readonly route?: string;
31
+ /** The resolved HTTP status of the failed request. */
32
+ readonly statusCode?: number;
33
+ /** A per-request correlation id (from `x-request-id` when present, else generated). */
34
+ readonly requestId?: string;
35
+ /** An **app-supplied** user reference (via `setUser`) — never scraped from the request. */
36
+ readonly user?: string;
37
+ }
38
+
39
+ /**
40
+ * The assembled event a capture produces, handed to the project's optional
41
+ * `beforeSend` hook (spec §C) before transport. The hook may mutate it (to redact)
42
+ * or return `null` to drop the event entirely.
43
+ *
44
+ * `type` and `value` (the error class and message) are here for the hook to inspect
45
+ * and for the fingerprint, but are **not** put on the wire — the ingest contract has
46
+ * no slot for them (the fingerprint already encodes them, and error text is treated
47
+ * as untrusted-derived, ADR-0016). `level` likewise rides only on the event.
48
+ */
49
+ export interface FixbackErrorEvent {
50
+ /** The cross-surface fingerprint (shared-core signature, ADR-0028) — the fold key. */
51
+ errorSignature: string;
52
+ /** The error class / name (`TypeError`), or `"Message"` for a `captureMessage`. */
53
+ type: string;
54
+ /** The error message (or the message text for a `captureMessage`). */
55
+ value: string;
56
+ /** `false` = an uncaught process crash, `true` = a caught/manual capture (ADR-0025). */
57
+ handled?: boolean;
58
+ /** The severity of a `captureMessage` (event-only; unset for a `captureException`). */
59
+ level?: Severity;
60
+ /** The deploy environment (`production` / `staging` / …). */
61
+ environment?: string;
62
+ /** The host app's Release — the build the stack symbolicates against. */
63
+ release?: string;
64
+ /** The running occurrence count coalesced for this fingerprint in the flush window. */
65
+ occurrences?: number;
66
+ /** The parsed stack frames, top of stack first, scrubbed and capped. */
67
+ errorFrames?: CapturedFrame[];
68
+ /** The in-flight request's server context, when captured inside a request. */
69
+ server?: ServerContext;
70
+ }
71
+
72
+ /**
73
+ * The project's optional capture hook (spec §C, story 24), run at the scrub choke
74
+ * point **after** the built-in scrubbers. Mutate the event to redact, or return
75
+ * `null` to drop it entirely. Synchronous and network-free by contract; a hook that
76
+ * throws is treated as a no-op (the already-scrubbed event is kept).
77
+ */
78
+ export type BeforeSend = (event: FixbackErrorEvent) => FixbackErrorEvent | null;
79
+
80
+ /**
81
+ * One captured backend error **on the wire** — the payload item the SDK posts.
82
+ * `errorSignature` is required (the ADR-0027 fold key); everything else is optional.
83
+ * Mirrors the server's `errorItemBody`. `server` rides along for forward
84
+ * compatibility — the current endpoint strips unknown keys, so it is harmless today
85
+ * and ready for when the server persists it.
86
+ */
87
+ export interface ServerErrorPayload {
88
+ readonly errorSignature: string;
89
+ readonly handled?: boolean;
90
+ readonly environment?: string;
91
+ readonly release?: string;
92
+ readonly occurrences?: number;
93
+ readonly errorFrames?: readonly CapturedFrame[];
94
+ readonly server?: ServerContext;
95
+ }
96
+
97
+ /** The JSON batch envelope `POST`ed to `/api/errors` — a non-empty array of errors. */
98
+ export interface ServerErrorBatch {
99
+ readonly errors: readonly ServerErrorPayload[];
100
+ }
101
+
102
+ /** The per-item accept/reject result the endpoint returns (ADR-0026 partial-failure). */
103
+ export type ServerErrorItemResult =
104
+ | {
105
+ readonly accepted: true;
106
+ readonly issueId: string;
107
+ readonly folded: boolean;
108
+ readonly feedbackId?: string;
109
+ }
110
+ | { readonly accepted: false; readonly reason: string };
111
+
112
+ /** The endpoint's response body — `results` on `201`, or a `message` on a shed `429`. */
113
+ export interface ServerErrorResponse {
114
+ readonly results?: readonly ServerErrorItemResult[];
115
+ readonly message?: string;
116
+ }