@effectstream/log 0.100.20 → 0.100.21
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 -1
- package/src/process-handlers.ts +31 -7
- 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.21",
|
|
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.21",
|
|
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,7 +11,10 @@ export {
|
|
|
11
11
|
} from "./const.ts";
|
|
12
12
|
export { attachTransport } from "./tslog.ts";
|
|
13
13
|
export { DefaultLogLevels } from "./tslog.ts";
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
buildUnhandledRejectionHandler,
|
|
16
|
+
installUnhandledRejectionLogger,
|
|
17
|
+
} from "./process-handlers.ts";
|
|
15
18
|
|
|
16
19
|
// re-exporting this
|
|
17
20
|
// so that we don't need to re-import opentelemetry in every component
|
package/src/process-handlers.ts
CHANGED
|
@@ -19,13 +19,25 @@ import { log } from "./mod.ts";
|
|
|
19
19
|
*/
|
|
20
20
|
let unhandledRejectionLoggerInstalled = false;
|
|
21
21
|
|
|
22
|
-
|
|
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(
|
|
23
36
|
componentName: string,
|
|
24
37
|
shouldSurvive?: (reason: unknown) => boolean,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
process.on("unhandledRejection", (reason: unknown) => {
|
|
38
|
+
onFatal: (code: number) => void = (code) => process.exit(code),
|
|
39
|
+
): (reason: unknown) => void {
|
|
40
|
+
return (reason: unknown) => {
|
|
29
41
|
const survive = shouldSurvive?.(reason) ?? false;
|
|
30
42
|
log.remote(
|
|
31
43
|
componentName,
|
|
@@ -33,6 +45,18 @@ export function installUnhandledRejectionLogger(
|
|
|
33
45
|
survive ? SeverityNumber.WARN : SeverityNumber.ERROR,
|
|
34
46
|
(log) => log(reason),
|
|
35
47
|
);
|
|
36
|
-
if (!survive)
|
|
37
|
-
}
|
|
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
|
+
);
|
|
38
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
|
+
});
|