@effectstream/log 0.100.22 → 0.100.23
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/package.json +2 -2
- package/src/mod.ts +4 -0
- package/src/process-handlers.ts +62 -0
- package/test/process-handlers.test.ts +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/log",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.23",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": "./src/mod.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@effectstream/utils": "0.100.
|
|
16
|
+
"@effectstream/utils": "0.100.23",
|
|
17
17
|
"@opentelemetry/api": "^1.9.0",
|
|
18
18
|
"@opentelemetry/api-logs": "0.56.0",
|
|
19
19
|
"@opentelemetry/exporter-logs-otlp-http": "0.56.0",
|
package/src/mod.ts
CHANGED
|
@@ -11,6 +11,10 @@ export {
|
|
|
11
11
|
} from "./const.ts";
|
|
12
12
|
export { attachTransport } from "./tslog.ts";
|
|
13
13
|
export { DefaultLogLevels } from "./tslog.ts";
|
|
14
|
+
export {
|
|
15
|
+
buildUnhandledRejectionHandler,
|
|
16
|
+
installUnhandledRejectionLogger,
|
|
17
|
+
} from "./process-handlers.ts";
|
|
14
18
|
|
|
15
19
|
// re-exporting this
|
|
16
20
|
// so that we don't need to re-import opentelemetry in every component
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
2
|
+
import { log } from "./mod.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Install a process-wide `unhandledRejection` handler.
|
|
6
|
+
*
|
|
7
|
+
* By default the handler logs the error at ERROR and re-crashes the process
|
|
8
|
+
* via `process.exit(1)` — preserving the "let it crash" semantic that Node /
|
|
9
|
+
* Bun apply when no handler is registered. The point of installing this
|
|
10
|
+
* handler is *visibility*: without it, Bun crashes silently with no log line
|
|
11
|
+
* about what the rejection actually was.
|
|
12
|
+
*
|
|
13
|
+
* Pass `shouldSurvive` to opt specific errors out of the crash — useful for
|
|
14
|
+
* known-transient failures from external systems (e.g. a pg pool ECONNRESET
|
|
15
|
+
* from a serverless Postgres cold-start). Surviving errors are logged at
|
|
16
|
+
* WARN; non-surviving ones at ERROR before exit.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent: only the first call in this module registers a listener.
|
|
19
|
+
*/
|
|
20
|
+
let unhandledRejectionLoggerInstalled = false;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the `unhandledRejection` handler in isolation from `process.on`.
|
|
24
|
+
*
|
|
25
|
+
* The fatal-exit step is injected (`onFatal`, defaulting to `process.exit`) so
|
|
26
|
+
* the decision — *does this rejection crash the process or survive?* — can be
|
|
27
|
+
* exercised in a test without actually killing the test runner. This is the
|
|
28
|
+
* unit-level guard for the zombie failure mode: anything that is **not**
|
|
29
|
+
* classified survivable (e.g. a generic error escaping the effection root)
|
|
30
|
+
* must hit `onFatal`, never just log-and-continue.
|
|
31
|
+
*
|
|
32
|
+
* @returns the number of `onFatal` calls would-be (for the install path this is
|
|
33
|
+
* ignored; tests inspect their injected spy).
|
|
34
|
+
*/
|
|
35
|
+
export function buildUnhandledRejectionHandler(
|
|
36
|
+
componentName: string,
|
|
37
|
+
shouldSurvive?: (reason: unknown) => boolean,
|
|
38
|
+
onFatal: (code: number) => void = (code) => process.exit(code),
|
|
39
|
+
): (reason: unknown) => void {
|
|
40
|
+
return (reason: unknown) => {
|
|
41
|
+
const survive = shouldSurvive?.(reason) ?? false;
|
|
42
|
+
log.remote(
|
|
43
|
+
componentName,
|
|
44
|
+
["unhandledRejection"],
|
|
45
|
+
survive ? SeverityNumber.WARN : SeverityNumber.ERROR,
|
|
46
|
+
(log) => log(reason),
|
|
47
|
+
);
|
|
48
|
+
if (!survive) onFatal(1);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function installUnhandledRejectionLogger(
|
|
53
|
+
componentName: string,
|
|
54
|
+
shouldSurvive?: (reason: unknown) => boolean,
|
|
55
|
+
): void {
|
|
56
|
+
if (unhandledRejectionLoggerInstalled) return;
|
|
57
|
+
unhandledRejectionLoggerInstalled = true;
|
|
58
|
+
process.on(
|
|
59
|
+
"unhandledRejection",
|
|
60
|
+
buildUnhandledRejectionHandler(componentName, shouldSurvive),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Guards the zombie failure mode: the `unhandledRejection` handler must crash
|
|
2
|
+
// the process for anything it cannot prove survivable. The `survive` handler
|
|
3
|
+
// (logs WARN, no exit) is the dangerous branch — if a rejection that escapes
|
|
4
|
+
// the effection root were mis-classified as survivable, the whole effection
|
|
5
|
+
// tree would be halted while the process stayed alive. These tests pin the
|
|
6
|
+
// decision boundary using an injected `onFatal` spy instead of `process.exit`.
|
|
7
|
+
|
|
8
|
+
import { test, expect } from "bun:test";
|
|
9
|
+
import { buildUnhandledRejectionHandler } from "../src/mod.ts";
|
|
10
|
+
|
|
11
|
+
const fatalSpy = () => {
|
|
12
|
+
const calls: number[] = [];
|
|
13
|
+
return { fn: (code: number) => calls.push(code), calls };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("no shouldSurvive predicate => every rejection is fatal", () => {
|
|
17
|
+
const { fn, calls } = fatalSpy();
|
|
18
|
+
const handle = buildUnhandledRejectionHandler("test-component", undefined, fn);
|
|
19
|
+
|
|
20
|
+
handle(new Error("effection root died"));
|
|
21
|
+
|
|
22
|
+
expect(calls).toEqual([1]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("shouldSurvive=false => fatal (process exits)", () => {
|
|
26
|
+
const { fn, calls } = fatalSpy();
|
|
27
|
+
const handle = buildUnhandledRejectionHandler(
|
|
28
|
+
"test-component",
|
|
29
|
+
() => false,
|
|
30
|
+
fn,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
handle(new Error("real logic bug"));
|
|
34
|
+
|
|
35
|
+
expect(calls).toEqual([1]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("shouldSurvive=true => survives (no exit)", () => {
|
|
39
|
+
const { fn, calls } = fatalSpy();
|
|
40
|
+
const handle = buildUnhandledRejectionHandler(
|
|
41
|
+
"test-component",
|
|
42
|
+
() => true,
|
|
43
|
+
fn,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
handle({ code: "ECONNRESET", message: "transient pg blip" });
|
|
47
|
+
|
|
48
|
+
expect(calls).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("predicate decides per-rejection: only the survivable one is spared", () => {
|
|
52
|
+
const { fn, calls } = fatalSpy();
|
|
53
|
+
// Survive only objects carrying a `transient` marker; anything else is fatal.
|
|
54
|
+
const handle = buildUnhandledRejectionHandler(
|
|
55
|
+
"test-component",
|
|
56
|
+
(reason) => Boolean((reason as { transient?: boolean })?.transient),
|
|
57
|
+
fn,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
handle({ transient: true }); // survivable -> no exit
|
|
61
|
+
expect(calls).toEqual([]);
|
|
62
|
+
|
|
63
|
+
handle(new Error("generic")); // not survivable -> exit
|
|
64
|
+
expect(calls).toEqual([1]);
|
|
65
|
+
});
|