@fixback/node 0.2.0 → 0.3.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/README.md +4 -1
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +25 -19
- package/dist/config.js +11 -6
- package/dist/config.js.map +1 -1
- package/dist/host-identity.d.ts +60 -0
- package/dist/host-identity.js +72 -0
- package/dist/host-identity.js.map +1 -0
- package/dist/http.d.ts +2 -2
- package/dist/http.js +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/stack.d.ts +8 -18
- package/dist/stack.js +8 -77
- package/dist/stack.js.map +1 -1
- package/dist/transport.d.ts +1 -17
- package/dist/transport.js +4 -20
- package/dist/transport.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wire.d.ts +10 -16
- package/package.json +8 -6
- package/src/client.test.ts +0 -272
- package/src/client.ts +0 -262
- package/src/config.test.ts +0 -79
- package/src/config.ts +0 -131
- package/src/context.test.ts +0 -99
- package/src/context.ts +0 -124
- package/src/express.test.ts +0 -112
- package/src/express.ts +0 -85
- package/src/fingerprint-parity.test.ts +0 -86
- package/src/http.ts +0 -83
- package/src/index.ts +0 -51
- package/src/nestjs.test.ts +0 -146
- package/src/nestjs.ts +0 -123
- package/src/process.test.ts +0 -154
- package/src/process.ts +0 -153
- package/src/stack.test.ts +0 -89
- package/src/stack.ts +0 -153
- package/src/transport.test.ts +0 -187
- package/src/transport.ts +0 -227
- package/src/version.test.ts +0 -10
- package/src/version.ts +0 -13
- package/src/wire.ts +0 -116
package/src/transport.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
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
|
-
}
|
package/src/version.test.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
}
|