@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
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { computeFingerprint } from "@fixback/sdk-core";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { FixbackClient } from "./client";
|
|
5
|
-
import { resolveConfig } from "./config";
|
|
6
|
-
import type { CaptureTransport } from "./transport";
|
|
7
|
-
import type { ServerErrorPayload } from "./wire";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Cross-surface fingerprint parity (ADR-0027/0028, spec #224 Seam 3): the browser and
|
|
11
|
-
* node SDKs must compute the **same** fingerprint for the same logical error, or a
|
|
12
|
-
* crash surfaced from both the frontend and the backend would not group together.
|
|
13
|
-
*
|
|
14
|
-
* Both SDKs key an error with the **shared core** `computeFingerprint(type, value,
|
|
15
|
-
* stack)`. `browserFingerprint` reproduces the browser SDK's distillation exactly
|
|
16
|
-
* (`packages/sdk/src/error-capture.ts`: `type = error.name || "Error"`,
|
|
17
|
-
* `value = error.message`, `stack = error.stack`, then `computeFingerprint(...)`), so
|
|
18
|
-
* it is the key the browser SDK would produce. `nodeFingerprint` drives the real
|
|
19
|
-
* `@fixback/node` capture pipeline and reads the fingerprint off the outgoing payload.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** The key the browser SDK computes for `error` (its distillation + the shared core). */
|
|
23
|
-
function browserFingerprint(error: Error): string {
|
|
24
|
-
const type = error.name || "Error";
|
|
25
|
-
return computeFingerprint(type, error.message, error.stack);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** The key the node SDK puts on the wire for `error` (the real capture pipeline). */
|
|
29
|
-
function nodeFingerprint(error: unknown): string {
|
|
30
|
-
const enqueued: ServerErrorPayload[] = [];
|
|
31
|
-
const transport: CaptureTransport = {
|
|
32
|
-
enqueue: (payload) => enqueued.push(payload),
|
|
33
|
-
flush: async () => {},
|
|
34
|
-
close: async () => {},
|
|
35
|
-
};
|
|
36
|
-
const client = new FixbackClient(resolveConfig({ secretKey: "sk" }), { transport });
|
|
37
|
-
client.captureException(error);
|
|
38
|
-
return enqueued[0]!.errorSignature;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe("cross-surface fingerprint parity (ADR-0028)", () => {
|
|
42
|
-
it("matches the browser's fingerprint for a typed error with a stack", () => {
|
|
43
|
-
const error = new TypeError("Cannot read properties of undefined (reading 'id')");
|
|
44
|
-
error.stack = [
|
|
45
|
-
"TypeError: Cannot read properties of undefined (reading 'id')",
|
|
46
|
-
" at renderTotal (/srv/app/dist/checkout.js:88:12)",
|
|
47
|
-
" at handle (/srv/app/dist/server.js:42:5)",
|
|
48
|
-
].join("\n");
|
|
49
|
-
expect(nodeFingerprint(error)).toBe(browserFingerprint(error));
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("matches for a plain, stackless message-only error", () => {
|
|
53
|
-
const error = new Error("database connection refused");
|
|
54
|
-
error.stack = undefined;
|
|
55
|
-
expect(nodeFingerprint(error)).toBe(browserFingerprint(error));
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("matches for a custom error subclass (its name is the type on both surfaces)", () => {
|
|
59
|
-
class PaymentError extends Error {
|
|
60
|
-
constructor(message: string) {
|
|
61
|
-
super(message);
|
|
62
|
-
this.name = "PaymentError";
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
const error = new PaymentError("card declined");
|
|
66
|
-
expect(nodeFingerprint(error)).toBe(browserFingerprint(error));
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("is stable across instances — the same error from different install paths groups", () => {
|
|
70
|
-
// Two fleet instances throw the identical error; only the absolute path differs.
|
|
71
|
-
const onA = new Error("boom");
|
|
72
|
-
onA.stack = "Error: boom\n at handler (/srv/app-a/dist/checkout.js:88:12)";
|
|
73
|
-
const onB = new Error("boom");
|
|
74
|
-
onB.stack = "Error: boom\n at handler (/home/deploy/app-b/dist/checkout.js:88:12)";
|
|
75
|
-
|
|
76
|
-
// The node SDK folds them to one fingerprint, and it is the browser's fingerprint too.
|
|
77
|
-
expect(nodeFingerprint(onA)).toBe(nodeFingerprint(onB));
|
|
78
|
-
expect(nodeFingerprint(onA)).toBe(browserFingerprint(onA));
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("keeps genuinely distinct errors distinct", () => {
|
|
82
|
-
const a = new Error("first failure");
|
|
83
|
-
const b = new Error("second failure");
|
|
84
|
-
expect(nodeFingerprint(a)).not.toBe(nodeFingerprint(b));
|
|
85
|
-
});
|
|
86
|
-
});
|
package/src/http.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal **structural** types for the HTTP surface the adapters touch, plus
|
|
3
|
-
* {@link routePatternOf}.
|
|
4
|
-
*
|
|
5
|
-
* Described structurally (the same posture as `packages/expo/src/http.ts`) so the
|
|
6
|
-
* SDK needs no `@types/express` dependency: Express and NestJS (on Express) both pass
|
|
7
|
-
* requests/responses that satisfy these shapes, and tests pass plain objects. Only
|
|
8
|
-
* the private-by-default fields are read here — never bodies, headers beyond the
|
|
9
|
-
* correlation id, query values, or env.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** The request slice the adapters read — never its body, headers (bar the id), or query. */
|
|
13
|
-
export interface HttpRequestLike {
|
|
14
|
-
readonly method?: string;
|
|
15
|
-
readonly baseUrl?: string;
|
|
16
|
-
readonly path?: string;
|
|
17
|
-
/** Populated by the router once a route matches — its `path` is the **pattern**. */
|
|
18
|
-
readonly route?: { readonly path?: string | RegExp } | undefined;
|
|
19
|
-
readonly headers?: Readonly<Record<string, string | string[] | undefined>>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The response slice the adapters read — only the resolved status. */
|
|
23
|
-
export interface HttpResponseLike {
|
|
24
|
-
readonly statusCode?: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The Express/Nest `next` callback — `next(err)` forwards to the error pipeline. */
|
|
28
|
-
export type NextFunction = (err?: unknown) => void;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The route **pattern** for a request (`/api/users/:id`), joining a mounted router's
|
|
32
|
-
* `baseUrl` with the matched `route.path`. Returns `undefined` when no route has
|
|
33
|
-
* matched — deliberately **never** falling back to the concrete `path`, which would
|
|
34
|
-
* leak the in-URL values the PII boundary forbids (spec §D12).
|
|
35
|
-
*/
|
|
36
|
-
export function routePatternOf(req: HttpRequestLike): string | undefined {
|
|
37
|
-
const routePath = req.route?.path;
|
|
38
|
-
if (routePath === undefined || routePath === null) return undefined;
|
|
39
|
-
const pattern =
|
|
40
|
-
typeof routePath === "string" ? routePath : (routePath.source ?? String(routePath));
|
|
41
|
-
const base = typeof req.baseUrl === "string" ? req.baseUrl : "";
|
|
42
|
-
if (pattern.length === 0) return base.length > 0 ? base : undefined;
|
|
43
|
-
if (base.length === 0) return pattern;
|
|
44
|
-
return `${base}${pattern.startsWith("/") ? pattern : `/${pattern}`}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The private-by-default server-context fields derivable from a request alone — the
|
|
49
|
-
* HTTP method and the route **pattern**. The single place that decides what an adapter
|
|
50
|
-
* reads off a request (never a body, header, query value, or the concrete path), so the
|
|
51
|
-
* Express and NestJS adapters can never drift on the privacy posture. The status is
|
|
52
|
-
* added by the caller (it differs: an Express error's own status vs a Nest
|
|
53
|
-
* `HttpException`'s).
|
|
54
|
-
*/
|
|
55
|
-
export function serverContextFromRequest(req: HttpRequestLike): { method?: string; route?: string } {
|
|
56
|
-
const out: { method?: string; route?: string } = {};
|
|
57
|
-
if (typeof req.method === "string" && req.method.length > 0) out.method = req.method;
|
|
58
|
-
const route = routePatternOf(req);
|
|
59
|
-
if (route) out.route = route;
|
|
60
|
-
return out;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Read an http-errors-style numeric status off a thrown value (`err.status`/`.statusCode`). */
|
|
64
|
-
export function numericStatus(error: unknown): number | undefined {
|
|
65
|
-
if (error && typeof error === "object") {
|
|
66
|
-
const e = error as { status?: unknown; statusCode?: unknown };
|
|
67
|
-
const raw = typeof e.status === "number" ? e.status : e.statusCode;
|
|
68
|
-
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
69
|
-
}
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* A sensible status for a captured request error: the error's own status when set,
|
|
75
|
-
* else a 4xx/5xx already on the response, else `500`.
|
|
76
|
-
*/
|
|
77
|
-
export function errorStatus(error: unknown, res: HttpResponseLike): number {
|
|
78
|
-
const fromError = numericStatus(error);
|
|
79
|
-
if (fromError && fromError >= 400) return fromError;
|
|
80
|
-
const fromResponse = res.statusCode;
|
|
81
|
-
if (typeof fromResponse === "number" && fromResponse >= 400) return fromResponse;
|
|
82
|
-
return 500;
|
|
83
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@fixback/node` — the Fixback backend error SDK for Node servers.
|
|
3
|
-
*
|
|
4
|
-
* `init({ secretKey })` wires a batched secret-key transport, the polite
|
|
5
|
-
* process-crash handlers, and the manual capture API below. Framework adapters are
|
|
6
|
-
* separate entry points so a plain install never pulls a framework in:
|
|
7
|
-
*
|
|
8
|
-
* - `@fixback/node/express` — {@link https://expressjs.com Express} middlewares.
|
|
9
|
-
* - `@fixback/node/nestjs` — a NestJS module + exception filter.
|
|
10
|
-
*
|
|
11
|
-
* The root entry is framework-free: import from here for `init`, manual capture,
|
|
12
|
-
* `setUser`, and the shared types.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// Capture surface + lifecycle.
|
|
16
|
-
export {
|
|
17
|
-
captureException,
|
|
18
|
-
captureMessage,
|
|
19
|
-
close,
|
|
20
|
-
FixbackClient,
|
|
21
|
-
flush,
|
|
22
|
-
getClient,
|
|
23
|
-
init,
|
|
24
|
-
} from "./client";
|
|
25
|
-
export type { CaptureClient, CaptureContext, ClientDeps } from "./client";
|
|
26
|
-
|
|
27
|
-
// Per-request context (AsyncLocalStorage) — attach a user, or read the active context.
|
|
28
|
-
export { currentServerContext, getRequestStore, setRequestUser, setRequestUser as setUser } from "./context";
|
|
29
|
-
export type { RequestStore } from "./context";
|
|
30
|
-
|
|
31
|
-
// Configuration.
|
|
32
|
-
export { DEFAULT_API_URL } from "./config";
|
|
33
|
-
export type { InitOptions } from "./config";
|
|
34
|
-
|
|
35
|
-
// Transport contract (advanced — a custom transport).
|
|
36
|
-
export type { CaptureTransport } from "./transport";
|
|
37
|
-
|
|
38
|
-
// Wire / event value shapes.
|
|
39
|
-
export type {
|
|
40
|
-
BeforeSend,
|
|
41
|
-
FixbackErrorEvent,
|
|
42
|
-
ServerContext,
|
|
43
|
-
ServerErrorPayload,
|
|
44
|
-
Severity,
|
|
45
|
-
} from "./wire";
|
|
46
|
-
|
|
47
|
-
// Re-exported from the shared core so callers can type stack frames in `beforeSend`.
|
|
48
|
-
export type { CapturedFrame } from "@fixback/sdk-core";
|
|
49
|
-
|
|
50
|
-
// The SDK's own version, reported as the capture `sdkVersion`.
|
|
51
|
-
export { NODE_SDK_VERSION } from "./version";
|
package/src/nestjs.test.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import "reflect-metadata";
|
|
2
|
-
|
|
3
|
-
import type { ArgumentsHost } from "@nestjs/common";
|
|
4
|
-
import { HttpException } from "@nestjs/common";
|
|
5
|
-
import { APP_FILTER, BaseExceptionFilter } from "@nestjs/core";
|
|
6
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
-
|
|
8
|
-
import { close as moduleClose, init } from "./client";
|
|
9
|
-
import type { CaptureClient, CaptureContext } from "./client";
|
|
10
|
-
import { captureNestException, FixbackExceptionFilter, FixbackModule } from "./nestjs";
|
|
11
|
-
import type { CaptureTransport } from "./transport";
|
|
12
|
-
import type { ServerErrorPayload } from "./wire";
|
|
13
|
-
|
|
14
|
-
interface Captured {
|
|
15
|
-
error: unknown;
|
|
16
|
-
context?: CaptureContext;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function recordingClient(): { captures: Captured[]; client: CaptureClient } {
|
|
20
|
-
const captures: Captured[] = [];
|
|
21
|
-
return {
|
|
22
|
-
captures,
|
|
23
|
-
client: { captureException: (error, context) => captures.push({ error, context }) },
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function recorder(): { enqueued: ServerErrorPayload[]; transport: CaptureTransport } {
|
|
28
|
-
const enqueued: ServerErrorPayload[] = [];
|
|
29
|
-
return {
|
|
30
|
-
enqueued,
|
|
31
|
-
transport: {
|
|
32
|
-
enqueue: (payload) => enqueued.push(payload),
|
|
33
|
-
flush: async () => {},
|
|
34
|
-
close: async () => {},
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function fakeHost(req: unknown, res: unknown, type = "http"): ArgumentsHost {
|
|
40
|
-
const http = {
|
|
41
|
-
getRequest: <T>() => req as T,
|
|
42
|
-
getResponse: <T>() => res as T,
|
|
43
|
-
getNext: <T>() => undefined as T,
|
|
44
|
-
};
|
|
45
|
-
return {
|
|
46
|
-
switchToHttp: () => http,
|
|
47
|
-
getType: () => type,
|
|
48
|
-
getArgs: () => [],
|
|
49
|
-
getArgByIndex: () => undefined,
|
|
50
|
-
switchToRpc: () => ({}),
|
|
51
|
-
switchToWs: () => ({}),
|
|
52
|
-
} as unknown as ArgumentsHost;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
afterEach(async () => {
|
|
56
|
-
await moduleClose();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("captureNestException", () => {
|
|
60
|
-
it("captures a thrown request error with server context (handled:true)", () => {
|
|
61
|
-
const { captures, client } = recordingClient();
|
|
62
|
-
captureNestException(
|
|
63
|
-
new Error("boom"),
|
|
64
|
-
fakeHost({ method: "GET", route: { path: "/orders/:id" } }, { statusCode: 200 }),
|
|
65
|
-
client,
|
|
66
|
-
);
|
|
67
|
-
expect(captures[0]!.context?.handled).toBe(true);
|
|
68
|
-
expect(captures[0]!.context?.server?.method).toBe("GET");
|
|
69
|
-
expect(captures[0]!.context?.server?.route).toBe("/orders/:id");
|
|
70
|
-
expect(captures[0]!.context?.server?.statusCode).toBe(500);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("uses an HttpException's status code", () => {
|
|
74
|
-
const { captures, client } = recordingClient();
|
|
75
|
-
captureNestException(
|
|
76
|
-
new HttpException("forbidden", 403),
|
|
77
|
-
fakeHost({ method: "DELETE", route: { path: "/x" } }, { statusCode: 200 }),
|
|
78
|
-
client,
|
|
79
|
-
);
|
|
80
|
-
expect(captures[0]!.context?.server?.statusCode).toBe(403);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("never throws even if capture fails", () => {
|
|
84
|
-
const client: CaptureClient = {
|
|
85
|
-
captureException() {
|
|
86
|
-
throw new Error("capture blew up");
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
expect(() =>
|
|
90
|
-
captureNestException(new Error("y"), fakeHost({ method: "GET" }, {}), client),
|
|
91
|
-
).not.toThrow();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("FixbackExceptionFilter", () => {
|
|
96
|
-
it("captures the exception, then delegates to Nest's base filter (behaviour unchanged)", () => {
|
|
97
|
-
const { enqueued, transport } = recorder();
|
|
98
|
-
init(
|
|
99
|
-
{ secretKey: "sk", captureUncaughtException: false, captureUnhandledRejection: false },
|
|
100
|
-
{ transport },
|
|
101
|
-
);
|
|
102
|
-
const superCatch = vi
|
|
103
|
-
.spyOn(BaseExceptionFilter.prototype, "catch")
|
|
104
|
-
.mockImplementation(() => {});
|
|
105
|
-
|
|
106
|
-
const filter = new FixbackExceptionFilter();
|
|
107
|
-
const host = fakeHost({ method: "GET", route: { path: "/x" } }, { statusCode: 200 });
|
|
108
|
-
filter.catch(new Error("boom"), host);
|
|
109
|
-
|
|
110
|
-
expect(enqueued).toHaveLength(1);
|
|
111
|
-
expect(enqueued[0]!.handled).toBe(true);
|
|
112
|
-
expect(enqueued[0]!.server?.route).toBe("/x");
|
|
113
|
-
expect(superCatch).toHaveBeenCalledTimes(1);
|
|
114
|
-
superCatch.mockRestore();
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe("FixbackModule", () => {
|
|
119
|
-
it("configure() applies a request-context middleware to all routes", () => {
|
|
120
|
-
const applied: { middleware: unknown[]; routes: unknown[] } = { middleware: [], routes: [] };
|
|
121
|
-
const consumer = {
|
|
122
|
-
apply: (...mws: unknown[]) => {
|
|
123
|
-
applied.middleware = mws;
|
|
124
|
-
return {
|
|
125
|
-
forRoutes: (...routes: unknown[]) => {
|
|
126
|
-
applied.routes = routes;
|
|
127
|
-
},
|
|
128
|
-
};
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
new FixbackModule().configure(consumer as never);
|
|
132
|
-
expect(applied.middleware).toHaveLength(1);
|
|
133
|
-
expect(typeof applied.middleware[0]).toBe("function");
|
|
134
|
-
expect(applied.routes).toEqual(["*"]);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("forRoot() registers the exception filter as a global APP_FILTER", () => {
|
|
138
|
-
const dynamic = FixbackModule.forRoot();
|
|
139
|
-
expect(dynamic.module).toBe(FixbackModule);
|
|
140
|
-
const providers = dynamic.providers ?? [];
|
|
141
|
-
const filterProvider = providers.find(
|
|
142
|
-
(p) => (p as { provide?: unknown }).provide === APP_FILTER,
|
|
143
|
-
) as { useClass?: unknown } | undefined;
|
|
144
|
-
expect(filterProvider?.useClass).toBe(FixbackExceptionFilter);
|
|
145
|
-
});
|
|
146
|
-
});
|
package/src/nestjs.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The **NestJS** adapter (`@fixback/node/nestjs`, story 2) — a module + exception
|
|
3
|
-
* filter that capture thrown request errors with server context, **without changing
|
|
4
|
-
* the app's behaviour**.
|
|
5
|
-
*
|
|
6
|
-
* - {@link FixbackExceptionFilter} extends Nest's `BaseExceptionFilter`: it captures
|
|
7
|
-
* the exception (`handled: true`, with the request's route pattern / method /
|
|
8
|
-
* status) and then delegates to `super.catch`, so Nest still produces the exact
|
|
9
|
-
* HTTP response it would have. Registered globally via `APP_FILTER`.
|
|
10
|
-
* - {@link FixbackModule} wires that filter and applies the request-context
|
|
11
|
-
* middleware (AsyncLocalStorage correlation) to every route. Register it with
|
|
12
|
-
* `FixbackModule.forRoot()`.
|
|
13
|
-
*
|
|
14
|
-
* `@nestjs/common` and `@nestjs/core` are **optional peer dependencies** — this module
|
|
15
|
-
* is only loaded when you import `@fixback/node/nestjs`, so an Express-only or
|
|
16
|
-
* manual-capture install never pulls Nest in.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import "reflect-metadata";
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
type ArgumentsHost,
|
|
23
|
-
Catch,
|
|
24
|
-
type DynamicModule,
|
|
25
|
-
HttpException,
|
|
26
|
-
type MiddlewareConsumer,
|
|
27
|
-
Module,
|
|
28
|
-
type NestModule,
|
|
29
|
-
} from "@nestjs/common";
|
|
30
|
-
import { APP_FILTER, BaseExceptionFilter } from "@nestjs/core";
|
|
31
|
-
|
|
32
|
-
import {
|
|
33
|
-
type CaptureClient,
|
|
34
|
-
captureException as moduleCaptureException,
|
|
35
|
-
getClient,
|
|
36
|
-
} from "./client";
|
|
37
|
-
import { createRequestContextMiddleware } from "./context";
|
|
38
|
-
import {
|
|
39
|
-
errorStatus,
|
|
40
|
-
type HttpRequestLike,
|
|
41
|
-
type HttpResponseLike,
|
|
42
|
-
serverContextFromRequest,
|
|
43
|
-
} from "./http";
|
|
44
|
-
|
|
45
|
-
/** Files through the active module client by default. */
|
|
46
|
-
const defaultClient: CaptureClient = {
|
|
47
|
-
captureException: (error, context) => moduleCaptureException(error, context),
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/** Resolve the status: an `HttpException`'s own status, else the shared heuristic. */
|
|
51
|
-
function nestStatus(exception: unknown, res: HttpResponseLike): number {
|
|
52
|
-
if (exception instanceof HttpException) {
|
|
53
|
-
const status = exception.getStatus();
|
|
54
|
-
if (typeof status === "number") return status;
|
|
55
|
-
}
|
|
56
|
-
return errorStatus(exception, res);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Capture a Nest exception with the request's server context (`handled: true`).
|
|
61
|
-
* Exported so it can be unit-tested against a fake `ArgumentsHost`; the filter uses
|
|
62
|
-
* it. Never throws — a capture failure must not break the request pipeline.
|
|
63
|
-
*/
|
|
64
|
-
export function captureNestException(
|
|
65
|
-
exception: unknown,
|
|
66
|
-
host: ArgumentsHost,
|
|
67
|
-
client: CaptureClient = defaultClient,
|
|
68
|
-
): void {
|
|
69
|
-
try {
|
|
70
|
-
const isHttp = typeof host.getType === "function" ? host.getType() === "http" : true;
|
|
71
|
-
if (!isHttp) {
|
|
72
|
-
client.captureException(exception, { handled: true });
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const http = host.switchToHttp();
|
|
76
|
-
const req = http.getRequest<HttpRequestLike>();
|
|
77
|
-
const res = (http.getResponse<HttpResponseLike>() ?? {}) as HttpResponseLike;
|
|
78
|
-
const base = req ? serverContextFromRequest(req) : {};
|
|
79
|
-
const server = { ...base, statusCode: nestStatus(exception, res) };
|
|
80
|
-
client.captureException(exception, { handled: true, server });
|
|
81
|
-
} catch {
|
|
82
|
-
try {
|
|
83
|
-
client.captureException(exception, { handled: true });
|
|
84
|
-
} catch {
|
|
85
|
-
/* capture must never break the pipeline */
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* A global exception filter that captures every thrown request error and then hands
|
|
92
|
-
* off to Nest's `BaseExceptionFilter`, so the HTTP response is exactly what Nest
|
|
93
|
-
* would have produced (no behaviour change).
|
|
94
|
-
*/
|
|
95
|
-
@Catch()
|
|
96
|
-
export class FixbackExceptionFilter extends BaseExceptionFilter {
|
|
97
|
-
catch(exception: unknown, host: ArgumentsHost): void {
|
|
98
|
-
captureNestException(exception, host);
|
|
99
|
-
super.catch(exception, host);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The Fixback NestJS module. Register with `imports: [FixbackModule.forRoot()]`; it
|
|
105
|
-
* installs the global {@link FixbackExceptionFilter} and applies the request-context
|
|
106
|
-
* middleware to every route.
|
|
107
|
-
*/
|
|
108
|
-
@Module({})
|
|
109
|
-
export class FixbackModule implements NestModule {
|
|
110
|
-
static forRoot(): DynamicModule {
|
|
111
|
-
return {
|
|
112
|
-
module: FixbackModule,
|
|
113
|
-
providers: [{ provide: APP_FILTER, useClass: FixbackExceptionFilter }],
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
configure(consumer: MiddlewareConsumer): void {
|
|
118
|
-
const requestIdHeader = getClient()?.requestIdHeader;
|
|
119
|
-
consumer
|
|
120
|
-
.apply(createRequestContextMiddleware(requestIdHeader ? { requestIdHeader } : {}))
|
|
121
|
-
.forRoutes("*");
|
|
122
|
-
}
|
|
123
|
-
}
|
package/src/process.test.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { installProcessHandlers, type ProcessLike } from "./process";
|
|
4
|
-
|
|
5
|
-
type Listener = (...args: unknown[]) => void;
|
|
6
|
-
|
|
7
|
-
/** A tiny EventEmitter-shaped stand-in for `process` (never the real one, in tests). */
|
|
8
|
-
class FakeProcess implements ProcessLike {
|
|
9
|
-
private readonly map = new Map<string, Listener[]>();
|
|
10
|
-
on(event: string, listener: Listener): this {
|
|
11
|
-
this.map.set(event, [...(this.map.get(event) ?? []), listener]);
|
|
12
|
-
return this;
|
|
13
|
-
}
|
|
14
|
-
removeListener(event: string, listener: Listener): this {
|
|
15
|
-
this.map.set(event, (this.map.get(event) ?? []).filter((l) => l !== listener));
|
|
16
|
-
return this;
|
|
17
|
-
}
|
|
18
|
-
listeners(event: string): Listener[] {
|
|
19
|
-
return [...(this.map.get(event) ?? [])];
|
|
20
|
-
}
|
|
21
|
-
emit(event: string, ...args: unknown[]): void {
|
|
22
|
-
for (const l of [...(this.map.get(event) ?? [])]) l(...args);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function fakeSink() {
|
|
27
|
-
const captures: Array<{ error: unknown; handled?: boolean }> = [];
|
|
28
|
-
let flushes = 0;
|
|
29
|
-
return {
|
|
30
|
-
captures,
|
|
31
|
-
get flushes() {
|
|
32
|
-
return flushes;
|
|
33
|
-
},
|
|
34
|
-
sink: {
|
|
35
|
-
captureException(error: unknown, context?: { handled?: boolean }) {
|
|
36
|
-
captures.push({ error, handled: context?.handled });
|
|
37
|
-
},
|
|
38
|
-
flush: async () => {
|
|
39
|
-
flushes += 1;
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
46
|
-
|
|
47
|
-
describe("installProcessHandlers", () => {
|
|
48
|
-
it("adds an uncaughtException and unhandledRejection listener, and uninstall removes them", () => {
|
|
49
|
-
const proc = new FakeProcess();
|
|
50
|
-
const { sink } = fakeSink();
|
|
51
|
-
const uninstall = installProcessHandlers(
|
|
52
|
-
sink,
|
|
53
|
-
{ captureUncaughtException: true, captureUnhandledRejection: true },
|
|
54
|
-
{ process: proc, onFatalError: () => {} },
|
|
55
|
-
);
|
|
56
|
-
expect(proc.listeners("uncaughtException")).toHaveLength(1);
|
|
57
|
-
expect(proc.listeners("unhandledRejection")).toHaveLength(1);
|
|
58
|
-
uninstall();
|
|
59
|
-
expect(proc.listeners("uncaughtException")).toHaveLength(0);
|
|
60
|
-
expect(proc.listeners("unhandledRejection")).toHaveLength(0);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("installs only the handlers the gates enable", () => {
|
|
64
|
-
const proc = new FakeProcess();
|
|
65
|
-
const { sink } = fakeSink();
|
|
66
|
-
installProcessHandlers(
|
|
67
|
-
sink,
|
|
68
|
-
{ captureUncaughtException: false, captureUnhandledRejection: true },
|
|
69
|
-
{ process: proc, onFatalError: () => {} },
|
|
70
|
-
);
|
|
71
|
-
expect(proc.listeners("uncaughtException")).toHaveLength(0);
|
|
72
|
-
expect(proc.listeners("unhandledRejection")).toHaveLength(1);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("captures an uncaught exception as handled:false", () => {
|
|
76
|
-
const proc = new FakeProcess();
|
|
77
|
-
const { sink, captures } = fakeSink();
|
|
78
|
-
installProcessHandlers(
|
|
79
|
-
sink,
|
|
80
|
-
{ captureUncaughtException: true, captureUnhandledRejection: false },
|
|
81
|
-
{ process: proc, onFatalError: () => {} },
|
|
82
|
-
);
|
|
83
|
-
proc.emit("uncaughtException", new Error("crash"), "uncaughtException");
|
|
84
|
-
expect(captures).toHaveLength(1);
|
|
85
|
-
expect((captures[0]!.error as Error).message).toBe("crash");
|
|
86
|
-
expect(captures[0]!.handled).toBe(false);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("preserves the default crash — flushes then calls onFatalError — when it is the only listener", async () => {
|
|
90
|
-
const proc = new FakeProcess();
|
|
91
|
-
const { sink } = fakeSink();
|
|
92
|
-
const onFatalError = vi.fn();
|
|
93
|
-
installProcessHandlers(
|
|
94
|
-
sink,
|
|
95
|
-
{ captureUncaughtException: true, captureUnhandledRejection: false },
|
|
96
|
-
{ process: proc, onFatalError },
|
|
97
|
-
);
|
|
98
|
-
const boom = new Error("boom");
|
|
99
|
-
proc.emit("uncaughtException", boom);
|
|
100
|
-
await tick();
|
|
101
|
-
expect(onFatalError).toHaveBeenCalledTimes(1);
|
|
102
|
-
expect(onFatalError).toHaveBeenCalledWith(boom);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("does NOT change exit behaviour when the app has its own uncaughtException listener", async () => {
|
|
106
|
-
const proc = new FakeProcess();
|
|
107
|
-
const { sink, captures } = fakeSink();
|
|
108
|
-
const onFatalError = vi.fn();
|
|
109
|
-
installProcessHandlers(
|
|
110
|
-
sink,
|
|
111
|
-
{ captureUncaughtException: true, captureUnhandledRejection: false },
|
|
112
|
-
{ process: proc, onFatalError },
|
|
113
|
-
);
|
|
114
|
-
// The host installed its own handler — it owns the exit decision.
|
|
115
|
-
proc.on("uncaughtException", () => {});
|
|
116
|
-
proc.emit("uncaughtException", new Error("x"));
|
|
117
|
-
await tick();
|
|
118
|
-
expect(captures).toHaveLength(1); // still captured
|
|
119
|
-
expect(onFatalError).not.toHaveBeenCalled(); // but we never force an exit
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("captures an unhandled rejection as handled:false and never forces an exit (story 5)", async () => {
|
|
123
|
-
const proc = new FakeProcess();
|
|
124
|
-
const { sink, captures } = fakeSink();
|
|
125
|
-
const onFatalError = vi.fn();
|
|
126
|
-
installProcessHandlers(
|
|
127
|
-
sink,
|
|
128
|
-
{ captureUncaughtException: false, captureUnhandledRejection: true },
|
|
129
|
-
{ process: proc, onFatalError },
|
|
130
|
-
);
|
|
131
|
-
proc.emit("unhandledRejection", new Error("rejected"), Promise.resolve());
|
|
132
|
-
await tick();
|
|
133
|
-
expect(captures).toHaveLength(1);
|
|
134
|
-
expect((captures[0]!.error as Error).message).toBe("rejected");
|
|
135
|
-
expect(captures[0]!.handled).toBe(false);
|
|
136
|
-
expect(onFatalError).not.toHaveBeenCalled();
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("never throws out of the handler when capture itself fails", () => {
|
|
140
|
-
const proc = new FakeProcess();
|
|
141
|
-
const throwingSink = {
|
|
142
|
-
captureException() {
|
|
143
|
-
throw new Error("capture blew up");
|
|
144
|
-
},
|
|
145
|
-
flush: async () => {},
|
|
146
|
-
};
|
|
147
|
-
installProcessHandlers(
|
|
148
|
-
throwingSink,
|
|
149
|
-
{ captureUncaughtException: true, captureUnhandledRejection: false },
|
|
150
|
-
{ process: proc, onFatalError: () => {} },
|
|
151
|
-
);
|
|
152
|
-
expect(() => proc.emit("uncaughtException", new Error("x"))).not.toThrow();
|
|
153
|
-
});
|
|
154
|
-
});
|