pure-effect 0.14.0 → 0.16.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.
@@ -0,0 +1,173 @@
1
+ // @ts-check
2
+
3
+ import { trace, SpanStatusCode } from '@opentelemetry/api';
4
+ import { configureEffect } from 'pure-effect';
5
+
6
+ /** @import { Tracer } from "@opentelemetry/api" */
7
+ /** @import { EffectConfiguration, ParallelDecision, RunWrapper, StepRunner } from "pure-effect" */
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
+ * A span has to wrap `op`: `startActiveSpan` is what makes each Command span a child of the run's
27
+ * span. Anything these hooks throw reaches the flow. A throw after `op` succeeded rejects the run
28
+ * without running the Command again, one before `op` is called counts as the Command failing, and
29
+ * one from `onRun` rejects the run. An integration that only watches, such as metrics or logging,
30
+ * should catch its own errors so it cannot change a run's outcome.
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} TelemetryOptions
35
+ * @property {string} [tracerName] - Defaults to `'pure-effect'`.
36
+ * @property {Tracer} [tracer] - Supply a tracer directly instead of resolving one by name. Useful in tests.
37
+ */
38
+
39
+ /**
40
+ * A span status message, without putting a Command's data on the span.
41
+ *
42
+ * Only a string error or an Error's message is used. A structured error object can hold the very
43
+ * values this file keeps off spans, and `String(someObject)` would say nothing useful anyway.
44
+ *
45
+ * @param {any} error
46
+ * @returns {string | undefined}
47
+ */
48
+ const statusMessage = (error) =>
49
+ error instanceof Error ? error.message : typeof error === 'string' ? error : undefined;
50
+
51
+ /**
52
+ * Builds the tracing hooks without installing them.
53
+ *
54
+ * Returning a configuration leaves the caller in charge of where tracing sits relative to recording:
55
+ * passed to one `configureEffect` call together, or installed as separate layers, the two merge the
56
+ * same way, and the caller holds the function that removes each.
57
+ *
58
+ * @param {TelemetryOptions} [options]
59
+ * @returns {EffectConfiguration}
60
+ */
61
+ export function telemetryHooks(options = {}) {
62
+ const { tracerName = 'pure-effect', tracer = trace.getTracer(tracerName) } = options;
63
+
64
+ /**
65
+ * The root span is named after the flow, so traces are distinguishable in a UI. Every span
66
+ * ends in a `finally`, so a thrown Command cannot leak one.
67
+ * @type {RunWrapper}
68
+ */
69
+ const onRun = (effect, pipeline, flowName) =>
70
+ tracer.startActiveSpan(flowName || 'Effect Pipeline', async (rootSpan) => {
71
+ rootSpan.setAttribute('effect.flow', flowName || '');
72
+ try {
73
+ const result = await pipeline();
74
+ if (result.type === 'Failure') {
75
+ rootSpan.setStatus({ code: SpanStatusCode.ERROR, message: statusMessage(result.error) });
76
+ } else {
77
+ rootSpan.setStatus({ code: SpanStatusCode.OK });
78
+ }
79
+ return result;
80
+ } catch (/** @type any */ err) {
81
+ rootSpan.recordException(err);
82
+ rootSpan.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
83
+ throw err;
84
+ } finally {
85
+ rootSpan.end();
86
+ }
87
+ });
88
+
89
+ /**
90
+ * `op` has to be awaited and its result returned. Returning a value without calling `op` is how
91
+ * replay suppresses I/O, so a hook that forgot to call it would silently stop every Command.
92
+ * A Parallel's `op` returns its decision rather than throwing, even when a branch's own code threw,
93
+ * so a cancelled one is marked from the decision.
94
+ * @type {StepRunner}
95
+ */
96
+ const onStep = (name, type, op) =>
97
+ tracer.startActiveSpan(name, async (span) => {
98
+ span.setAttribute('effect.type', type);
99
+ try {
100
+ const result = await op();
101
+ const decision = type === 'Parallel' ? /** @type {ParallelDecision} */ (result) : undefined;
102
+ if (decision?.cancelled) {
103
+ const by = decision.branch === null ? 'an enclosing Parallel' : `branch ${decision.branch}`;
104
+ span.setStatus({ code: SpanStatusCode.ERROR, message: `Cancelled by ${by}` });
105
+ } else span.setStatus({ code: SpanStatusCode.OK });
106
+ return result;
107
+ } catch (/** @type any */ err) {
108
+ span.recordException(err);
109
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
110
+ throw err;
111
+ } finally {
112
+ span.end();
113
+ }
114
+ });
115
+
116
+ return { onRun, onStep };
117
+ }
118
+
119
+ /**
120
+ * Installs tracing as its own layer on top of whatever is configured, and returns the function that
121
+ * removes it again. Call it once per process: layers stack, so a second call opens a second span per
122
+ * run and per Command. A setup path that can run again (a reloading dev server, a per-suite
123
+ * bootstrap) should hold the remover and call it before installing a fresh layer.
124
+ *
125
+ * @param {TelemetryOptions} [options]
126
+ */
127
+ export function enableTelemetry(options) {
128
+ return configureEffect(telemetryHooks(options));
129
+ }
130
+
131
+ /**
132
+ * Starts a Node SDK exporting over OTLP, and returns it so a caller can shut it down.
133
+ *
134
+ * Kept out of module scope on purpose: importing a reference file should not open exporters, spawn
135
+ * background work, or register process handlers. The SDK is imported dynamically so the hooks above
136
+ * stay usable wherever `@opentelemetry/api` runs, without pulling the Node SDK in behind them.
137
+ *
138
+ * @param {Object} [options]
139
+ * @param {string} [options.serviceName]
140
+ * @param {string} [options.url] - OTLP HTTP endpoint.
141
+ * @param {boolean} [options.handleSigterm] - Flush and exit on SIGTERM. Off by default, since
142
+ * installing a process handler is the application's decision, not a library's.
143
+ */
144
+ export async function startTelemetrySdk(options = {}) {
145
+ const { serviceName = 'pure-effect', url = 'http://localhost:4318/v1/traces', handleSigterm = false } = options;
146
+ const [{ NodeSDK }, { OTLPTraceExporter }, { SimpleSpanProcessor }] = await Promise.all([
147
+ import('@opentelemetry/sdk-node'),
148
+ import('@opentelemetry/exporter-trace-otlp-proto'),
149
+ import('@opentelemetry/sdk-trace-base')
150
+ ]);
151
+
152
+ const traceExporter = new OTLPTraceExporter({ url });
153
+ // `spanProcessors` replaced the deprecated singular `spanProcessor`, and passing an exporter
154
+ // alongside a processor that already wraps it was redundant.
155
+ const sdk = new NodeSDK({
156
+ serviceName,
157
+ spanProcessors: [new SimpleSpanProcessor(traceExporter)],
158
+ instrumentations: []
159
+ });
160
+
161
+ sdk.start();
162
+
163
+ if (handleSigterm) {
164
+ process.on('SIGTERM', () => {
165
+ sdk.shutdown()
166
+ .then(() => console.log('Tracing terminated'))
167
+ .catch((error) => console.log('Error terminating tracing', error))
168
+ .finally(() => process.exit(0));
169
+ });
170
+ }
171
+
172
+ return sdk;
173
+ }
@@ -0,0 +1,170 @@
1
+ // @ts-check
2
+
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import { configureEffect, recorder, Failure } from 'pure-effect';
5
+
6
+ /** @import { Effect, EffectConfiguration, RunWrapper, StepRunner, CommandInterceptor, TraceEntry, TraceLog, SuccessState, FailureState } from "pure-effect" */
7
+
8
+ /**
9
+ * Records every run of an application without touching any call site, one trace per run. `recordEffect` covers
10
+ * tests and scripts, where one call holds the whole run.
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} RecordingStore
15
+ * @property {ReturnType<typeof recorder>} rec
16
+ * @property {TraceLog} head - The trace's own fields, packaged as the run starts rather than after it
17
+ * @property {boolean} contextCaptured
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} RecordingOptions
22
+ * @property {(trace: TraceLog) => Promise<void> | void} [sink] - Receives each kept trace, to write to S3 or a
23
+ * database. Hand slow writes to a queue rather than awaiting them inside a request.
24
+ * @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value before it enters the
25
+ * trace: results, serialized errors, and the stored `initialInput` and `context`, told apart by `kind`.
26
+ * It receives a copy, so changing it in place never reaches the run.
27
+ * @property {number} [maxEntries] - Caps trace length, 500 by default; the overflow is reported as `dropped`.
28
+ * @property {boolean} [stack] - Records stack traces for thrown errors.
29
+ * @property {(result: SuccessState<any> | FailureState<any>) => boolean} [keep] - Decides which runs reach the sink:
30
+ * failures only by default. Return `true` to keep everything, or sample. A run whose own code threw is
31
+ * offered as a Failure carrying the thrown error.
32
+ * @property {(error: unknown, flowName?: string) => void} [onSinkError] - Receives an error thrown by `keep` or
33
+ * `sink`. Defaults to `console.error`.
34
+ * @property {(message: string, flowName?: string) => void} [onWarning] - Receives a warning, once per flow, about a
35
+ * kept trace that will not replay as recorded: it has no input, `maxEntries` cut it short, or some of its
36
+ * steps are named 'anonymous'. Defaults to `console.warn`.
37
+ */
38
+
39
+ /**
40
+ * Builds the three hooks that record each run, without installing them, so the caller decides where recording sits
41
+ * among its other hooks.
42
+ *
43
+ * @param {RecordingOptions} [options]
44
+ * @returns {EffectConfiguration}
45
+ */
46
+ export function recordingHooks(options = {}) {
47
+ const {
48
+ sink = async () => {},
49
+ redact,
50
+ maxEntries = 500,
51
+ stack,
52
+ keep = (result) => result.type === 'Failure',
53
+ onSinkError = (error, flowName) => console.error(`Recording failed for '${flowName || 'flow'}':`, error),
54
+ onWarning = (message) => console.warn(message)
55
+ } = options;
56
+ // One recorder per run, found through async-local scope: a single shared recorder would mix concurrent runs.
57
+ /** @type {AsyncLocalStorage<RecordingStore>} */
58
+ const scope = new AsyncLocalStorage();
59
+ /** Warnings already given, as `kind:flowName`. */
60
+ const warned = new Set();
61
+
62
+ /** @type {RunWrapper} */
63
+ const onRun = async (effect, pipeline, flowName) => {
64
+ const rec = recorder({ redact, maxEntries, stack });
65
+ // Packaged before the run, so a Command that changes its input cannot rewrite what the trace says it received.
66
+ const head = rec.toTrace({ flowName, initialInput: effect.initialInput });
67
+ return scope.run({ rec, head, contextCaptured: false }, async () => {
68
+ /** @type {{ result: SuccessState<any> | FailureState<any> } | { error: unknown }} */
69
+ let outcome;
70
+ try {
71
+ outcome = { result: await pipeline() };
72
+ } catch (error) {
73
+ outcome = { error };
74
+ }
75
+ // A run whose own code threw is kept as a Failure: it is the one most worth replaying.
76
+ const result = 'result' in outcome ? outcome.result : Failure(outcome.error);
77
+ // Recording never decides the outcome: a keep or sink that throws is reported, not returned.
78
+ try {
79
+ if (keep(result)) {
80
+ const { dropped = 0, trace } = rec.toTrace();
81
+ warnAboutReplay(flowName, effect, dropped, trace);
82
+ await sink({ ...head, dropped, trace });
83
+ }
84
+ } catch (error) {
85
+ reportSinkError(error, flowName);
86
+ }
87
+ if ('error' in outcome) throw outcome.error;
88
+ return outcome.result;
89
+ });
90
+ };
91
+
92
+ /**
93
+ * Warns, once per flow, about a kept trace that will not replay as recorded.
94
+ * @param {string} flowName
95
+ * @param {Effect<any, any, any>} effect
96
+ * @param {number} dropped
97
+ * @param {TraceEntry[]} trace
98
+ */
99
+ const warnAboutReplay = (flowName, effect, dropped, trace) => {
100
+ const warnOnce = (/** @type {string} */ kind, /** @type {string} */ message) => {
101
+ if (warned.has(`${kind}:${flowName}`)) return;
102
+ warned.add(`${kind}:${flowName}`);
103
+ onWarning(`Recording '${flowName || 'flow'}': ${message}`, flowName);
104
+ };
105
+ if (effect.initialInput === undefined) {
106
+ warnOnce(
107
+ 'input',
108
+ 'the flow carries no input, so its traces hold none and timeTravel rebuilds it from undefined. ' +
109
+ 'Build the outermost flow with effectPipe, even as a one-step pipeline.'
110
+ );
111
+ }
112
+ if (dropped > 0) {
113
+ warnOnce(
114
+ 'capped',
115
+ `a kept trace dropped ${dropped} entries under maxEntries (${maxEntries}), so it replays only up to ` +
116
+ 'the first step it lacks. Raise maxEntries for this flow to replay whole runs.'
117
+ );
118
+ }
119
+ const anonymous = trace.filter((entry) => entry.command === 'anonymous').length;
120
+ if (anonymous > 0) {
121
+ warnOnce(
122
+ 'anonymous',
123
+ `${anonymous} of its steps are named 'anonymous', usually inline arrow Commands, so a replay cannot ` +
124
+ 'tell them apart and would not notice two of them trading places. Name them with a const, as in ' +
125
+ 'const cmdLoadOrder = () => ..., or with meta.name.'
126
+ );
127
+ }
128
+ };
129
+
130
+ /** A reporter that throws does not change the run either. */
131
+ const reportSinkError = (/** @type {unknown} */ error, /** @type {string} */ flowName) => {
132
+ try {
133
+ onSinkError(error, flowName);
134
+ } catch {}
135
+ };
136
+
137
+ /**
138
+ * Outside a recorded run there is no store, so the Command runs untouched. `path` must be passed on: a replay
139
+ * matches steps on it.
140
+ * @type {StepRunner}
141
+ */
142
+ const onStep = async (name, type, op, path) => {
143
+ const store = scope.getStore();
144
+ return store ? await store.rec.onStep(name, type, op, path) : await op();
145
+ };
146
+
147
+ /**
148
+ * `onRun` never sees the context, so it is copied from the run's first Command, before that Command can change
149
+ * it. A run that stops before any Command records none.
150
+ * @type {CommandInterceptor}
151
+ */
152
+ const onBeforeCommand = async (command, context) => {
153
+ const store = scope.getStore();
154
+ if (!store || store.contextCaptured) return;
155
+ store.contextCaptured = true;
156
+ store.head.context = store.rec.toTrace({ context }).context;
157
+ };
158
+
159
+ return { onRun, onStep, onBeforeCommand };
160
+ }
161
+
162
+ /**
163
+ * Installs recording as its own layer and returns the function that removes it. Call it once per process: layers
164
+ * stack, so a second call records every run twice.
165
+ *
166
+ * @param {RecordingOptions} [options]
167
+ */
168
+ export function enableRecording(options) {
169
+ return configureEffect(recordingHooks(options));
170
+ }