pure-effect 0.12.0 → 0.13.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/CHANGELOG.md +148 -0
- package/README.md +109 -48
- package/index.d.ts +18 -11
- package/index.js +130 -71
- package/package.json +30 -2
- package/.prettierrc +0 -12
- package/CLAUDE.md +0 -205
- package/examples/opentelemetry-example.js +0 -164
- package/examples/recording-example.js +0 -104
- package/jsconfig.json +0 -13
- package/test/all.js +0 -2446
- package/test/types.test-d.ts +0 -335
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
|
4
|
-
import { configureEffect } from '../index.js';
|
|
5
|
-
|
|
6
|
-
/** @import { Tracer } from "@opentelemetry/api" */
|
|
7
|
-
/** @import { EffectConfiguration, RunWrapper, StepRunner } from "../index.js" */
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Reference wiring for OpenTelemetry spans
|
|
11
|
-
*
|
|
12
|
-
* `onRun` opens one span per `runEffect` call and `onStep` opens a child span per Command,
|
|
13
|
-
* which is the whole integration: the hooks already sit exactly where spans want to start
|
|
14
|
-
* and end.
|
|
15
|
-
*
|
|
16
|
-
* Importing this file does nothing on its own. Call `startTelemetrySdk()` to stand up an
|
|
17
|
-
* exporter, and `enableTelemetry()` to install the hooks, or `telemetryHooks()` to get them
|
|
18
|
-
* as a configuration you can merge with others.
|
|
19
|
-
*
|
|
20
|
-
* No Command input or output is put on a span, deliberately. Spans carry names, timings, and
|
|
21
|
-
* status; values belong in a trace, where `recorder`'s `redact` strips them before they leave the
|
|
22
|
-
* process and what remains can be replayed. Copying values onto spans would duplicate them into
|
|
23
|
-
* the one place they cannot be redacted after the fact, and an initial input routinely holds a
|
|
24
|
-
* password, a token, or a card number.
|
|
25
|
-
*
|
|
26
|
-
* These hooks use the raw `onStep` and `onRun` rather than `observe`, because a span has to wrap
|
|
27
|
-
* `op`: `startActiveSpan` is what makes each Command span a child of the run's span. An integration
|
|
28
|
-
* that only watches, such as metrics or logging, should use `observe` instead and cannot break a
|
|
29
|
-
* flow by throwing.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @typedef {Object} TelemetryOptions
|
|
34
|
-
* @property {string} [tracerName] - Defaults to `'pure-effect'`.
|
|
35
|
-
* @property {Tracer} [tracer] - Supply a tracer directly instead of resolving one by name. Useful in tests.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* A span status message, without putting a Command's data on the span.
|
|
40
|
-
*
|
|
41
|
-
* Only a string error or an Error's message is used. A structured error object can hold the very
|
|
42
|
-
* values this file keeps off spans, and `String(someObject)` would say nothing useful anyway.
|
|
43
|
-
*
|
|
44
|
-
* @param {any} error
|
|
45
|
-
* @returns {string | undefined}
|
|
46
|
-
*/
|
|
47
|
-
const statusMessage = (error) =>
|
|
48
|
-
error instanceof Error ? error.message : typeof error === 'string' ? error : undefined;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Builds the tracing hooks without installing them.
|
|
52
|
-
*
|
|
53
|
-
* Returning a configuration rather than calling `configureEffect` is what lets tracing coexist with
|
|
54
|
-
* recording: there is one slot per hook, so both concerns go into a single `configureEffect` call,
|
|
55
|
-
* which merges them. A helper that installed itself would silently replace whatever was there.
|
|
56
|
-
*
|
|
57
|
-
* @param {TelemetryOptions} [options]
|
|
58
|
-
* @returns {EffectConfiguration}
|
|
59
|
-
*/
|
|
60
|
-
export function telemetryHooks(options = {}) {
|
|
61
|
-
const { tracerName = 'pure-effect', tracer = trace.getTracer(tracerName) } = options;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The root span is named after the flow, so traces are distinguishable in a UI. Every span
|
|
65
|
-
* ends in a `finally`, so a thrown Command cannot leak one.
|
|
66
|
-
* @type {RunWrapper}
|
|
67
|
-
*/
|
|
68
|
-
const onRun = (effect, pipeline, flowName) =>
|
|
69
|
-
tracer.startActiveSpan(flowName || 'Effect Pipeline', async (rootSpan) => {
|
|
70
|
-
rootSpan.setAttribute('effect.flow', flowName || '');
|
|
71
|
-
try {
|
|
72
|
-
const result = await pipeline();
|
|
73
|
-
if (result.type === 'Failure') {
|
|
74
|
-
rootSpan.setStatus({ code: SpanStatusCode.ERROR, message: statusMessage(result.error) });
|
|
75
|
-
} else {
|
|
76
|
-
rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
77
|
-
}
|
|
78
|
-
return result;
|
|
79
|
-
} catch (/** @type any */ err) {
|
|
80
|
-
rootSpan.recordException(err);
|
|
81
|
-
rootSpan.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
|
|
82
|
-
throw err;
|
|
83
|
-
} finally {
|
|
84
|
-
rootSpan.end();
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* `op` has to be awaited and its result returned. Returning a value without calling `op` is how
|
|
90
|
-
* replay suppresses I/O, so a hook that forgot to call it would silently stop every Command.
|
|
91
|
-
* @type {StepRunner}
|
|
92
|
-
*/
|
|
93
|
-
const onStep = (name, type, op) =>
|
|
94
|
-
tracer.startActiveSpan(name, async (span) => {
|
|
95
|
-
span.setAttribute('effect.type', type);
|
|
96
|
-
try {
|
|
97
|
-
const result = await op();
|
|
98
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
99
|
-
return result;
|
|
100
|
-
} catch (/** @type any */ err) {
|
|
101
|
-
span.recordException(err);
|
|
102
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
|
|
103
|
-
throw err;
|
|
104
|
-
} finally {
|
|
105
|
-
span.end();
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
return { onRun, onStep };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Installs tracing on its own. Pass `telemetryHooks()` to `configureEffect` alongside anything else
|
|
114
|
-
* that needs the hooks instead, since a bare call here replaces whatever was configured before.
|
|
115
|
-
*
|
|
116
|
-
* @param {TelemetryOptions} [options]
|
|
117
|
-
*/
|
|
118
|
-
export function enableTelemetry(options) {
|
|
119
|
-
configureEffect(telemetryHooks(options));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Starts a Node SDK exporting over OTLP, and returns it so a caller can shut it down.
|
|
124
|
-
*
|
|
125
|
-
* Kept out of module scope on purpose: importing a reference file should not open exporters, spawn
|
|
126
|
-
* background work, or register process handlers. The SDK is imported dynamically so the hooks above
|
|
127
|
-
* stay usable wherever `@opentelemetry/api` runs, without pulling the Node SDK in behind them.
|
|
128
|
-
*
|
|
129
|
-
* @param {Object} [options]
|
|
130
|
-
* @param {string} [options.serviceName]
|
|
131
|
-
* @param {string} [options.url] - OTLP HTTP endpoint.
|
|
132
|
-
* @param {boolean} [options.handleSigterm] - Flush and exit on SIGTERM. Off by default, since
|
|
133
|
-
* installing a process handler is the application's decision, not a library's.
|
|
134
|
-
*/
|
|
135
|
-
export async function startTelemetrySdk(options = {}) {
|
|
136
|
-
const { serviceName = 'pure-effect', url = 'http://localhost:4318/v1/traces', handleSigterm = false } = options;
|
|
137
|
-
const [{ NodeSDK }, { OTLPTraceExporter }, { SimpleSpanProcessor }] = await Promise.all([
|
|
138
|
-
import('@opentelemetry/sdk-node'),
|
|
139
|
-
import('@opentelemetry/exporter-trace-otlp-proto'),
|
|
140
|
-
import('@opentelemetry/sdk-trace-base')
|
|
141
|
-
]);
|
|
142
|
-
|
|
143
|
-
const traceExporter = new OTLPTraceExporter({ url });
|
|
144
|
-
// `spanProcessors` replaced the deprecated singular `spanProcessor`, and passing an exporter
|
|
145
|
-
// alongside a processor that already wraps it was redundant.
|
|
146
|
-
const sdk = new NodeSDK({
|
|
147
|
-
serviceName,
|
|
148
|
-
spanProcessors: [new SimpleSpanProcessor(traceExporter)],
|
|
149
|
-
instrumentations: []
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
sdk.start();
|
|
153
|
-
|
|
154
|
-
if (handleSigterm) {
|
|
155
|
-
process.on('SIGTERM', () => {
|
|
156
|
-
sdk.shutdown()
|
|
157
|
-
.then(() => console.log('Tracing terminated'))
|
|
158
|
-
.catch((error) => console.log('Error terminating tracing', error))
|
|
159
|
-
.finally(() => process.exit(0));
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return sdk;
|
|
164
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
-
import { configureEffect, recorder } from '../index.js';
|
|
5
|
-
|
|
6
|
-
/** @import { EffectConfiguration, RunWrapper, StepRunner, CommandInterceptor, TraceLog, SuccessState, FailureState } from "../index.js" */
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Example wiring for recording every run of an application.
|
|
10
|
-
* `recordEffect` covers tests and scripts, where one call site holds the whole run. This
|
|
11
|
-
* exists for the other case: recording without touching any call site.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* @typedef {Object} RecordingStore
|
|
16
|
-
* @property {ReturnType<typeof recorder>} rec
|
|
17
|
-
* @property {string} [flowName]
|
|
18
|
-
* @property {any} initialInput
|
|
19
|
-
* @property {any} context
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @typedef {Object} RecordingOptions
|
|
24
|
-
* @property {(trace: TraceLog) => Promise<void> | void} [sink] - Receives a finished trace. Writing to
|
|
25
|
-
* S3 or a database fit here. Prefer not to await slow I/O inside
|
|
26
|
-
* a request: hand the trace to a queue or a background task instead.
|
|
27
|
-
* @property {(value: any, name: string, kind: string) => any} [redact] - Runs before any value enters the
|
|
28
|
-
* trace, so nothing sensitive reaches the sink even in memory. It sees results, serialized errors,
|
|
29
|
-
* and the `initialInput` and `context` the trace stores, distinguished by `kind`.
|
|
30
|
-
* @property {number} [maxEntries] - Caps trace length; the overflow count is reported as `dropped`.
|
|
31
|
-
* @property {boolean} [stack] - Records stack traces for thrown errors.
|
|
32
|
-
* @property {(result: SuccessState<any> | FailureState<any>) => boolean} [keep] - Decides which runs
|
|
33
|
-
* reach the sink. Defaults to failures only. Return `true` always to keep everything, or
|
|
34
|
-
* sample successes with a probability check.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Builds the three hooks that record each run, without installing them.
|
|
39
|
-
*
|
|
40
|
-
* Returning a configuration rather than calling `configureEffect` is what lets recording coexist
|
|
41
|
-
* with tracing: there is one slot per hook, so both concerns go into a single `configureEffect` call,
|
|
42
|
-
* which merges them. A helper that installed itself would silently replace whatever was there.
|
|
43
|
-
*
|
|
44
|
-
* @param {RecordingOptions} [options]
|
|
45
|
-
* @returns {EffectConfiguration}
|
|
46
|
-
*/
|
|
47
|
-
export function recordingHooks(options = {}) {
|
|
48
|
-
const {
|
|
49
|
-
sink = async () => {},
|
|
50
|
-
redact,
|
|
51
|
-
maxEntries = 500,
|
|
52
|
-
stack,
|
|
53
|
-
keep = (result) => result.type === 'Failure'
|
|
54
|
-
} = options;
|
|
55
|
-
/** @type {AsyncLocalStorage<RecordingStore>} */
|
|
56
|
-
const scope = new AsyncLocalStorage();
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* One recorder per run, held in async-local scope so `onStep` can find the right one without a
|
|
60
|
-
* recorder being threaded through any business-logic signature. A single module-level recorder
|
|
61
|
-
* would interleave the steps of concurrent runs into one trace.
|
|
62
|
-
* @type {RunWrapper}
|
|
63
|
-
*/
|
|
64
|
-
const onRun = async (effect, pipeline, flowName) => {
|
|
65
|
-
const rec = recorder({ redact, maxEntries, stack });
|
|
66
|
-
/** @type {RecordingStore} */
|
|
67
|
-
const store = { rec, flowName, initialInput: /** @type {any} */ (effect).initialInput, context: undefined };
|
|
68
|
-
return scope.run(store, async () => {
|
|
69
|
-
const result = await pipeline();
|
|
70
|
-
if (keep(result)) await sink(rec.toTrace(store));
|
|
71
|
-
return result;
|
|
72
|
-
});
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Outside a recorded run there is no store, so the Command runs untouched.
|
|
77
|
-
* @type {StepRunner}
|
|
78
|
-
*/
|
|
79
|
-
const onStep = async (name, type, op) => {
|
|
80
|
-
const store = scope.getStore();
|
|
81
|
-
return store ? await store.rec.onStep(name, type, op) : await op();
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* `onRun` never sees the context, so it is captured from the first Command of the run.
|
|
86
|
-
* @type {CommandInterceptor}
|
|
87
|
-
*/
|
|
88
|
-
const onBeforeCommand = async (command, context) => {
|
|
89
|
-
const store = scope.getStore();
|
|
90
|
-
if (store && store.context === undefined) store.context = context;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
return { onRun, onStep, onBeforeCommand };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Installs recording on its own. Pass `recordingHooks()` to `configureEffect` alongside anything else
|
|
98
|
-
* that needs the hooks instead, since a bare call here replaces whatever was configured before.
|
|
99
|
-
*
|
|
100
|
-
* @param {RecordingOptions} [options]
|
|
101
|
-
*/
|
|
102
|
-
export function enableRecording(options) {
|
|
103
|
-
configureEffect(recordingHooks(options));
|
|
104
|
-
}
|
package/jsconfig.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"allowJs": true,
|
|
4
|
-
"checkJs": true,
|
|
5
|
-
"strict": true,
|
|
6
|
-
"noEmit": true,
|
|
7
|
-
"target": "es2022",
|
|
8
|
-
"module": "nodenext",
|
|
9
|
-
"moduleResolution": "nodenext",
|
|
10
|
-
"lib": ["es2022", "dom"]
|
|
11
|
-
},
|
|
12
|
-
"include": ["index.js", "test/all.js", "test/types.test-d.ts", "examples/*.js"]
|
|
13
|
-
}
|