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.
@@ -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
- }