@sylphx/sdk 0.12.0 → 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,3 +1,185 @@
1
+ // src/health/causality.ts
2
+ var BAGGAGE_KEY_CAUSE = "sylphx.health.cause";
3
+ var BAGGAGE_KEY_CHAIN = "sylphx.health.cause_chain";
4
+ var DEFAULT_CAUSE_THRESHOLD = 0.5;
5
+ var MAX_CHAIN_LENGTH = 5;
6
+ var cachedApi;
7
+ async function tryLoadCausalityApi() {
8
+ if (cachedApi !== void 0) return cachedApi;
9
+ try {
10
+ const api = await import("@opentelemetry/api");
11
+ if (!api?.context || !api?.propagation) {
12
+ cachedApi = null;
13
+ return cachedApi;
14
+ }
15
+ cachedApi = { context: api.context, propagation: api.propagation };
16
+ } catch {
17
+ cachedApi = null;
18
+ }
19
+ return cachedApi;
20
+ }
21
+ function resolveServiceNameFromEnv(env) {
22
+ if (env.OTEL_SERVICE_NAME) return env.OTEL_SERVICE_NAME;
23
+ if (env.SERVICE_NAME) return env.SERVICE_NAME;
24
+ return "unknown";
25
+ }
26
+ function defaultServiceName() {
27
+ if (typeof process !== "undefined" && process.env) {
28
+ return resolveServiceNameFromEnv(process.env);
29
+ }
30
+ return "unknown";
31
+ }
32
+ function isSafeIdentifier(part) {
33
+ return part.length > 0 && !/[,@\s]/.test(part);
34
+ }
35
+ function encodeCauseFromScore(score, threshold, serviceName) {
36
+ const factors = score.signalFactors;
37
+ if (!factors) return null;
38
+ const entries = [];
39
+ for (const [signalName, factor] of Object.entries(factors)) {
40
+ if (typeof factor !== "number" || !Number.isFinite(factor)) continue;
41
+ if (factor >= threshold) continue;
42
+ if (!isSafeIdentifier(signalName) || !isSafeIdentifier(serviceName)) continue;
43
+ entries.push(`${signalName}@${serviceName}`);
44
+ }
45
+ if (entries.length === 0) return null;
46
+ return entries.join(",");
47
+ }
48
+ function parseCauseValue(value) {
49
+ const out = [];
50
+ for (const raw of value.split(",")) {
51
+ const piece = raw.trim();
52
+ if (piece.length === 0) continue;
53
+ const atIdx = piece.indexOf("@");
54
+ if (atIdx <= 0 || atIdx === piece.length - 1) continue;
55
+ const signal = piece.slice(0, atIdx);
56
+ const service = piece.slice(atIdx + 1);
57
+ if (!isSafeIdentifier(signal) || !isSafeIdentifier(service)) continue;
58
+ out.push({ signal, service });
59
+ }
60
+ return out;
61
+ }
62
+ function parseChainValue(value) {
63
+ return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0 && isSafeIdentifier(s));
64
+ }
65
+ function appendChain(prev, serviceName, maxLen) {
66
+ if (!isSafeIdentifier(serviceName)) return prev;
67
+ if (prev[prev.length - 1] === serviceName) return prev;
68
+ const next = [...prev, serviceName];
69
+ if (next.length <= maxLen) return next;
70
+ return next.slice(next.length - maxLen);
71
+ }
72
+ var CausalityHandleImpl = class {
73
+ _state = "loading";
74
+ apiRef = null;
75
+ threshold;
76
+ serviceName;
77
+ propagateChain;
78
+ constructor(opts) {
79
+ const threshold = opts.threshold ?? DEFAULT_CAUSE_THRESHOLD;
80
+ if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) {
81
+ throw new Error(`createCausalityHandle: threshold must be in (0, 1], got ${threshold}`);
82
+ }
83
+ this.threshold = threshold;
84
+ this.serviceName = opts.serviceName ?? defaultServiceName();
85
+ this.propagateChain = opts.propagateChain !== false;
86
+ void this.initialize();
87
+ }
88
+ get state() {
89
+ return this._state;
90
+ }
91
+ async initialize() {
92
+ const api = await tryLoadCausalityApi();
93
+ if (this._state === "disposed") return;
94
+ if (api === null) {
95
+ this._state = "unavailable";
96
+ return;
97
+ }
98
+ this.apiRef = api;
99
+ this._state = "ready";
100
+ }
101
+ readCause() {
102
+ if (this._state !== "ready" || !this.apiRef) return null;
103
+ try {
104
+ const baggage = this.apiRef.propagation.getActiveBaggage();
105
+ if (!baggage) return null;
106
+ const causeEntry = baggage.getEntry(BAGGAGE_KEY_CAUSE);
107
+ if (!causeEntry || typeof causeEntry.value !== "string") return null;
108
+ const causes = parseCauseValue(causeEntry.value);
109
+ if (causes.length === 0) return null;
110
+ const chainEntry = baggage.getEntry(BAGGAGE_KEY_CHAIN);
111
+ const chain = chainEntry && typeof chainEntry.value === "string" ? parseChainValue(chainEntry.value) : [];
112
+ return { causes, chain };
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ encodeCause(score) {
118
+ try {
119
+ return encodeCauseFromScore(score, this.threshold, this.serviceName);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ async runWithCause(fn, score) {
125
+ if (this._state !== "ready" || !this.apiRef) {
126
+ return await fn();
127
+ }
128
+ try {
129
+ const localCause = this.encodeCause(score);
130
+ const existing = this.apiRef.propagation.getActiveBaggage();
131
+ const existingChain = existing?.getEntry(BAGGAGE_KEY_CHAIN)?.value ? parseChainValue(existing.getEntry(BAGGAGE_KEY_CHAIN).value) : [];
132
+ if (!localCause && !existing) {
133
+ return await fn();
134
+ }
135
+ const baseBaggage = existing ?? this.apiRef.propagation.createBaggage();
136
+ let nextBaggage = baseBaggage;
137
+ if (localCause) {
138
+ const newEntry = { value: localCause };
139
+ const upstream = existing?.getEntry(BAGGAGE_KEY_CAUSE)?.value;
140
+ const mergedValue = upstream ? mergeCauseValues(upstream, localCause) : localCause;
141
+ nextBaggage = nextBaggage.setEntry(BAGGAGE_KEY_CAUSE, {
142
+ ...newEntry,
143
+ value: mergedValue
144
+ });
145
+ }
146
+ if (this.propagateChain && (localCause || existingChain.length > 0)) {
147
+ const nextChain = localCause ? appendChain(existingChain, this.serviceName, MAX_CHAIN_LENGTH) : existingChain;
148
+ if (nextChain.length > 0) {
149
+ nextBaggage = nextBaggage.setEntry(BAGGAGE_KEY_CHAIN, {
150
+ value: nextChain.join(",")
151
+ });
152
+ }
153
+ }
154
+ const ctxWithBaggage = this.apiRef.propagation.setBaggage(
155
+ this.apiRef.context.active(),
156
+ nextBaggage
157
+ );
158
+ return await this.apiRef.context.with(ctxWithBaggage, fn);
159
+ } catch {
160
+ return await fn();
161
+ }
162
+ }
163
+ dispose() {
164
+ this._state = "disposed";
165
+ this.apiRef = null;
166
+ }
167
+ };
168
+ function mergeCauseValues(existing, local) {
169
+ const seen = /* @__PURE__ */ new Set();
170
+ const out = [];
171
+ for (const piece of [...existing.split(","), ...local.split(",")]) {
172
+ const trimmed = piece.trim();
173
+ if (trimmed.length === 0 || seen.has(trimmed)) continue;
174
+ seen.add(trimmed);
175
+ out.push(trimmed);
176
+ }
177
+ return out.join(",");
178
+ }
179
+ function createCausalityHandle(opts = {}) {
180
+ return new CausalityHandleImpl(opts);
181
+ }
182
+
1
183
  // src/health/effects.ts
2
184
  import { Effect } from "effect";
3
185
 
@@ -70,6 +252,377 @@ function createNodeHandler(source) {
70
252
  };
71
253
  }
72
254
 
255
+ // src/health/history.ts
256
+ var GENESIS_PREV_HASH = "0".repeat(64);
257
+ var DEFAULT_HISTORY_CAPACITY = 1e3;
258
+ function canonicalizeSignals(signals) {
259
+ const keys = Object.keys(signals).sort();
260
+ const pairs = keys.map((k) => {
261
+ const value = signals[k];
262
+ return [k, value];
263
+ });
264
+ return JSON.stringify(Object.fromEntries(pairs));
265
+ }
266
+ function encodeScore(score) {
267
+ return score.toFixed(6);
268
+ }
269
+ async function sha256Hex(input) {
270
+ const subtle = globalThis.crypto?.subtle;
271
+ if (!subtle || typeof subtle.digest !== "function") {
272
+ return "";
273
+ }
274
+ const buf = new TextEncoder().encode(input);
275
+ const digest = await subtle.digest("SHA-256", buf);
276
+ const bytes = new Uint8Array(digest);
277
+ let out = "";
278
+ for (let i = 0; i < bytes.length; i++) {
279
+ out += (bytes[i] | 0).toString(16).padStart(2, "0");
280
+ }
281
+ return out;
282
+ }
283
+ async function computeEntryHash(prehash) {
284
+ const canonical = [
285
+ prehash.sequence.toString(10),
286
+ prehash.evaluatedAt,
287
+ encodeScore(prehash.score),
288
+ prehash.signalsDigest,
289
+ prehash.prevHash
290
+ ].join("|");
291
+ return await sha256Hex(canonical);
292
+ }
293
+ var InMemoryHistoryStoreImpl = class {
294
+ capacity;
295
+ buf = [];
296
+ constructor(opts = {}) {
297
+ const cap = opts.capacity ?? DEFAULT_HISTORY_CAPACITY;
298
+ if (!Number.isFinite(cap) || cap <= 0 || cap > 1e7) {
299
+ throw new Error(`InMemoryHistoryStore: capacity must be in (0, 10000000], got ${cap}`);
300
+ }
301
+ this.capacity = cap;
302
+ }
303
+ append(entry) {
304
+ this.buf.push(entry);
305
+ if (this.buf.length > this.capacity) {
306
+ this.buf.shift();
307
+ }
308
+ }
309
+ range(query) {
310
+ const out = [];
311
+ for (const e of this.buf) {
312
+ if (query.fromSequence !== void 0 && e.sequence < query.fromSequence) continue;
313
+ if (query.toSequence !== void 0 && e.sequence > query.toSequence) continue;
314
+ if (query.fromTimestamp !== void 0 && e.evaluatedAt < query.fromTimestamp) continue;
315
+ if (query.toTimestamp !== void 0 && e.evaluatedAt > query.toTimestamp) continue;
316
+ out.push(e);
317
+ if (query.limit !== void 0 && out.length >= query.limit) break;
318
+ }
319
+ return out;
320
+ }
321
+ latest() {
322
+ return this.buf[this.buf.length - 1] ?? null;
323
+ }
324
+ };
325
+ function createInMemoryHistoryStore(opts = {}) {
326
+ return new InMemoryHistoryStoreImpl(opts);
327
+ }
328
+ var HistoryRecorderImpl = class {
329
+ store;
330
+ nextSequence = 0;
331
+ prevHash = GENESIS_PREV_HASH;
332
+ disposed = false;
333
+ initPromise;
334
+ /**
335
+ * Serializes record() calls so concurrent `evaluate()` invocations
336
+ * don't race on `nextSequence` / `prevHash`. Each new call chains off
337
+ * the previous; the chain itself never propagates errors (catch in the
338
+ * tail so one failed record doesn't poison subsequent ones).
339
+ */
340
+ inFlight = Promise.resolve();
341
+ constructor(opts) {
342
+ this.store = opts.store ?? createInMemoryHistoryStore({ capacity: opts.capacity });
343
+ this.initPromise = this.warmStart();
344
+ }
345
+ async warmStart() {
346
+ try {
347
+ const latest = await this.store.latest();
348
+ if (latest && Number.isFinite(latest.sequence)) {
349
+ this.nextSequence = latest.sequence + 1;
350
+ this.prevHash = latest.entryHash || GENESIS_PREV_HASH;
351
+ }
352
+ } catch {
353
+ }
354
+ }
355
+ async recordInternal(score) {
356
+ if (this.disposed) return null;
357
+ await this.initPromise;
358
+ try {
359
+ const signalsDigest = await sha256Hex(canonicalizeSignals(score.signals));
360
+ const prehash = {
361
+ sequence: this.nextSequence,
362
+ evaluatedAt: score.lastTickAt,
363
+ score: score.score,
364
+ signalsDigest,
365
+ prevHash: this.prevHash
366
+ };
367
+ const entryHash = await computeEntryHash(prehash);
368
+ const entry = { ...prehash, entryHash };
369
+ await this.store.append(entry);
370
+ this.nextSequence += 1;
371
+ this.prevHash = entryHash || GENESIS_PREV_HASH;
372
+ return entry;
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+ async record(score) {
378
+ const job = this.inFlight.then(() => this.recordInternal(score));
379
+ this.inFlight = job.catch(() => void 0);
380
+ return await job;
381
+ }
382
+ async range(query) {
383
+ try {
384
+ return await this.store.range(query);
385
+ } catch {
386
+ return [];
387
+ }
388
+ }
389
+ async latest() {
390
+ try {
391
+ return await this.store.latest();
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+ async verify(query) {
397
+ const entries = await this.range(query);
398
+ return await verifyChain(entries);
399
+ }
400
+ dispose() {
401
+ this.disposed = true;
402
+ }
403
+ };
404
+ function createHistoryRecorder(opts = {}) {
405
+ return new HistoryRecorderImpl(opts);
406
+ }
407
+ async function verifyChain(entries) {
408
+ if (entries.length === 0) {
409
+ return { verified: true, entriesChecked: 0, firstBreakAtSequence: null, reason: null };
410
+ }
411
+ let expectedPrev = null;
412
+ let checked = 0;
413
+ for (let i = 0; i < entries.length; i++) {
414
+ const entry = entries[i];
415
+ if (i === 0 && entry.sequence === 0 && entry.prevHash !== GENESIS_PREV_HASH) {
416
+ return {
417
+ verified: false,
418
+ entriesChecked: checked,
419
+ firstBreakAtSequence: entry.sequence,
420
+ reason: `genesis entry must have prevHash="${GENESIS_PREV_HASH}", got ${entry.prevHash}`
421
+ };
422
+ }
423
+ if (i > 0 && expectedPrev !== null && entry.prevHash !== expectedPrev) {
424
+ return {
425
+ verified: false,
426
+ entriesChecked: checked,
427
+ firstBreakAtSequence: entry.sequence,
428
+ reason: `prevHash mismatch at sequence ${entry.sequence}: expected ${expectedPrev}, got ${entry.prevHash}`
429
+ };
430
+ }
431
+ const recomputedHash = await computeEntryHash({
432
+ sequence: entry.sequence,
433
+ evaluatedAt: entry.evaluatedAt,
434
+ score: entry.score,
435
+ signalsDigest: entry.signalsDigest,
436
+ prevHash: entry.prevHash
437
+ });
438
+ if (entry.entryHash && recomputedHash && recomputedHash !== entry.entryHash) {
439
+ return {
440
+ verified: false,
441
+ entriesChecked: checked,
442
+ firstBreakAtSequence: entry.sequence,
443
+ reason: `entryHash mismatch at sequence ${entry.sequence}: stored=${entry.entryHash} recomputed=${recomputedHash}`
444
+ };
445
+ }
446
+ expectedPrev = entry.entryHash || null;
447
+ checked += 1;
448
+ }
449
+ return {
450
+ verified: true,
451
+ entriesChecked: checked,
452
+ firstBreakAtSequence: null,
453
+ reason: null
454
+ };
455
+ }
456
+
457
+ // src/health/otel.ts
458
+ var SDK_NAME = "@sylphx/sdk/health";
459
+ var SDK_VERSION = "0.11.0";
460
+ var METRIC_NAMES = {
461
+ score: "health.score",
462
+ signalFactor: "health.signal.factor",
463
+ signalValue: "health.signal.value",
464
+ signalValueState: "health.signal.value_state",
465
+ /** ADR-143 §3.3 — increments per evaluation when an upstream cause is observed. */
466
+ downstreamCause: "health.downstream_cause"
467
+ };
468
+ var SPAN_ATTRIBUTES = {
469
+ score: "service.health.score",
470
+ signalFactor: (name) => `service.health.signal.${name}.factor`,
471
+ /** ADR-143 §3.2 — the upstream service that originated the failure chain. */
472
+ causeRoot: "service.health.cause_root",
473
+ /** ADR-143 §3.2 — the signal at the root that failed. */
474
+ causeSignal: "service.health.cause_signal",
475
+ /** ADR-143 §3.2 — comma-delimited hop chain (oldest to newest). */
476
+ causeChain: "service.health.cause_chain"
477
+ };
478
+ var EVENT_NAMES = {
479
+ evaluated: "health.evaluated"
480
+ };
481
+ function createOtelEmitter(opts = {}) {
482
+ return new OtelEmitterImpl(opts);
483
+ }
484
+ var OtelEmitterImpl = class {
485
+ constructor(opts) {
486
+ this.opts = opts;
487
+ this.staticAttrs = opts.attributes ?? {};
488
+ this.events = opts.events === true;
489
+ if (opts.meter) {
490
+ this.instruments = buildInstruments(opts.meter);
491
+ this._state = "ready";
492
+ return;
493
+ }
494
+ void this.initialize();
495
+ }
496
+ _state = "loading";
497
+ instruments = null;
498
+ traceApi = null;
499
+ staticAttrs;
500
+ events;
501
+ get state() {
502
+ return this._state;
503
+ }
504
+ async initialize() {
505
+ const api = await tryLoadOtelApi();
506
+ if (api === null) {
507
+ this._state = "unavailable";
508
+ return;
509
+ }
510
+ if (this._state === "disposed") return;
511
+ try {
512
+ const meter = api.metrics.getMeter(SDK_NAME, SDK_VERSION);
513
+ this.instruments = buildInstruments(meter);
514
+ this.traceApi = api.trace;
515
+ this._state = "ready";
516
+ } catch {
517
+ this._state = "unavailable";
518
+ }
519
+ }
520
+ emit(score, upstreamCause) {
521
+ if (this._state !== "ready" || !this.instruments) return;
522
+ const baseAttrs = { ...this.staticAttrs };
523
+ try {
524
+ this.instruments.scoreGauge.record(score.score, baseAttrs);
525
+ for (const [signalName, rawValue] of Object.entries(score.signals)) {
526
+ const signalAttrs = { ...baseAttrs, "signal.name": signalName };
527
+ const factor = score.signalFactors?.[signalName];
528
+ if (typeof factor === "number" && Number.isFinite(factor)) {
529
+ this.instruments.factorGauge.record(factor, signalAttrs);
530
+ }
531
+ if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
532
+ this.instruments.valueGauge.record(rawValue, signalAttrs);
533
+ } else {
534
+ const stateAttrs = {
535
+ ...signalAttrs,
536
+ state: String(rawValue)
537
+ };
538
+ this.instruments.valueStateCounter.add(1, stateAttrs);
539
+ }
540
+ }
541
+ if (upstreamCause && upstreamCause.causes.length > 0) {
542
+ for (const cause of upstreamCause.causes) {
543
+ this.instruments.downstreamCauseCounter.add(1, {
544
+ ...baseAttrs,
545
+ cause_root: cause.service,
546
+ cause_signal: cause.signal
547
+ });
548
+ }
549
+ }
550
+ if (this.traceApi) {
551
+ const span = this.traceApi.getActiveSpan?.();
552
+ maybeAttachToSpan(span, score, this.events, upstreamCause);
553
+ }
554
+ } catch {
555
+ }
556
+ }
557
+ dispose() {
558
+ this._state = "disposed";
559
+ this.instruments = null;
560
+ this.traceApi = null;
561
+ }
562
+ };
563
+ var cachedApi2;
564
+ async function tryLoadOtelApi() {
565
+ if (cachedApi2 !== void 0) return cachedApi2;
566
+ try {
567
+ cachedApi2 = await import("@opentelemetry/api");
568
+ } catch {
569
+ cachedApi2 = null;
570
+ }
571
+ return cachedApi2;
572
+ }
573
+ function buildInstruments(meter) {
574
+ return {
575
+ scoreGauge: meter.createGauge(METRIC_NAMES.score, {
576
+ description: "Multi-signal health score per ADR-141 \xA74. Range [0, 1]. Higher = healthier.",
577
+ unit: "1"
578
+ }),
579
+ factorGauge: meter.createGauge(METRIC_NAMES.signalFactor, {
580
+ description: "Per-signal health factor; folded into health.score by the SDK scoring strategy (default: weightedProduct, ADR-111 \xA74.3).",
581
+ unit: "1"
582
+ }),
583
+ valueGauge: meter.createGauge(METRIC_NAMES.signalValue, {
584
+ description: "Raw observation behind a health signal (ms, count, rate). Numeric values only; string-valued signals emit health.signal.value_state instead."
585
+ }),
586
+ valueStateCounter: meter.createCounter(METRIC_NAMES.signalValueState, {
587
+ description: 'Per-evaluation counter for non-numeric signal readings (e.g. "ok" / "timeout"). state attribute carries the string label.'
588
+ }),
589
+ downstreamCauseCounter: meter.createCounter(METRIC_NAMES.downstreamCause, {
590
+ description: "Per-evaluation counter incremented when this service observes an upstream cause via OTel baggage (ADR-143). Operators read rate() to size the blast radius of an incident."
591
+ })
592
+ };
593
+ }
594
+ function maybeAttachToSpan(span, score, events, upstreamCause) {
595
+ if (!span || typeof span !== "object") return;
596
+ const setAttribute = span.setAttribute;
597
+ if (typeof setAttribute === "function") {
598
+ setAttribute.call(span, SPAN_ATTRIBUTES.score, score.score);
599
+ for (const [signalName, factor] of Object.entries(score.signalFactors ?? {})) {
600
+ if (typeof factor === "number" && Number.isFinite(factor)) {
601
+ setAttribute.call(span, SPAN_ATTRIBUTES.signalFactor(signalName), factor);
602
+ }
603
+ }
604
+ if (upstreamCause && upstreamCause.causes.length > 0) {
605
+ const primary = upstreamCause.causes[0];
606
+ if (primary) {
607
+ setAttribute.call(span, SPAN_ATTRIBUTES.causeRoot, primary.service);
608
+ setAttribute.call(span, SPAN_ATTRIBUTES.causeSignal, primary.signal);
609
+ }
610
+ if (upstreamCause.chain.length > 0) {
611
+ setAttribute.call(span, SPAN_ATTRIBUTES.causeChain, upstreamCause.chain.join(","));
612
+ }
613
+ }
614
+ }
615
+ if (events) {
616
+ const addEvent = span.addEvent;
617
+ if (typeof addEvent === "function") {
618
+ addEvent.call(span, EVENT_NAMES.evaluated, {
619
+ [SPAN_ATTRIBUTES.score]: score.score,
620
+ "service.health.lastTickAt": score.lastTickAt
621
+ });
622
+ }
623
+ }
624
+ }
625
+
73
626
  // src/health/scoring.ts
74
627
  function clamp01(x) {
75
628
  if (!Number.isFinite(x)) return 0;
@@ -195,6 +748,167 @@ function startUnixSocketServer(source, opts = {}) {
195
748
  return handle;
196
749
  }
197
750
 
751
+ // src/health/middleware.ts
752
+ var DEFAULT_PATH = "/healthz";
753
+ var DEFAULT_SOCKET_PATH = "/var/run/sylphx/health.sock";
754
+ var AUTO_SOCKET_HANDLES = /* @__PURE__ */ new WeakMap();
755
+ function resolveInstance(opts) {
756
+ if (opts.instance) return opts.instance;
757
+ const { path: _path, instance: _instance, unixSocket: _unixSocket, ...sylphxOpts } = opts;
758
+ return sylphxHealth(sylphxOpts);
759
+ }
760
+ function normalizePath(input) {
761
+ if (!input) return DEFAULT_PATH;
762
+ return input.startsWith("/") ? input : `/${input}`;
763
+ }
764
+ function getEnvSocketPath() {
765
+ const proc = globalThis.process;
766
+ const value = proc?.env?.SYLPHX_HEALTH_APP_SOCKET;
767
+ return value && value.trim().length > 0 ? value.trim() : void 0;
768
+ }
769
+ function resolveSocketPath(explicit, envPath) {
770
+ if (explicit) return explicit;
771
+ if (envPath) return envPath;
772
+ return DEFAULT_SOCKET_PATH;
773
+ }
774
+ function resolveUnixSocketOption(input) {
775
+ if (input === false) return null;
776
+ const envPath = getEnvSocketPath();
777
+ if (input === void 0) {
778
+ return envPath ? { path: envPath, required: false } : null;
779
+ }
780
+ if (input === true) {
781
+ return { path: envPath ?? DEFAULT_SOCKET_PATH, required: false };
782
+ }
783
+ return {
784
+ path: resolveSocketPath(input.path, envPath),
785
+ required: input.required ?? false,
786
+ ...input.unlinkOnShutdown !== void 0 ? { unlinkOnShutdown: input.unlinkOnShutdown } : {}
787
+ };
788
+ }
789
+ function bindUnixSocketIfRequested(health, opts) {
790
+ const socket = resolveUnixSocketOption(opts.unixSocket);
791
+ if (!socket) return () => void 0;
792
+ const existing = AUTO_SOCKET_HANDLES.get(health);
793
+ if (existing) return () => void 0;
794
+ try {
795
+ const handle = health.serveUnixSocket({
796
+ path: socket.path,
797
+ ...socket.unlinkOnShutdown !== void 0 ? { unlinkOnShutdown: socket.unlinkOnShutdown } : {}
798
+ });
799
+ AUTO_SOCKET_HANDLES.set(health, handle);
800
+ return () => {
801
+ if (AUTO_SOCKET_HANDLES.get(health) === handle) {
802
+ AUTO_SOCKET_HANDLES.delete(health);
803
+ }
804
+ void handle.shutdown();
805
+ };
806
+ } catch (err) {
807
+ if (socket.required) throw err;
808
+ return () => void 0;
809
+ }
810
+ }
811
+ function honoHelper(opts = {}) {
812
+ const path = normalizePath(opts.path);
813
+ const health = resolveInstance(opts);
814
+ const disposeSocket = bindUnixSocketIfRequested(health, opts);
815
+ const respond = health.handler();
816
+ const middleware = (async (c, next) => {
817
+ if (c.req.path === path) return respond();
818
+ await health.runWithCause(() => next());
819
+ });
820
+ Object.defineProperty(middleware, "health", { value: health, enumerable: true });
821
+ Object.defineProperty(middleware, "dispose", {
822
+ value: () => {
823
+ disposeSocket();
824
+ health.dispose();
825
+ },
826
+ enumerable: false
827
+ });
828
+ return middleware;
829
+ }
830
+ function pathOf(url) {
831
+ if (!url) return "/";
832
+ const q = url.indexOf("?");
833
+ return q === -1 ? url : url.slice(0, q);
834
+ }
835
+ function expressHelper(opts = {}) {
836
+ const path = normalizePath(opts.path);
837
+ const health = resolveInstance(opts);
838
+ const disposeSocket = bindUnixSocketIfRequested(health, opts);
839
+ const respond = health.nodeHandler();
840
+ const middleware = ((req, res, next) => {
841
+ if (pathOf(req.url) === path) {
842
+ void respond(req, res);
843
+ return;
844
+ }
845
+ void health.runWithCause(async () => next());
846
+ });
847
+ Object.defineProperty(middleware, "health", { value: health, enumerable: true });
848
+ Object.defineProperty(middleware, "dispose", {
849
+ value: () => {
850
+ disposeSocket();
851
+ health.dispose();
852
+ },
853
+ enumerable: false
854
+ });
855
+ return middleware;
856
+ }
857
+ function fastifyHelper(opts = {}) {
858
+ const path = normalizePath(opts.path);
859
+ const health = resolveInstance(opts);
860
+ const disposeSocket = bindUnixSocketIfRequested(health, opts);
861
+ const respond = health.handler();
862
+ const plugin = (async (instance) => {
863
+ instance.get(path, async (_req, reply) => {
864
+ const response = await respond();
865
+ const body = await response.text();
866
+ reply.code(response.status);
867
+ reply.header("Content-Type", "application/json");
868
+ reply.header("Cache-Control", "no-store, no-cache, must-revalidate");
869
+ reply.send(body);
870
+ });
871
+ });
872
+ Object.defineProperty(plugin, "health", { value: health, enumerable: true });
873
+ Object.defineProperty(plugin, "dispose", {
874
+ value: () => {
875
+ disposeSocket();
876
+ health.dispose();
877
+ },
878
+ enumerable: false
879
+ });
880
+ return plugin;
881
+ }
882
+ function fetchHelper(opts = {}) {
883
+ const health = resolveInstance(opts);
884
+ const disposeSocket = bindUnixSocketIfRequested(health, opts);
885
+ const respond = health.handler();
886
+ const handler = ((req) => respond(req));
887
+ Object.defineProperty(handler, "health", { value: health, enumerable: true });
888
+ Object.defineProperty(handler, "dispose", {
889
+ value: () => {
890
+ disposeSocket();
891
+ health.dispose();
892
+ },
893
+ enumerable: false
894
+ });
895
+ return handler;
896
+ }
897
+ var withHealth = {
898
+ /** Hono middleware. Mount with `app.use('*', withHealth.hono())`. */
899
+ hono: honoHelper,
900
+ /** Express / Connect / Polka middleware. Mount with `app.use(withHealth.express())`. */
901
+ express: expressHelper,
902
+ /** Fastify plugin. Register with `await fastify.register(withHealth.fastify())`. */
903
+ fastify: fastifyHelper,
904
+ /**
905
+ * Pure Web Fetch handler. Works with Bun.serve, Next.js route
906
+ * handlers, Deno, Cloudflare Workers, and any other `Request →
907
+ * Response` runtime.
908
+ */
909
+ fetch: fetchHelper
910
+ };
911
+
198
912
  // src/health/signals/error-rate.ts
199
913
  function parseWindow(w) {
200
914
  if (typeof w === "number") {
@@ -362,6 +1076,60 @@ function memoryPressureSignal(opts = {}) {
362
1076
  };
363
1077
  }
364
1078
 
1079
+ // src/health/signals/ping.ts
1080
+ var DEFAULT_TIMEOUT_MS = 500;
1081
+ function pingSignal(opts) {
1082
+ if (typeof opts.ping !== "function") {
1083
+ throw new Error(`pingSignal[${opts.name}]: ping must be a function`);
1084
+ }
1085
+ if (typeof opts.name !== "string" || opts.name.length === 0) {
1086
+ throw new Error("pingSignal: name must be a non-empty string");
1087
+ }
1088
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1089
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1090
+ throw new Error(`pingSignal[${opts.name}]: timeoutMs must be > 0, got ${timeoutMs}`);
1091
+ }
1092
+ const weight = opts.weight ?? 1;
1093
+ return {
1094
+ name: opts.name,
1095
+ weight,
1096
+ async read() {
1097
+ let timer;
1098
+ const timeoutPromise = new Promise((resolve) => {
1099
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
1100
+ });
1101
+ try {
1102
+ const result = await Promise.race([opts.ping().then(() => "ok"), timeoutPromise]);
1103
+ if (result === "timeout") {
1104
+ return { value: "timeout", healthFactor: 0 };
1105
+ }
1106
+ return { value: "ok", healthFactor: 1 };
1107
+ } catch (err) {
1108
+ const message = err instanceof Error ? err.message : String(err);
1109
+ return { value: message.slice(0, 120), healthFactor: 0 };
1110
+ } finally {
1111
+ if (timer) clearTimeout(timer);
1112
+ }
1113
+ }
1114
+ };
1115
+ }
1116
+ function databaseSignal(opts) {
1117
+ return pingSignal({
1118
+ name: opts.name ?? "database",
1119
+ ping: opts.ping,
1120
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
1121
+ ...opts.weight !== void 0 ? { weight: opts.weight } : {}
1122
+ });
1123
+ }
1124
+ function redisSignal(opts) {
1125
+ return pingSignal({
1126
+ name: opts.name ?? "redis",
1127
+ ping: opts.ping,
1128
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
1129
+ ...opts.weight !== void 0 ? { weight: opts.weight } : {}
1130
+ });
1131
+ }
1132
+
365
1133
  // src/health/signals/queue-depth.ts
366
1134
  function queueDepthSignal(opts) {
367
1135
  if (typeof opts.getter !== "function") {
@@ -407,15 +1175,41 @@ function queueDepthSignal(opts) {
407
1175
  }
408
1176
 
409
1177
  // src/health/index.ts
1178
+ function resolveOtelEmitter(otelOpt) {
1179
+ if (otelOpt === false) return null;
1180
+ const emitterOpts = otelOpt && typeof otelOpt === "object" ? otelOpt : {};
1181
+ return createOtelEmitter(emitterOpts);
1182
+ }
1183
+ function resolveCausalityHandle(opt) {
1184
+ if (opt === false) return null;
1185
+ const causalityOpts = opt && typeof opt === "object" ? opt : {};
1186
+ return createCausalityHandle(causalityOpts);
1187
+ }
1188
+ function resolveHistoryRecorder(opt) {
1189
+ if (opt === false) return null;
1190
+ const recorderOpts = opt && typeof opt === "object" ? opt : {};
1191
+ return createHistoryRecorder(recorderOpts);
1192
+ }
1193
+ function clampHealthFactor(value) {
1194
+ if (!Number.isFinite(value)) return 0;
1195
+ if (value < 0) return 0;
1196
+ if (value > 1) return 1;
1197
+ return value;
1198
+ }
410
1199
  function sylphxHealth(opts = {}) {
411
1200
  const signals = opts.signals && opts.signals.length > 0 ? [...opts.signals] : [eventLoopLagSignal({ degradedMs: 5e3, deadMs: 3e4, weight: 1 })];
412
1201
  const scoringStrategy = opts.scoringStrategy ?? defaultScoringStrategy();
413
1202
  const now = opts.now ?? (() => /* @__PURE__ */ new Date());
1203
+ const otelEmitter = resolveOtelEmitter(opts.otel);
1204
+ const causality = resolveCausalityHandle(opts.causality);
1205
+ const historyRecorder = resolveHistoryRecorder(opts.history);
414
1206
  let disposed = false;
1207
+ let lastScore = null;
415
1208
  const evaluate = async () => {
416
1209
  if (disposed) {
417
1210
  throw new Error("sylphxHealth: instance disposed");
418
1211
  }
1212
+ const upstreamCause = causality ? causality.readCause() : null;
419
1213
  const readings = await Promise.all(
420
1214
  signals.map(async (signal) => ({
421
1215
  signal,
@@ -424,17 +1218,47 @@ function sylphxHealth(opts = {}) {
424
1218
  );
425
1219
  const score = scoringStrategy(readings);
426
1220
  const signalsMap = {};
1221
+ const signalFactors = {};
427
1222
  for (const { signal, reading } of readings) {
428
1223
  signalsMap[signal.name] = reading.value;
1224
+ signalFactors[signal.name] = reading.unknown === true ? 1 : clampHealthFactor(reading.healthFactor);
429
1225
  }
430
- return {
1226
+ const result = {
431
1227
  score,
432
1228
  signals: signalsMap,
1229
+ signalFactors,
433
1230
  lastTickAt: now().toISOString()
434
1231
  };
1232
+ lastScore = result;
1233
+ if (otelEmitter) {
1234
+ otelEmitter.emit(result, upstreamCause);
1235
+ }
1236
+ if (historyRecorder) {
1237
+ void historyRecorder.record(result);
1238
+ }
1239
+ return result;
435
1240
  };
436
1241
  const evaluator = { evaluate };
437
1242
  const evaluateEffect2 = evaluateEffect(evaluate);
1243
+ const runWithCause = async (fn) => {
1244
+ if (!causality || !lastScore) return await fn();
1245
+ return await causality.runWithCause(fn, lastScore);
1246
+ };
1247
+ const getHistory = async (query = {}) => {
1248
+ if (!historyRecorder) return [];
1249
+ return await historyRecorder.range(query);
1250
+ };
1251
+ const verifyHistory = async (query = {}) => {
1252
+ if (!historyRecorder) {
1253
+ return {
1254
+ verified: false,
1255
+ entriesChecked: 0,
1256
+ firstBreakAtSequence: null,
1257
+ reason: "history recording is disabled on this instance"
1258
+ };
1259
+ }
1260
+ return await historyRecorder.verify(query);
1261
+ };
438
1262
  return {
439
1263
  signals,
440
1264
  evaluate,
@@ -448,9 +1272,18 @@ function sylphxHealth(opts = {}) {
448
1272
  serveUnixSocket(unixOpts) {
449
1273
  return startUnixSocketServer(evaluator, unixOpts);
450
1274
  },
1275
+ getLastScore() {
1276
+ return lastScore;
1277
+ },
1278
+ runWithCause,
1279
+ getHistory,
1280
+ verifyHistory,
451
1281
  dispose() {
452
1282
  if (disposed) return;
453
1283
  disposed = true;
1284
+ if (otelEmitter) otelEmitter.dispose();
1285
+ if (causality) causality.dispose();
1286
+ if (historyRecorder) historyRecorder.dispose();
454
1287
  for (const s of signals) {
455
1288
  try {
456
1289
  s.dispose?.();
@@ -461,15 +1294,34 @@ function sylphxHealth(opts = {}) {
461
1294
  };
462
1295
  }
463
1296
  export {
1297
+ BAGGAGE_KEY_CAUSE,
1298
+ BAGGAGE_KEY_CHAIN,
1299
+ DEFAULT_CAUSE_THRESHOLD,
1300
+ DEFAULT_HISTORY_CAPACITY,
1301
+ GENESIS_PREV_HASH,
464
1302
  HealthError,
1303
+ MAX_CHAIN_LENGTH,
1304
+ EVENT_NAMES as OTEL_EVENT_NAMES,
1305
+ METRIC_NAMES as OTEL_METRIC_NAMES,
1306
+ SPAN_ATTRIBUTES as OTEL_SPAN_ATTRIBUTES,
1307
+ canonicalizeSignals,
1308
+ createCausalityHandle,
1309
+ createHistoryRecorder,
1310
+ createInMemoryHistoryStore,
465
1311
  createNodeHandler,
1312
+ createOtelEmitter,
466
1313
  createWebHandler,
1314
+ databaseSignal,
467
1315
  defaultScoringStrategy,
468
1316
  errorRateSignal,
469
1317
  eventLoopLagSignal,
470
1318
  memoryPressureSignal,
1319
+ pingSignal,
471
1320
  queueDepthSignal,
1321
+ redisSignal,
472
1322
  sylphxHealth,
473
- weightedProduct
1323
+ verifyChain,
1324
+ weightedProduct,
1325
+ withHealth
474
1326
  };
475
1327
  //# sourceMappingURL=index.mjs.map