@sylphx/sdk 0.11.2 → 0.12.1

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,6 +1,372 @@
1
1
  import { Effect } from 'effect';
2
+ import { Meter, Tracer } from '@opentelemetry/api';
2
3
  import { monitorEventLoopDelay } from 'node:perf_hooks';
3
4
 
5
+ /**
6
+ * Audit-grade health history for `@sylphx/sdk/health` (ADR-144).
7
+ *
8
+ * Every `evaluate()` produces a tamper-evident `HistoryEntry` linked to
9
+ * the previous entry via a SHA-256 prev-hash chain (the same primitive
10
+ * AWS CloudTrail uses for digest-based log file integrity validation).
11
+ * Operators run `sylphx health verify` to walk the chain offline and
12
+ * detect any post-hoc tampering.
13
+ *
14
+ * # Storage is pluggable
15
+ *
16
+ * The SDK ships an `InMemoryHistoryStore` (ring buffer, FIFO eviction)
17
+ * as the reference implementation. Customers wanting compliance-grade
18
+ * durability (SOC 2 Type II, GDPR Article 32, HIPAA 164.312(b)) plug
19
+ * their own persistent `HistoryStore` — a thin interface that the SDK
20
+ * calls with each new entry. Adapter packages for Postgres / S3 / cold
21
+ * storage ship separately.
22
+ *
23
+ * # Cardinal rule (carried from ADR-141 / 142 / 143)
24
+ *
25
+ * Recording failures never throw upstream. The `/healthz` response is
26
+ * the load-bearing contract; the history record is observability
27
+ * augmentation. A store that throws during `append()` produces a logged
28
+ * warning (in dev) and is otherwise a no-op for that evaluation.
29
+ *
30
+ * # Wire format normative
31
+ *
32
+ * Per ADR-144 §3 — sorted-key canonical signal JSON, base16 SHA-256
33
+ * hashes, six-decimal-place score precision, `|` separator for the
34
+ * entry-hash preimage. Stable from day one.
35
+ */
36
+
37
+ /**
38
+ * One tamper-evident history entry. The `prevHash` links to the previous
39
+ * entry's `entryHash`; entry 0 (`sequence === 0`) uses the genesis
40
+ * placeholder `"0".repeat(64)`. Stable wire shape — additive evolution
41
+ * only.
42
+ */
43
+ interface HistoryEntry {
44
+ /** Monotonic, 0-indexed per service. */
45
+ readonly sequence: number;
46
+ /** ISO-8601 timestamp matching `HealthScore.lastTickAt`. */
47
+ readonly evaluatedAt: string;
48
+ /** Aggregate score in `[0, 1]`. */
49
+ readonly score: number;
50
+ /** Base16 SHA-256 of canonicalized signal map. */
51
+ readonly signalsDigest: string;
52
+ /** Base16 SHA-256 of previous entry's `entryHash` (`"0"*64` at sequence 0). */
53
+ readonly prevHash: string;
54
+ /** Base16 SHA-256 of `(sequence|evaluatedAt|score|signalsDigest|prevHash)`. */
55
+ readonly entryHash: string;
56
+ }
57
+ /** Query for `HistoryStore.range`. */
58
+ interface HistoryQuery {
59
+ /** Inclusive lower sequence. Omit for "from the beginning available". */
60
+ readonly fromSequence?: number;
61
+ /** Inclusive upper sequence. Omit for "to the most recent". */
62
+ readonly toSequence?: number;
63
+ /** Inclusive lower timestamp (ISO-8601). */
64
+ readonly fromTimestamp?: string;
65
+ /** Inclusive upper timestamp (ISO-8601). */
66
+ readonly toTimestamp?: string;
67
+ /** Maximum entries returned. Implementations may cap further. */
68
+ readonly limit?: number;
69
+ }
70
+ /** Pluggable persistence adapter. Implementations may be sync or async. */
71
+ interface HistoryStore {
72
+ /** Append one entry. MUST persist before resolving for compliance use. */
73
+ append(entry: HistoryEntry): Promise<void> | void;
74
+ /** Retrieve entries matching `query` in ascending sequence order. */
75
+ range(query: HistoryQuery): Promise<readonly HistoryEntry[]> | readonly HistoryEntry[];
76
+ /** Most recent entry, or null when empty. */
77
+ latest(): Promise<HistoryEntry | null> | HistoryEntry | null;
78
+ /** Optional capacity hint for ring-buffer stores. */
79
+ readonly capacity?: number;
80
+ }
81
+ /** Outcome of chain verification — `verified: false` always carries a reason. */
82
+ interface VerificationResult {
83
+ readonly verified: boolean;
84
+ readonly entriesChecked: number;
85
+ readonly firstBreakAtSequence: number | null;
86
+ readonly reason: string | null;
87
+ }
88
+ /** Options for the in-memory reference store. */
89
+ interface InMemoryHistoryStoreOptions {
90
+ /** Ring-buffer capacity. Default 1000. */
91
+ readonly capacity?: number;
92
+ }
93
+ /** Options for the recorder bound to a `sylphxHealth` instance. */
94
+ interface HistoryRecorderOptions {
95
+ /** Underlying store. Defaults to `InMemoryHistoryStore`. */
96
+ readonly store?: HistoryStore;
97
+ /** Capacity passed to the default in-memory store. Default 1000. */
98
+ readonly capacity?: number;
99
+ }
100
+ /** The flag value passed via `sylphxHealth({ history })`. */
101
+ type HistoryOption = boolean | HistoryRecorderOptions;
102
+ /** Genesis prev-hash for the very first entry per service. */
103
+ declare const GENESIS_PREV_HASH: string;
104
+ /** Default ring-buffer size when callers don't override. */
105
+ declare const DEFAULT_HISTORY_CAPACITY = 1000;
106
+ /**
107
+ * Sorted-key JSON canonicalization. Same signals in different insertion
108
+ * order produce the same string — guarantees `signalsDigest` stability
109
+ * across V8 / Bun / browser JSON.stringify implementations.
110
+ */
111
+ declare function canonicalizeSignals(signals: Record<string, number | string | boolean>): string;
112
+ /** Build an in-memory ring-buffer history store. */
113
+ declare function createInMemoryHistoryStore(opts?: InMemoryHistoryStoreOptions): HistoryStore;
114
+ /**
115
+ * Records every `HealthScore` as a hash-chained entry. Owns a single
116
+ * `nextSequence` counter + the `prevHash` value carried forward across
117
+ * recordings. The recorder is per-instance (per-service); the chain is
118
+ * never shared across services.
119
+ */
120
+ interface HistoryRecorder {
121
+ /** Backing store. Exposed for advanced callers (e.g. tests + verification). */
122
+ readonly store: HistoryStore;
123
+ /** Record one score. Resolves to the persisted entry (or null on failure). */
124
+ record(score: HealthScore): Promise<HistoryEntry | null>;
125
+ /** Convenience: store.range. */
126
+ range(query: HistoryQuery): Promise<readonly HistoryEntry[]>;
127
+ /** Convenience: store.latest. */
128
+ latest(): Promise<HistoryEntry | null>;
129
+ /**
130
+ * Verify the chain over a query window. Walks entries in sequence
131
+ * order, recomputing each `entryHash` and confirming it matches the
132
+ * next entry's `prevHash`. Returns the first break with a reason.
133
+ */
134
+ verify(query: HistoryQuery): Promise<VerificationResult>;
135
+ /** Tear down internal references. Idempotent. */
136
+ dispose(): void;
137
+ }
138
+ /** Build a recorder bound to a (defaulted) in-memory store. */
139
+ declare function createHistoryRecorder(opts?: HistoryRecorderOptions): HistoryRecorder;
140
+ /**
141
+ * Walk an ordered (by sequence) chain of entries, recomputing each
142
+ * `entryHash` and confirming it matches the next entry's `prevHash`.
143
+ * Returns the first break encountered.
144
+ *
145
+ * Edge cases:
146
+ * - Empty input → `verified: true, entriesChecked: 0`. Vacuous truth.
147
+ * - First entry has `prevHash !== GENESIS_PREV_HASH` AND `sequence === 0`
148
+ * → break ("genesis prev-hash mismatch").
149
+ * - First entry's `sequence > 0` but `prevHash === GENESIS_PREV_HASH`
150
+ * → this is a sub-window query; we accept and chain forward from
151
+ * `entries[0]`.
152
+ */
153
+ declare function verifyChain(entries: ReadonlyArray<HistoryEntry>): Promise<VerificationResult>;
154
+
155
+ /**
156
+ * Causality-aware health propagation for `@sylphx/sdk/health` (ADR-143).
157
+ *
158
+ * Reads + writes the OpenTelemetry Baggage entry `sylphx.health.cause`
159
+ * with value `<signal>@<service>[,<signal>@<service>...]` per ADR-143 §3.1.
160
+ * Append-only `sylphx.health.cause_chain` carries the hop chain so
161
+ * operators can reconstruct multi-hop incident paths.
162
+ *
163
+ * # Read side (downstream)
164
+ *
165
+ * On every `evaluate()`, the SDK reads `propagation.getActiveBaggage()`
166
+ * before computing the score. When the baggage carries an
167
+ * `sylphx.health.cause` entry, the resulting `HealthScore.upstreamCause`
168
+ * surfaces the parsed causes + chain. The OTel emitter then sets
169
+ * `service.health.cause_root` / `cause_signal` / `cause_chain` span
170
+ * attributes and increments the `health.downstream_cause` Counter.
171
+ *
172
+ * # Write side (upstream — the service whose signal is failing)
173
+ *
174
+ * `runWithCause(fn, score)` wraps an async function in a new OTel
175
+ * context where the cause baggage is set. Used by `withHealth.*`
176
+ * middleware to inject baggage on every non-/healthz request so that
177
+ * downstream calls within that request inherit the cause via standard
178
+ * OTel context propagation (W3C Baggage header on HTTP, native
179
+ * baggage on gRPC, queue metadata on Kafka/Cloud Tasks).
180
+ *
181
+ * # Graceful fallback
182
+ *
183
+ * `@opentelemetry/api` is an optional peer dependency (see ADR-142
184
+ * §2.3). When the dynamic import fails — or when the runtime exposes
185
+ * only a partial OTel API — the handle transitions to `'unavailable'`,
186
+ * every method becomes a no-op, and `runWithCause` runs the inner
187
+ * function with no context modification. The `/healthz` contract is
188
+ * unchanged in either case (ADR-143 §2.5 cardinal rule).
189
+ *
190
+ * # Cardinal rules (carried from ADR-141 / ADR-142)
191
+ *
192
+ * - Propagation never throws. Errors in `readCause`, `encodeCause`,
193
+ * `runWithCause` are caught and downgraded to no-op behaviour.
194
+ * - The HTTP response shape is unchanged. Baggage is augmentation;
195
+ * probe surface is the load-bearing contract.
196
+ */
197
+
198
+ declare const BAGGAGE_KEY_CAUSE = "sylphx.health.cause";
199
+ declare const BAGGAGE_KEY_CHAIN = "sylphx.health.cause_chain";
200
+ /** Default threshold below which a signal triggers cause propagation. */
201
+ declare const DEFAULT_CAUSE_THRESHOLD = 0.5;
202
+ /** Maximum number of hops persisted in the chain baggage (ADR-143 §5). */
203
+ declare const MAX_CHAIN_LENGTH = 5;
204
+ /**
205
+ * The flag value passed by callers via `sylphxHealth({ causality })` /
206
+ * `withHealth.hono({ causality })`. `true` (default) enables propagation
207
+ * with sensible defaults; `false` disables it; an object configures it.
208
+ */
209
+ type CausalityOption = boolean | CausalityOptions;
210
+ interface CausalityOptions {
211
+ /**
212
+ * Below this signal factor, the SDK considers the signal "causing
213
+ * degradation" and emits a cause entry. Default: `0.5` — matches the
214
+ * ADR-111 kill threshold. Range: `(0, 1]`.
215
+ */
216
+ readonly threshold?: number;
217
+ /**
218
+ * Override the service name used in cause entries. Falls back to
219
+ * `process.env.OTEL_SERVICE_NAME` then `process.env.SERVICE_NAME`
220
+ * then `"unknown"`. Operators of multi-tenant runtimes typically
221
+ * override this per evaluator.
222
+ */
223
+ readonly serviceName?: string;
224
+ /**
225
+ * When true (default), the SDK appends the local service name to
226
+ * `sylphx.health.cause_chain` baggage so downstream operators can
227
+ * reconstruct multi-hop incident paths.
228
+ */
229
+ readonly propagateChain?: boolean;
230
+ }
231
+ /** Parsed view of an upstream cause read from baggage. */
232
+ interface UpstreamCause {
233
+ /**
234
+ * The list of `<signal>@<service>` pairs carried in the baggage entry.
235
+ * Operators read `causes[0]` for the most-significant root; the full
236
+ * list preserves multi-cause concurrent failures.
237
+ */
238
+ readonly causes: ReadonlyArray<{
239
+ readonly signal: string;
240
+ readonly service: string;
241
+ }>;
242
+ /**
243
+ * The hop chain — services that observed this cause and forwarded it.
244
+ * Most recent service is at the tail. `chain.length === 0` means the
245
+ * baggage entry was just minted (no downstream services have observed
246
+ * it yet — typically only at the moment-of-incident origin).
247
+ */
248
+ readonly chain: ReadonlyArray<string>;
249
+ }
250
+ /** Operational handle the SDK uses to read + write cause baggage. */
251
+ interface CausalityHandle {
252
+ /** Async loader state — exposed for tests + diagnostics. */
253
+ readonly state: 'loading' | 'ready' | 'unavailable' | 'disposed';
254
+ /** Read upstream cause from active OTel baggage. Returns null when absent. */
255
+ readCause(): UpstreamCause | null;
256
+ /**
257
+ * Encode the local cause string for baggage. Returns null when none of
258
+ * the signals' factors are below the configured threshold.
259
+ */
260
+ encodeCause(score: HealthScore): string | null;
261
+ /**
262
+ * Run `fn` within a new OTel context that has our cause + chain
263
+ * baggage entries set. Used by the middleware to inject baggage on
264
+ * every non-/healthz request. When OTel API is unavailable, this
265
+ * degrades to `fn()` directly.
266
+ */
267
+ runWithCause<T>(fn: () => T | Promise<T>, score: HealthScore): Promise<T>;
268
+ /** Tear down internal references. Idempotent. */
269
+ dispose(): void;
270
+ }
271
+ /**
272
+ * Build a causality handle. Returns a handle whose state starts at
273
+ * `'loading'`; emit / read operations are no-ops during loading. Once
274
+ * the dynamic import resolves, state transitions to `'ready'` (or
275
+ * `'unavailable'` when OTel API is absent).
276
+ */
277
+ declare function createCausalityHandle(opts?: CausalityOptions): CausalityHandle;
278
+
279
+ /**
280
+ * Static attributes the emitter merges onto every metric / event /
281
+ * span-attribute it produces. Use to stamp deployment metadata
282
+ * (`deployment.env`, `git.sha`, …) that the OTel resource detector
283
+ * doesn't pick up automatically.
284
+ */
285
+ type OtelStaticAttributes = Readonly<Record<string, string | number | boolean>>;
286
+ interface OtelEmitterOptions {
287
+ /**
288
+ * Pre-built OTel Meter. When provided, the emitter uses it directly and
289
+ * skips the async OTel API load. Common case: tests, sandboxes, multi-
290
+ * tenant runtimes wiring a custom MeterProvider per request.
291
+ */
292
+ readonly meter?: Meter;
293
+ /**
294
+ * Pre-built OTel Tracer. Same intent as `meter`. The emitter uses the
295
+ * tracer only to read the active span when setting attributes /
296
+ * events; it does not create new spans.
297
+ */
298
+ readonly tracer?: Tracer;
299
+ /**
300
+ * When true, the emitter records a `health.evaluated` span event on
301
+ * every evaluation (if a span is active). Default false — high-
302
+ * frequency probe paths produce many events and most operators only
303
+ * need the metric.
304
+ */
305
+ readonly events?: boolean;
306
+ /** Static attributes merged onto every emitted metric / span attribute. */
307
+ readonly attributes?: OtelStaticAttributes;
308
+ }
309
+ /**
310
+ * The flag value passed by callers via `sylphxHealth({ otel })` /
311
+ * `withHealth.hono({ otel })`. `true` (default) enables emission with
312
+ * sensible defaults; `false` disables it; an object configures it.
313
+ */
314
+ type OtelOption = boolean | OtelEmitterOptions;
315
+ interface OtelEmitter {
316
+ /**
317
+ * Emit metrics / span attributes / event for one HealthScore.
318
+ * `upstreamCause` (ADR-143) carries the parsed causality baggage so the
319
+ * emitter can record `health.downstream_cause` counter +
320
+ * `service.health.cause_*` span attributes. Non-throwing.
321
+ */
322
+ emit(score: HealthScore, upstreamCause?: UpstreamCause | null): void;
323
+ /**
324
+ * Tear down any cached instruments. Called from `sylphxHealth.dispose()`
325
+ * during graceful shutdown. The OTel SDK owns the underlying meter
326
+ * lifecycle; this method only releases the emitter's caches.
327
+ */
328
+ dispose(): void;
329
+ /** Readiness state — exposed for tests. `loading` is transient; `ready` and `unavailable` are terminal. */
330
+ readonly state: 'loading' | 'ready' | 'unavailable' | 'disposed';
331
+ }
332
+ /** Metric names — pinned by ADR-142 §3.1 + ADR-143 §3.3, stable from day one. */
333
+ declare const METRIC_NAMES: {
334
+ readonly score: "health.score";
335
+ readonly signalFactor: "health.signal.factor";
336
+ readonly signalValue: "health.signal.value";
337
+ readonly signalValueState: "health.signal.value_state";
338
+ /** ADR-143 §3.3 — increments per evaluation when an upstream cause is observed. */
339
+ readonly downstreamCause: "health.downstream_cause";
340
+ };
341
+ /** Span attribute names — pinned by ADR-142 §2.1.3 + ADR-143 §3.2, stable from day one. */
342
+ declare const SPAN_ATTRIBUTES: {
343
+ readonly score: "service.health.score";
344
+ readonly signalFactor: (name: string) => string;
345
+ /** ADR-143 §3.2 — the upstream service that originated the failure chain. */
346
+ readonly causeRoot: "service.health.cause_root";
347
+ /** ADR-143 §3.2 — the signal at the root that failed. */
348
+ readonly causeSignal: "service.health.cause_signal";
349
+ /** ADR-143 §3.2 — comma-delimited hop chain (oldest to newest). */
350
+ readonly causeChain: "service.health.cause_chain";
351
+ };
352
+ /** Span event name — pinned by ADR-142 §2.1.4. */
353
+ declare const EVENT_NAMES: {
354
+ readonly evaluated: "health.evaluated";
355
+ };
356
+ /**
357
+ * Build an OTel emitter.
358
+ *
359
+ * Returns a sealed emitter object whose `emit()` is initially a no-op
360
+ * while the OTel API loads in the background. Once loaded, all subsequent
361
+ * `emit()` calls produce metrics / span attributes / events per ADR-142.
362
+ *
363
+ * When `@opentelemetry/api` is not installed in the host project (the
364
+ * common case for hobby projects), the emitter transitions to
365
+ * `unavailable` and all `emit()` calls remain no-ops for the lifetime of
366
+ * the instance. No exceptions, no warnings.
367
+ */
368
+ declare function createOtelEmitter(opts?: OtelEmitterOptions): OtelEmitter;
369
+
4
370
  /**
5
371
  * Type SSOT for `@sylphx/sdk/health` (ADR-111 §4).
6
372
  *
@@ -81,6 +447,14 @@ type Signal = SyncSignal | AsyncSignal;
81
447
  interface HealthScore {
82
448
  readonly score: number;
83
449
  readonly signals: Record<string, number | string | boolean>;
450
+ /**
451
+ * Normalised per-signal health factors in [0, 1].
452
+ *
453
+ * Additive to the ADR-111 wire shape: existing readers can ignore it,
454
+ * while ADR-142 OTel emission uses it as the SSOT for
455
+ * `health.signal.factor`.
456
+ */
457
+ readonly signalFactors?: Record<string, number>;
84
458
  readonly lastTickAt: string;
85
459
  }
86
460
  /**
@@ -127,6 +501,48 @@ interface SylphxHealthOptions {
127
501
  * `lastTickAt` timestamps.
128
502
  */
129
503
  readonly now?: () => Date;
504
+ /**
505
+ * OpenTelemetry emission (ADR-142). When `true` (default), every
506
+ * `evaluate()` records `health.score` / `health.signal.*` metrics and
507
+ * sets `service.health.score` on the active span. `false` disables
508
+ * emission entirely. An object configures advanced behaviour (custom
509
+ * meter, span events, static attributes).
510
+ *
511
+ * Graceful fallback: if `@opentelemetry/api` is not installed in the
512
+ * host project, the emitter no-ops cleanly — no warning, no exception.
513
+ */
514
+ readonly otel?: OtelOption;
515
+ /**
516
+ * Causality-aware health propagation (ADR-143). When `true` (default),
517
+ * every `evaluate()` reads upstream cause from active OTel baggage
518
+ * and surfaces it on the metric stream (`health.downstream_cause`
519
+ * counter + `service.health.cause_root` span attribute). The
520
+ * `withHealth.*` middleware additionally writes the local cause into
521
+ * the request context baggage on every non-`/healthz` request so
522
+ * downstream services inherit causality via standard OTel
523
+ * propagation. `false` disables both read and write. An object
524
+ * tunes the threshold + service-name + chain behaviour.
525
+ *
526
+ * Graceful fallback: if `@opentelemetry/api` is not installed, the
527
+ * handle becomes `'unavailable'` and propagation is a no-op.
528
+ */
529
+ readonly causality?: CausalityOption;
530
+ /**
531
+ * Audit-grade health history (ADR-144). When `true` (default), every
532
+ * `evaluate()` records a tamper-evident `HistoryEntry` into the
533
+ * configured `HistoryStore` (default: in-memory ring buffer with
534
+ * capacity 1000). Entries form a SHA-256 prev-hash chain — operators
535
+ * (and compliance auditors) can replay the chain to detect post-hoc
536
+ * tampering.
537
+ *
538
+ * Set `false` to disable recording. Pass an object to plug a
539
+ * persistent store (Postgres / S3 / OpenSearch) or override capacity.
540
+ *
541
+ * The in-memory store is suitable for testing + low-stakes single-pod
542
+ * deployments. SOC 2 / GDPR / HIPAA compliance requires a persistent
543
+ * adapter; the interface is public and stable so any backend works.
544
+ */
545
+ readonly history?: HistoryOption;
130
546
  }
131
547
 
132
548
  /**
@@ -264,6 +680,205 @@ interface UnixSocketServerHandle {
264
680
  shutdown(): Promise<void>;
265
681
  }
266
682
 
683
+ /**
684
+ * Framework middleware helpers for `@sylphx/sdk/health` (ADR-141 Phase 0).
685
+ *
686
+ * Zero-Config one-liners for the most common JS server frameworks. Each
687
+ * helper creates a `sylphxHealth()` instance internally and returns a
688
+ * framework-idiomatic middleware / plugin / handler that serves `/healthz`.
689
+ *
690
+ * For advanced control (custom signals, scoring strategies, Unix-socket
691
+ * IPC for the sidecar), import `sylphxHealth` directly — these helpers
692
+ * are wrappers around the same factory.
693
+ *
694
+ * @example Hono
695
+ * ```ts
696
+ * import { Hono } from 'hono'
697
+ * import { withHealth } from '@sylphx/sdk/health'
698
+ *
699
+ * const app = new Hono()
700
+ * app.use('*', withHealth.hono())
701
+ * ```
702
+ *
703
+ * @example Express
704
+ * ```ts
705
+ * import express from 'express'
706
+ * import { withHealth } from '@sylphx/sdk/health'
707
+ *
708
+ * const app = express()
709
+ * app.use(withHealth.express())
710
+ * ```
711
+ *
712
+ * @example Fastify
713
+ * ```ts
714
+ * import Fastify from 'fastify'
715
+ * import { withHealth } from '@sylphx/sdk/health'
716
+ *
717
+ * const fastify = Fastify()
718
+ * await fastify.register(withHealth.fastify())
719
+ * ```
720
+ *
721
+ * @example Bun.serve / Next.js / Deno / edge runtimes
722
+ * ```ts
723
+ * import { withHealth } from '@sylphx/sdk/health'
724
+ *
725
+ * const handleHealth = withHealth.fetch()
726
+ * Bun.serve({
727
+ * port: 3000,
728
+ * fetch(req) {
729
+ * if (new URL(req.url).pathname === '/healthz') return handleHealth(req)
730
+ * return new Response('hello')
731
+ * },
732
+ * })
733
+ * ```
734
+ *
735
+ * Structural types are used for the framework signatures so this module
736
+ * has **zero peer dependencies** on Hono / Express / Fastify. The user's
737
+ * framework supplies the actual types at the call site via TypeScript's
738
+ * structural typing.
739
+ */
740
+
741
+ /**
742
+ * Common options for every framework helper. Extends `SylphxHealthOptions`
743
+ * (signals, scoringStrategy, now) with a `path` override.
744
+ */
745
+ interface WithHealthOptions extends SylphxHealthOptions {
746
+ /**
747
+ * HTTP path to serve the health endpoint on. Defaults to `/healthz` —
748
+ * the de-facto Kubernetes convention adopted by Spring Boot, FastAPI,
749
+ * and the IETF `application/health+json` draft.
750
+ */
751
+ readonly path?: string;
752
+ /**
753
+ * Pre-built `SylphxHealth` instance. When provided, options other than
754
+ * `path` are ignored and the helper just wires the instance up. Use
755
+ * this when you need to share one health instance across multiple
756
+ * frameworks (e.g. Hono middleware + Unix-socket IPC for the sidecar).
757
+ */
758
+ readonly instance?: SylphxHealth;
759
+ /**
760
+ * Bind the same health score on the sidecar IPC socket.
761
+ *
762
+ * Default: auto-enable when the platform injects
763
+ * `SYLPHX_HEALTH_APP_SOCKET` into the app container. This keeps the
764
+ * one-line helper enough for ADR-111 health-agent score polling while
765
+ * remaining a no-op on local/dev/edge runtimes without the env var.
766
+ *
767
+ * Set `false` to opt out, `true` to bind the default socket path, or
768
+ * pass `{ path, required }` for explicit control. `required` defaults
769
+ * to `false` so non-Bun runtimes never fail module import just because
770
+ * they share the public SDK.
771
+ */
772
+ readonly unixSocket?: boolean | {
773
+ readonly path?: string;
774
+ readonly required?: boolean;
775
+ readonly unlinkOnShutdown?: boolean;
776
+ };
777
+ }
778
+ /**
779
+ * Minimal structural type for a Hono request context. We only need
780
+ * `c.req.path` to decide whether the request hits the health endpoint.
781
+ */
782
+ interface HonoContextLike {
783
+ readonly req: {
784
+ readonly path: string;
785
+ };
786
+ }
787
+ /** Hono `Next` is a parameterless async callback that yields to the next middleware. */
788
+ type HonoNextLike = () => Promise<void>;
789
+ /**
790
+ * Hono middleware that responds to `GET /healthz` and yields otherwise.
791
+ *
792
+ * The returned function carries a `.health` reference (the underlying
793
+ * `SylphxHealth` instance) and a `.dispose()` method for graceful
794
+ * shutdown. Wire `dispose()` into your existing SIGTERM handler so the
795
+ * SDK releases the event-loop-lag histogram + Unix socket on shutdown:
796
+ *
797
+ * ```ts
798
+ * const health = withHealth.hono()
799
+ * app.use('*', health)
800
+ * // From your app's signal-handler wiring:
801
+ * // onShutdown(() => health.dispose())
802
+ * ```
803
+ */
804
+ interface HonoHealthMiddleware {
805
+ (c: HonoContextLike, next: HonoNextLike): Promise<Response | void>;
806
+ readonly health: SylphxHealth;
807
+ dispose(): void;
808
+ }
809
+ declare function honoHelper(opts?: WithHealthOptions): HonoHealthMiddleware;
810
+ /** Express-style request — only `url` is needed to match `/healthz`. */
811
+ interface ExpressRequestLike {
812
+ readonly url?: string;
813
+ readonly method?: string;
814
+ }
815
+ /** Express-style response — subset required by the Node handler. */
816
+ interface ExpressResponseLike {
817
+ statusCode: number;
818
+ setHeader(name: string, value: string): void;
819
+ end(body?: string): void;
820
+ }
821
+ /** Express-style next callback — called when the request is not for /healthz. */
822
+ type ExpressNextLike = (err?: unknown) => void;
823
+ interface ExpressHealthMiddleware {
824
+ (req: ExpressRequestLike, res: ExpressResponseLike, next: ExpressNextLike): void;
825
+ readonly health: SylphxHealth;
826
+ dispose(): void;
827
+ }
828
+ declare function expressHelper(opts?: WithHealthOptions): ExpressHealthMiddleware;
829
+ /**
830
+ * Minimal structural type for a Fastify instance — only the `.get` route
831
+ * registration is needed. The plugin signature matches Fastify's
832
+ * encapsulated-plugin contract: `(instance, opts) => Promise<void>`.
833
+ */
834
+ interface FastifyInstanceLike {
835
+ get(path: string, handler: (req: unknown, reply: FastifyReplyLike) => Promise<unknown> | unknown): unknown;
836
+ }
837
+ /** Minimal Fastify reply — subset required by the health response. */
838
+ interface FastifyReplyLike {
839
+ code(code: number): FastifyReplyLike;
840
+ header(name: string, value: string): FastifyReplyLike;
841
+ send(body?: unknown): FastifyReplyLike | Promise<FastifyReplyLike> | void;
842
+ }
843
+ interface FastifyHealthPlugin {
844
+ (instance: FastifyInstanceLike, opts?: unknown): Promise<void>;
845
+ readonly health: SylphxHealth;
846
+ dispose(): void;
847
+ }
848
+ declare function fastifyHelper(opts?: WithHealthOptions): FastifyHealthPlugin;
849
+ interface FetchHealthHandler {
850
+ (req?: Request): Promise<Response>;
851
+ readonly health: SylphxHealth;
852
+ dispose(): void;
853
+ }
854
+ declare function fetchHelper(opts?: WithHealthOptions): FetchHealthHandler;
855
+ /**
856
+ * One-line health-check wiring for popular JS server frameworks.
857
+ *
858
+ * All helpers internally create a `sylphxHealth()` instance with sane
859
+ * defaults (single event-loop-lag signal) and serve `/healthz` returning
860
+ * the standard `HealthScore` JSON.
861
+ *
862
+ * Each returned helper exposes the underlying `SylphxHealth` instance
863
+ * via `.health` and a `.dispose()` shortcut for graceful shutdown.
864
+ *
865
+ * See `sylphxHealth()` for advanced use (custom signals, scoring, IPC).
866
+ */
867
+ declare const withHealth: {
868
+ /** Hono middleware. Mount with `app.use('*', withHealth.hono())`. */
869
+ readonly hono: typeof honoHelper;
870
+ /** Express / Connect / Polka middleware. Mount with `app.use(withHealth.express())`. */
871
+ readonly express: typeof expressHelper;
872
+ /** Fastify plugin. Register with `await fastify.register(withHealth.fastify())`. */
873
+ readonly fastify: typeof fastifyHelper;
874
+ /**
875
+ * Pure Web Fetch handler. Works with Bun.serve, Next.js route
876
+ * handlers, Deno, Cloudflare Workers, and any other `Request →
877
+ * Response` runtime.
878
+ */
879
+ readonly fetch: typeof fetchHelper;
880
+ };
881
+
267
882
  /**
268
883
  * Scoring strategies (ADR-111 §4 — three-tier health gate).
269
884
  *
@@ -485,6 +1100,87 @@ interface MemoryPressureOptions {
485
1100
  }
486
1101
  declare function memoryPressureSignal(opts?: MemoryPressureOptions): SyncSignal;
487
1102
 
1103
+ /**
1104
+ * `pingSignal` — generic binary readiness signal for any sub-system the
1105
+ * service depends on (Postgres, Redis, Kafka, S3, an upstream API, …).
1106
+ *
1107
+ * The signal calls `ping()` every poll tick and reports:
1108
+ * - `healthFactor: 1` if the ping resolves (any value) within the timeout
1109
+ * - `healthFactor: 0` if the ping throws OR times out
1110
+ *
1111
+ * Binary — no partial-credit semantics. For richer scoring use a custom
1112
+ * signal that returns a graded `healthFactor`; `pingSignal` is the
1113
+ * canonical "subsystem reachable yes/no" primitive that 99% of dependency
1114
+ * probes need.
1115
+ *
1116
+ * `databaseSignal` and `redisSignal` are thin convenience wrappers
1117
+ * with idiomatic defaults for those two ubiquitous dependencies.
1118
+ *
1119
+ * @example Generic
1120
+ * ```ts
1121
+ * import { pingSignal } from '@sylphx/sdk/health'
1122
+ *
1123
+ * app.use('*', withHealth.hono({
1124
+ * signals: [
1125
+ * pingSignal({
1126
+ * name: 'upstream-api',
1127
+ * ping: async () => { await fetch('https://upstream.example/_ping') },
1128
+ * }),
1129
+ * ],
1130
+ * }))
1131
+ * ```
1132
+ *
1133
+ * @example Database + Redis
1134
+ * ```ts
1135
+ * import { withHealth, databaseSignal, redisSignal } from '@sylphx/sdk/health'
1136
+ *
1137
+ * app.use('*', withHealth.hono({
1138
+ * signals: [
1139
+ * databaseSignal({ ping: () => pool.query('SELECT 1') }),
1140
+ * redisSignal({ ping: () => redis.ping() }),
1141
+ * ],
1142
+ * }))
1143
+ * ```
1144
+ */
1145
+
1146
+ interface PingSignalOptions {
1147
+ /** Stable signal name. Appears in the wire JSON `signals.<name>` map. */
1148
+ readonly name: string;
1149
+ /**
1150
+ * Async function the SDK calls every poll tick. Resolve (any value) =
1151
+ * healthy. Throw / reject = unhealthy. Timeout → unhealthy.
1152
+ */
1153
+ readonly ping: () => Promise<unknown>;
1154
+ /**
1155
+ * Per-tick timeout in milliseconds. Default 500ms — fast enough to
1156
+ * fold into a `/healthz` response without holding the event loop, slow
1157
+ * enough to avoid false negatives on a momentarily-slow dependency.
1158
+ * Tune up for dependencies that legitimately take longer (e.g. S3 list).
1159
+ */
1160
+ readonly timeoutMs?: number;
1161
+ /** Weight in the weighted-product score. Default 1.0. */
1162
+ readonly weight?: number;
1163
+ }
1164
+ declare function pingSignal(opts: PingSignalOptions): AsyncSignal;
1165
+ interface DependencyPingOptions {
1166
+ readonly ping: () => Promise<unknown>;
1167
+ readonly timeoutMs?: number;
1168
+ readonly weight?: number;
1169
+ /** Override the signal name. Defaults to the subsystem name. */
1170
+ readonly name?: string;
1171
+ }
1172
+ /**
1173
+ * Postgres / SQL-database readiness — convenience over `pingSignal` with
1174
+ * `name: 'database'`. Wrap the caller's `pool.query('SELECT 1')` or
1175
+ * equivalent.
1176
+ */
1177
+ declare function databaseSignal(opts: DependencyPingOptions): AsyncSignal;
1178
+ /**
1179
+ * Redis / KV readiness — convenience over `pingSignal` with `name: 'redis'`.
1180
+ * Wrap the caller's `redis.ping()` or equivalent.
1181
+ */
1182
+ declare function redisSignal(opts: DependencyPingOptions): AsyncSignal;
1183
+
488
1184
  /**
489
1185
  * `queueDepthSignal` — backpressure indicator (ADR-111 §4.3).
490
1186
  *
@@ -558,6 +1254,12 @@ declare function queueDepthSignal(opts: QueueDepthOptions): AsyncSignal;
558
1254
  * "recent5sErrorRate": 0.001,
559
1255
  * "memoryPressure": 0.45
560
1256
  * },
1257
+ * "signalFactors": {
1258
+ * "eventLoopLagMs": 1,
1259
+ * "queueDepth": 0.8,
1260
+ * "recent5sErrorRate": 0.99,
1261
+ * "memoryPressure": 0.7
1262
+ * },
561
1263
  * "lastTickAt": "2026-05-03T12:34:56.789Z"
562
1264
  * }
563
1265
  * ```
@@ -619,63 +1321,40 @@ interface SylphxHealth extends HealthEvaluator {
619
1321
  * polls `/var/run/sylphx/health.sock` by default (ADR-111 §3.2.4).
620
1322
  */
621
1323
  serveUnixSocket(opts?: UnixSocketServerOptions): UnixSocketServerHandle;
1324
+ /**
1325
+ * Most recent score returned by `evaluate()`. Returns null before
1326
+ * the first evaluation. Used by `withHealth.*` middleware to inject
1327
+ * cause baggage on non-probe requests (ADR-143).
1328
+ */
1329
+ getLastScore(): HealthScore | null;
1330
+ /**
1331
+ * Run `fn` within an OTel context that has our local cause baggage
1332
+ * set, derived from the most recent `evaluate()`. When causality is
1333
+ * disabled or the OTel API is unavailable, runs `fn()` directly.
1334
+ * Middleware consumers don't usually call this — they let
1335
+ * `withHealth.*` wire it automatically. Exposed for advanced users
1336
+ * who build custom transport wrappers.
1337
+ */
1338
+ runWithCause<T>(fn: () => T | Promise<T>): Promise<T>;
1339
+ /**
1340
+ * Retrieve audit-grade history entries (ADR-144). Returns an empty
1341
+ * array when history is disabled. Operators use this for compliance
1342
+ * evidence + post-incident reconstruction.
1343
+ */
1344
+ getHistory(query?: HistoryQuery): Promise<readonly HistoryEntry[]>;
1345
+ /**
1346
+ * Verify the integrity of the recorded history chain over `query`
1347
+ * (defaults to all available entries). Returns `verified: true`
1348
+ * when the SHA-256 chain matches end-to-end; otherwise reports the
1349
+ * first sequence where tampering is detected.
1350
+ */
1351
+ verifyHistory(query?: HistoryQuery): Promise<VerificationResult>;
622
1352
  /**
623
1353
  * Tear down all registered signals. Idempotent. Call during graceful
624
1354
  * shutdown to release histograms, file watchers, etc.
625
1355
  */
626
1356
  dispose(): void;
627
1357
  }
628
- /**
629
- * Build a `SylphxHealth` instance.
630
- *
631
- * @example Hono integration:
632
- * ```ts
633
- * import { Hono } from 'hono'
634
- * import {
635
- * sylphxHealth,
636
- * eventLoopLagSignal,
637
- * queueDepthSignal,
638
- * errorRateSignal,
639
- * memoryPressureSignal,
640
- * } from '@sylphx/sdk/health'
641
- *
642
- * const errors = errorRateSignal({ window: '5s', degradedRate: 0.05 })
643
- *
644
- * const health = sylphxHealth({
645
- * signals: [
646
- * eventLoopLagSignal({ degradedMs: 5000, deadMs: 30000 }),
647
- * queueDepthSignal({ getter: () => queue.size, fullThreshold: 1000 }),
648
- * errors,
649
- * memoryPressureSignal({ degradedRatio: 0.85 }),
650
- * ],
651
- * })
652
- *
653
- * const app = new Hono()
654
- * app.get('/healthz', health.handler())
655
- *
656
- * // Track requests for the error-rate signal
657
- * app.use(async (c, next) => {
658
- * try { await next(); errors.recordSuccess() }
659
- * catch (err) { errors.recordError(); throw err }
660
- * })
661
- *
662
- * // Or — primary transport for the sidecar:
663
- * health.serveUnixSocket() // → /var/run/sylphx/health.sock
664
- * ```
665
- *
666
- * @example Worked example — OpenClaw under PDF-extract load (ADR-111 §4.5):
667
- *
668
- * ```text
669
- * eventLoopLagMs = 6000 → factor 0.4
670
- * queueDepth = 12 → factor 1.0
671
- * errorRate = 0.002 → factor 1.0
672
- * memoryPressure = 0.55 → factor 1.0
673
- * score = 0.4^0.4 × 1.0^0.6 ≈ 0.69
674
- *
675
- * → falls in [0.5, 0.8] → sidecar drains traffic, doesn't kill.
676
- * Pod gets to finish PDF extraction.
677
- * ```
678
- */
679
1358
  declare function sylphxHealth(opts?: SylphxHealthOptions): SylphxHealth;
680
1359
 
681
- export { type AsyncSignal, type ErrorRateOptions, type ErrorRateSignalHandle, type EventLoopLagOptions, HealthError, type HealthEvaluator, type HealthScore, type HealthSnapshot, type MemoryPressureOptions, type QueueDepthOptions, type ScoringStrategy, type Signal, type SignalBase, type SignalReading, type SylphxHealth, type SylphxHealthOptions, type SyncSignal, type UnixSocketServerHandle, type UnixSocketServerOptions, createNodeHandler, createWebHandler, defaultScoringStrategy, errorRateSignal, eventLoopLagSignal, memoryPressureSignal, queueDepthSignal, sylphxHealth, weightedProduct };
1360
+ export { type AsyncSignal, BAGGAGE_KEY_CAUSE, BAGGAGE_KEY_CHAIN, type CausalityHandle, type CausalityOption, type CausalityOptions, DEFAULT_CAUSE_THRESHOLD, DEFAULT_HISTORY_CAPACITY, type DependencyPingOptions, type ErrorRateOptions, type ErrorRateSignalHandle, type EventLoopLagOptions, type ExpressHealthMiddleware, type ExpressNextLike, type ExpressRequestLike, type ExpressResponseLike, type FastifyHealthPlugin, type FastifyInstanceLike, type FastifyReplyLike, type FetchHealthHandler, GENESIS_PREV_HASH, HealthError, type HealthEvaluator, type HealthScore, type HealthSnapshot, type HistoryEntry, type HistoryOption, type HistoryQuery, type HistoryRecorder, type HistoryRecorderOptions, type HistoryStore, type HonoContextLike, type HonoHealthMiddleware, type HonoNextLike, type InMemoryHistoryStoreOptions, MAX_CHAIN_LENGTH, type MemoryPressureOptions, EVENT_NAMES as OTEL_EVENT_NAMES, METRIC_NAMES as OTEL_METRIC_NAMES, SPAN_ATTRIBUTES as OTEL_SPAN_ATTRIBUTES, type OtelEmitter, type OtelEmitterOptions, type OtelOption, type OtelStaticAttributes, type PingSignalOptions, type QueueDepthOptions, type ScoringStrategy, type Signal, type SignalBase, type SignalReading, type SylphxHealth, type SylphxHealthOptions, type SyncSignal, type UnixSocketServerHandle, type UnixSocketServerOptions, type UpstreamCause, type VerificationResult, type WithHealthOptions, canonicalizeSignals, createCausalityHandle, createHistoryRecorder, createInMemoryHistoryStore, createNodeHandler, createOtelEmitter, createWebHandler, databaseSignal, defaultScoringStrategy, errorRateSignal, eventLoopLagSignal, memoryPressureSignal, pingSignal, queueDepthSignal, redisSignal, sylphxHealth, verifyChain, weightedProduct, withHealth };