@sylphx/sdk 0.12.0 → 0.12.2

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