@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/process.ts
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Process-level capture (spec §D9, stories 4 & 5) — the *polite* `uncaughtException`
|
|
3
|
-
* and `unhandledRejection` handlers that **never change the app's exit behaviour**.
|
|
4
|
-
*
|
|
5
|
-
* The subtlety: merely *adding* an `uncaughtException` listener suppresses Node's
|
|
6
|
-
* default crash. So to stay polite:
|
|
7
|
-
*
|
|
8
|
-
* - **uncaughtException:** capture (`handled: false`), then — only when we are the
|
|
9
|
-
* *sole* listener (the app installed none of its own) — best-effort flush and hand
|
|
10
|
-
* off to `onFatalError`, which preserves Node's default (log + non-zero exit). When
|
|
11
|
-
* the app has its own handler, we do nothing further: the app owns the exit.
|
|
12
|
-
* - **unhandledRejection:** capture (`handled: false`) and stop. We never escalate a
|
|
13
|
-
* rejection to a process exit — exactly the "never turn a logged rejection into an
|
|
14
|
-
* exit" guarantee (story 5).
|
|
15
|
-
*
|
|
16
|
-
* Everything is wrapped so a capture failure can never break the handler.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** The `process` slice these handlers touch — injectable so tests never touch the real one. */
|
|
20
|
-
export interface ProcessLike {
|
|
21
|
-
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
22
|
-
removeListener(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
23
|
-
listeners(event: string): Array<(...args: unknown[]) => void>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** What a captured process error is filed through — the {@link FixbackClient} satisfies it. */
|
|
27
|
-
export interface CaptureSink {
|
|
28
|
-
captureException(error: unknown, context?: { handled?: boolean }): void;
|
|
29
|
-
flush(): Promise<void>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Which process handlers to install. */
|
|
33
|
-
export interface ProcessHandlerOptions {
|
|
34
|
-
readonly captureUncaughtException: boolean;
|
|
35
|
-
readonly captureUnhandledRejection: boolean;
|
|
36
|
-
/** How long a fatal-path flush may take before the process exits anyway. */
|
|
37
|
-
readonly flushTimeoutMs?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Injectable collaborators for {@link installProcessHandlers}. */
|
|
41
|
-
export interface ProcessHandlerDeps {
|
|
42
|
-
readonly process?: ProcessLike;
|
|
43
|
-
/**
|
|
44
|
-
* Preserve Node's default fatal behaviour after a sole-listener uncaught exception.
|
|
45
|
-
* Defaults to logging the error and exiting non-zero (exactly what Node would do).
|
|
46
|
-
*/
|
|
47
|
-
readonly onFatalError?: (error: unknown) => void;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const DEFAULT_FLUSH_TIMEOUT_MS = 2_000;
|
|
51
|
-
|
|
52
|
-
/** The real `process`, when running on Node. */
|
|
53
|
-
function resolveProcess(): ProcessLike | undefined {
|
|
54
|
-
const g = globalThis as unknown as { process?: ProcessLike };
|
|
55
|
-
const proc = g.process;
|
|
56
|
-
return proc && typeof proc.on === "function" ? proc : undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Node's default fatal behaviour: print the error and exit non-zero. */
|
|
60
|
-
function defaultOnFatalError(proc: ProcessLike): (error: unknown) => void {
|
|
61
|
-
return (error: unknown) => {
|
|
62
|
-
try {
|
|
63
|
-
console.error(error);
|
|
64
|
-
} catch {
|
|
65
|
-
/* a hostile console must not stop the exit */
|
|
66
|
-
}
|
|
67
|
-
const exit = (proc as { exit?: (code?: number) => never }).exit;
|
|
68
|
-
if (typeof exit === "function") {
|
|
69
|
-
try {
|
|
70
|
-
exit(1);
|
|
71
|
-
} catch {
|
|
72
|
-
/* exit is terminal; nothing to do if it throws */
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Race a flush against a timeout so a wedged transport can't hang a crashing process. */
|
|
79
|
-
function flushWithTimeout(flush: () => Promise<void>, ms: number): Promise<void> {
|
|
80
|
-
let result: Promise<void>;
|
|
81
|
-
try {
|
|
82
|
-
result = Promise.resolve(flush());
|
|
83
|
-
} catch {
|
|
84
|
-
return Promise.resolve();
|
|
85
|
-
}
|
|
86
|
-
if (ms <= 0) return result.catch(() => {});
|
|
87
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
88
|
-
const timeout = new Promise<void>((resolve) => {
|
|
89
|
-
timer = setTimeout(resolve, ms);
|
|
90
|
-
});
|
|
91
|
-
return Promise.race([result.catch(() => {}), timeout]).finally(() => {
|
|
92
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Install the polite process handlers for `sink`, returning an uninstall function.
|
|
98
|
-
* A no-op (and a no-op uninstall) when no `process` is available or when both gates
|
|
99
|
-
* are off.
|
|
100
|
-
*/
|
|
101
|
-
export function installProcessHandlers(
|
|
102
|
-
sink: CaptureSink,
|
|
103
|
-
options: ProcessHandlerOptions,
|
|
104
|
-
deps: ProcessHandlerDeps = {},
|
|
105
|
-
): () => void {
|
|
106
|
-
const proc = deps.process ?? resolveProcess();
|
|
107
|
-
if (!proc) return () => {};
|
|
108
|
-
|
|
109
|
-
const onFatalError = deps.onFatalError ?? defaultOnFatalError(proc);
|
|
110
|
-
const flushTimeoutMs = options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
|
|
111
|
-
|
|
112
|
-
const onUncaughtException = (...args: unknown[]): void => {
|
|
113
|
-
const error = args[0];
|
|
114
|
-
try {
|
|
115
|
-
sink.captureException(error, { handled: false });
|
|
116
|
-
} catch {
|
|
117
|
-
/* capture must never break the handler */
|
|
118
|
-
}
|
|
119
|
-
// If the app installed its own uncaughtException handler, it owns the exit — we
|
|
120
|
-
// must not force one (that would *change* its behaviour). Only when we are the
|
|
121
|
-
// sole listener do we preserve Node's default crash.
|
|
122
|
-
const others = proc.listeners("uncaughtException").filter((l) => l !== onUncaughtException);
|
|
123
|
-
if (others.length > 0) return;
|
|
124
|
-
void flushWithTimeout(() => sink.flush(), flushTimeoutMs).finally(() => {
|
|
125
|
-
try {
|
|
126
|
-
onFatalError(error);
|
|
127
|
-
} catch {
|
|
128
|
-
/* the fatal handler is terminal */
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
const onUnhandledRejection = (...args: unknown[]): void => {
|
|
134
|
-
// Capture only — never escalate a rejection to a process exit (story 5).
|
|
135
|
-
try {
|
|
136
|
-
sink.captureException(args[0], { handled: false });
|
|
137
|
-
} catch {
|
|
138
|
-
/* capture must never break the handler */
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
if (options.captureUncaughtException) proc.on("uncaughtException", onUncaughtException);
|
|
143
|
-
if (options.captureUnhandledRejection) proc.on("unhandledRejection", onUnhandledRejection);
|
|
144
|
-
|
|
145
|
-
return () => {
|
|
146
|
-
if (options.captureUncaughtException) {
|
|
147
|
-
proc.removeListener("uncaughtException", onUncaughtException);
|
|
148
|
-
}
|
|
149
|
-
if (options.captureUnhandledRejection) {
|
|
150
|
-
proc.removeListener("unhandledRejection", onUnhandledRejection);
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
}
|
package/src/stack.test.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { extractError, extractStructuredFrames } from "./stack";
|
|
4
|
-
|
|
5
|
-
const V8_STACK = [
|
|
6
|
-
"TypeError: Cannot read properties of undefined (reading 'id')",
|
|
7
|
-
" at renderTotal (/srv/app/dist/checkout.js:88:12)",
|
|
8
|
-
" at Object.<anonymous> (/srv/app/dist/server.js:42:5)",
|
|
9
|
-
" at processTicksAndRejections (node:internal/process/task_queues:95:5)",
|
|
10
|
-
].join("\n");
|
|
11
|
-
|
|
12
|
-
describe("extractStructuredFrames", () => {
|
|
13
|
-
it("parses V8 frames into file/line/column/function, top of stack first", () => {
|
|
14
|
-
const frames = extractStructuredFrames(V8_STACK);
|
|
15
|
-
expect(frames[0]).toEqual({
|
|
16
|
-
file: "/srv/app/dist/checkout.js",
|
|
17
|
-
line: 88,
|
|
18
|
-
column: 12,
|
|
19
|
-
function: "renderTotal",
|
|
20
|
-
});
|
|
21
|
-
expect(frames[1]).toEqual({
|
|
22
|
-
file: "/srv/app/dist/server.js",
|
|
23
|
-
line: 42,
|
|
24
|
-
column: 5,
|
|
25
|
-
function: "Object.<anonymous>",
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("scrubs credentials and query strings from a frame's file url (shared-core scrub)", () => {
|
|
30
|
-
const stack = [
|
|
31
|
-
"Error: boom",
|
|
32
|
-
" at handler (https://user:pass@cdn.example.com/app.js?token=secret123:5:1)",
|
|
33
|
-
].join("\n");
|
|
34
|
-
const [frame] = extractStructuredFrames(stack);
|
|
35
|
-
expect(frame?.file).not.toContain("token=secret123");
|
|
36
|
-
expect(frame?.file).not.toContain("user:pass@");
|
|
37
|
-
expect(frame?.file).toContain("cdn.example.com/app.js");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("caps the number of frames it keeps", () => {
|
|
41
|
-
const many = ["Error: boom"]
|
|
42
|
-
.concat(Array.from({ length: 100 }, (_, i) => ` at fn${i} (/srv/app/f${i}.js:${i + 1}:1)`))
|
|
43
|
-
.join("\n");
|
|
44
|
-
expect(extractStructuredFrames(many, 5)).toHaveLength(5);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("returns no frames for a missing or stackless input", () => {
|
|
48
|
-
expect(extractStructuredFrames(undefined)).toEqual([]);
|
|
49
|
-
expect(extractStructuredFrames("")).toEqual([]);
|
|
50
|
-
expect(extractStructuredFrames("Error: no frames here")).toEqual([]);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("extractError", () => {
|
|
55
|
-
it("distils an Error into type / value / stack", () => {
|
|
56
|
-
const err = new TypeError("nope");
|
|
57
|
-
const extracted = extractError(err);
|
|
58
|
-
expect(extracted.type).toBe("TypeError");
|
|
59
|
-
expect(extracted.value).toBe("nope");
|
|
60
|
-
expect(extracted.stack).toBe(err.stack);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("falls back to Error for an anonymous error and keeps an empty message", () => {
|
|
64
|
-
const bare = new Error();
|
|
65
|
-
expect(extractError(bare).type).toBe("Error");
|
|
66
|
-
expect(extractError(bare).value).toBe("");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("distils a thrown string", () => {
|
|
70
|
-
expect(extractError("kaboom")).toEqual({ type: "Error", value: "kaboom", stack: undefined });
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("distils a thrown non-error object without throwing", () => {
|
|
74
|
-
const extracted = extractError({ weird: true });
|
|
75
|
-
expect(extracted.type).toBe("Error");
|
|
76
|
-
expect(typeof extracted.value).toBe("string");
|
|
77
|
-
expect(extracted.stack).toBeUndefined();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("uses a custom error subclass name as the type", () => {
|
|
81
|
-
class PaymentError extends Error {
|
|
82
|
-
constructor(message: string) {
|
|
83
|
-
super(message);
|
|
84
|
-
this.name = "PaymentError";
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
expect(extractError(new PaymentError("declined")).type).toBe("PaymentError");
|
|
88
|
-
});
|
|
89
|
-
});
|
package/src/stack.ts
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Turning a thrown value into the pieces a capture needs: the distilled
|
|
3
|
-
* `type | value | stack` a fingerprint is built from ({@link extractError}), and the
|
|
4
|
-
* **structured stack frames** for the wire ({@link extractStructuredFrames}).
|
|
5
|
-
*
|
|
6
|
-
* The distillation mirrors the browser SDK's exactly (`packages/sdk/src/error-capture.ts`)
|
|
7
|
-
* — `type = error.name`, `value = error.message`, `stack = error.stack` — so the same
|
|
8
|
-
* logical error yields the same shared-core fingerprint on either surface (the
|
|
9
|
-
* ADR-0028 parity guarantee). Frame parsing keeps the full script path (server-side
|
|
10
|
-
* symbolication matches it against uploaded sourcemaps) but scrubs each URL through
|
|
11
|
-
* the shared-core scrubber before it can leave the process.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { type CapturedFrame, scrubUrl } from "@fixback/sdk-core";
|
|
15
|
-
|
|
16
|
-
/** The most structured frames shipped per error (mirrors the server's cap). */
|
|
17
|
-
const STRUCTURED_FRAME_LIMIT = 30;
|
|
18
|
-
|
|
19
|
-
/** The distilled shape a fingerprint and a report are built from. */
|
|
20
|
-
export interface ExtractedError {
|
|
21
|
-
readonly type: string;
|
|
22
|
-
readonly value: string;
|
|
23
|
-
readonly stack?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ErrorLike {
|
|
27
|
-
name?: unknown;
|
|
28
|
-
message?: unknown;
|
|
29
|
-
stack?: unknown;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Coerce any value to a string without throwing (a hostile `toString` can throw). */
|
|
33
|
-
function asString(value: unknown): string {
|
|
34
|
-
if (typeof value === "string") return value;
|
|
35
|
-
if (value == null) return "";
|
|
36
|
-
try {
|
|
37
|
-
return String(value);
|
|
38
|
-
} catch {
|
|
39
|
-
return "";
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Best-effort JSON for a non-Error thrown object, falling back to `String`. */
|
|
44
|
-
function safeStringify(value: unknown): string {
|
|
45
|
-
try {
|
|
46
|
-
return JSON.stringify(value) ?? asString(value);
|
|
47
|
-
} catch {
|
|
48
|
-
return asString(value);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** True for a duck-typed error (a cross-realm Error, or an error-shaped throwable). */
|
|
53
|
-
function isErrorLike(value: object): value is ErrorLike {
|
|
54
|
-
const err = value as ErrorLike;
|
|
55
|
-
return (
|
|
56
|
-
typeof err.message === "string" ||
|
|
57
|
-
typeof err.stack === "string" ||
|
|
58
|
-
typeof err.name === "string"
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Distil any thrown value into `{ type, value, stack }`. An `Error` (or error-shaped
|
|
64
|
-
* object) contributes its `name` / `message` / `stack`; a thrown string becomes the
|
|
65
|
-
* value; any other value is stringified. Never throws.
|
|
66
|
-
*/
|
|
67
|
-
export function extractError(input: unknown): ExtractedError {
|
|
68
|
-
if (input instanceof Error) {
|
|
69
|
-
return {
|
|
70
|
-
type: asString(input.name) || "Error",
|
|
71
|
-
value: asString(input.message),
|
|
72
|
-
stack: typeof input.stack === "string" ? input.stack : undefined,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
if (typeof input === "string") {
|
|
76
|
-
return { type: "Error", value: input, stack: undefined };
|
|
77
|
-
}
|
|
78
|
-
if (input && typeof input === "object") {
|
|
79
|
-
if (isErrorLike(input)) {
|
|
80
|
-
const err = input as ErrorLike;
|
|
81
|
-
return {
|
|
82
|
-
type: asString(err.name) || "Error",
|
|
83
|
-
value: asString(err.message),
|
|
84
|
-
stack: typeof err.stack === "string" ? err.stack : undefined,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
return { type: "Error", value: safeStringify(input), stack: undefined };
|
|
88
|
-
}
|
|
89
|
-
return { type: "Error", value: asString(input), stack: undefined };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Split a `file:line:col` location into parts; `null` when it has no line. */
|
|
93
|
-
function parseLocation(
|
|
94
|
-
location: string,
|
|
95
|
-
): { file: string; line: number; column: number | null } | null {
|
|
96
|
-
// The file may itself contain colons (https://…, node:internal/…), so split from
|
|
97
|
-
// the right: the trailing `:line(:col)?` are the numeric groups.
|
|
98
|
-
const match = location.match(/^(.*?):(\d+)(?::(\d+))?$/);
|
|
99
|
-
if (!match) return null;
|
|
100
|
-
const file = match[1] ?? "";
|
|
101
|
-
if (file.length === 0 || file === "native" || file.includes("<anonymous>")) {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
const line = Number(match[2]);
|
|
105
|
-
if (!Number.isFinite(line)) return null;
|
|
106
|
-
const column = match[3] !== undefined ? Number(match[3]) : null;
|
|
107
|
-
return { file, line, column };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Extract structured frames from a stack for the wire (#117, ADR-0024), top of stack
|
|
112
|
-
* first. Keeps the full (scrubbed) script path so server-side symbolication can match
|
|
113
|
-
* it against uploaded sourcemaps; skips unlocatable frames (`native`, `<anonymous>`,
|
|
114
|
-
* eval); caps the count. V8 is Node's format; the Firefox/Safari form is handled too
|
|
115
|
-
* so the parser matches the browser SDK's exactly.
|
|
116
|
-
*/
|
|
117
|
-
export function extractStructuredFrames(
|
|
118
|
-
stack: string | undefined,
|
|
119
|
-
limit = STRUCTURED_FRAME_LIMIT,
|
|
120
|
-
): CapturedFrame[] {
|
|
121
|
-
if (typeof stack !== "string" || stack.length === 0) return [];
|
|
122
|
-
const frames: CapturedFrame[] = [];
|
|
123
|
-
for (const raw of stack.split("\n")) {
|
|
124
|
-
if (frames.length >= limit) break;
|
|
125
|
-
const line = raw.trim();
|
|
126
|
-
let fn: string | null = null;
|
|
127
|
-
let location: string | null = null;
|
|
128
|
-
// V8: "at fn (loc)" | "at loc"
|
|
129
|
-
const v8Named = line.match(/^at\s+(.+?)\s+\((.+)\)$/);
|
|
130
|
-
const v8Bare = v8Named ? null : line.match(/^at\s+(.+)$/);
|
|
131
|
-
const geckoAt = v8Named || v8Bare ? -1 : line.indexOf("@");
|
|
132
|
-
if (v8Named) {
|
|
133
|
-
fn = v8Named[1] ?? null;
|
|
134
|
-
location = v8Named[2] ?? null;
|
|
135
|
-
} else if (v8Bare) {
|
|
136
|
-
location = v8Bare[1] ?? null;
|
|
137
|
-
} else if (geckoAt >= 0) {
|
|
138
|
-
// Firefox / Safari: "fn@loc" | "@loc"
|
|
139
|
-
fn = geckoAt > 0 ? line.slice(0, geckoAt) : null;
|
|
140
|
-
location = line.slice(geckoAt + 1);
|
|
141
|
-
}
|
|
142
|
-
if (!location) continue;
|
|
143
|
-
const parsed = parseLocation(location);
|
|
144
|
-
if (!parsed) continue;
|
|
145
|
-
frames.push({
|
|
146
|
-
file: scrubUrl(parsed.file),
|
|
147
|
-
line: parsed.line,
|
|
148
|
-
column: parsed.column,
|
|
149
|
-
function: fn && fn.length > 0 ? fn : null,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
return frames;
|
|
153
|
-
}
|
package/src/transport.test.ts
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
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
|
-
});
|