@merittdev/horus 0.12.3 → 0.12.4

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.
Files changed (2) hide show
  1. package/dist/index.cjs +471 -3
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -65880,7 +65880,7 @@ init_cjs_shims();
65880
65880
 
65881
65881
  // ../../packages/core/src/version.ts
65882
65882
  init_cjs_shims();
65883
- var HORUS_VERSION = true ? "0.12.3" : "dev";
65883
+ var HORUS_VERSION = true ? "0.12.4" : "dev";
65884
65884
  var PINNED_SOURCE_VERSION = "2.0.2";
65885
65885
 
65886
65886
  // ../../packages/core/src/evidence.ts
@@ -71622,6 +71622,167 @@ var SourceMemoryVectorIndex = class {
71622
71622
  // ../../packages/connectors/src/contract.ts
71623
71623
  init_cjs_shims();
71624
71624
 
71625
+ // ../../packages/connectors/src/duration.ts
71626
+ init_cjs_shims();
71627
+ function readPath(obj, path) {
71628
+ if (Object.prototype.hasOwnProperty.call(obj, path)) return obj[path];
71629
+ const parts2 = path.split(".");
71630
+ let node = obj;
71631
+ for (const part of parts2) {
71632
+ if (node === null || typeof node !== "object") return void 0;
71633
+ node = node[part];
71634
+ }
71635
+ return node;
71636
+ }
71637
+ var UNIT_MS = {
71638
+ h: 36e5,
71639
+ m: 6e4,
71640
+ s: 1e3,
71641
+ ms: 1,
71642
+ us: 1e-3,
71643
+ "\xB5s": 1e-3,
71644
+ ns: 1e-6
71645
+ };
71646
+ var DURATION_TOKEN = /(\d+(?:\.\d+)?)\s*(ms|us|µs|ns|h|m|s)(?![a-zA-Z])/gi;
71647
+ function parseDurationMs(raw) {
71648
+ if (typeof raw !== "string") return null;
71649
+ const s5 = raw.trim().replace(/^~/, "");
71650
+ if (s5 === "") return null;
71651
+ let total = 0;
71652
+ let matched = false;
71653
+ DURATION_TOKEN.lastIndex = 0;
71654
+ let m5;
71655
+ while ((m5 = DURATION_TOKEN.exec(s5)) !== null) {
71656
+ const value = Number(m5[1]);
71657
+ const unit = m5[2].toLowerCase();
71658
+ const factor = UNIT_MS[unit];
71659
+ if (Number.isFinite(value) && factor !== void 0) {
71660
+ total += value * factor;
71661
+ matched = true;
71662
+ }
71663
+ }
71664
+ if (matched) return total;
71665
+ if (/^\d+(?:\.\d+)?$/.test(s5)) {
71666
+ const n4 = Number(s5);
71667
+ return Number.isFinite(n4) ? n4 : null;
71668
+ }
71669
+ return null;
71670
+ }
71671
+ function extractByRegex(text2, regex) {
71672
+ const m5 = regex.exec(text2);
71673
+ if (m5 === null) return null;
71674
+ const captured = m5[1] ?? m5[0];
71675
+ const v4 = captured.trim();
71676
+ return v4 === "" ? null : v4;
71677
+ }
71678
+ function percentile(sortedAsc, p5) {
71679
+ const n4 = sortedAsc.length;
71680
+ if (n4 === 0) return 0;
71681
+ if (n4 === 1) return sortedAsc[0];
71682
+ const rank = Math.ceil(p5 / 100 * n4);
71683
+ const idx = Math.min(n4 - 1, Math.max(0, rank - 1));
71684
+ return sortedAsc[idx];
71685
+ }
71686
+ function computeStat(durations) {
71687
+ const finite = durations.filter((d3) => Number.isFinite(d3));
71688
+ if (finite.length === 0) {
71689
+ return { avg: 0, p95: 0, count: 0, min: 0, max: 0 };
71690
+ }
71691
+ const sorted = [...finite].sort((a2, b5) => a2 - b5);
71692
+ const sum = sorted.reduce((acc, d3) => acc + d3, 0);
71693
+ return {
71694
+ avg: sum / sorted.length,
71695
+ p95: percentile(sorted, 95),
71696
+ count: sorted.length,
71697
+ min: sorted[0],
71698
+ max: sorted[sorted.length - 1]
71699
+ };
71700
+ }
71701
+ function readDimension(rec, spec) {
71702
+ if (spec.field !== void 0) {
71703
+ const raw = readPath(rec.fields, spec.field);
71704
+ if (raw === null || raw === void 0) {
71705
+ if (spec.pattern === void 0) return null;
71706
+ } else {
71707
+ const v4 = String(raw).trim();
71708
+ if (v4 !== "") return v4;
71709
+ if (spec.pattern === void 0) return null;
71710
+ }
71711
+ }
71712
+ if (spec.pattern !== void 0) {
71713
+ const sourceField = spec.patternField;
71714
+ const text2 = sourceField !== void 0 ? typeof readPath(rec.fields, sourceField) === "string" ? readPath(rec.fields, sourceField) : String(readPath(rec.fields, sourceField) ?? "") : rec.message ?? "";
71715
+ if (text2 === "") return null;
71716
+ try {
71717
+ return extractByRegex(text2, new RegExp(spec.pattern));
71718
+ } catch {
71719
+ return null;
71720
+ }
71721
+ }
71722
+ return null;
71723
+ }
71724
+ function readDuration(rec, opts) {
71725
+ if (opts.durationField !== void 0) {
71726
+ const raw = readPath(rec.fields, opts.durationField);
71727
+ if (typeof raw === "number" && Number.isFinite(raw)) {
71728
+ return opts.durationFieldUnit === "s" ? raw * 1e3 : raw;
71729
+ }
71730
+ if (typeof raw === "string") return parseDurationMs(raw);
71731
+ return null;
71732
+ }
71733
+ const msg = rec.message ?? "";
71734
+ if (msg === "") return null;
71735
+ if (opts.durationPattern !== void 0) {
71736
+ let isolated;
71737
+ try {
71738
+ isolated = extractByRegex(msg, new RegExp(opts.durationPattern));
71739
+ } catch {
71740
+ isolated = null;
71741
+ }
71742
+ return isolated !== null ? parseDurationMs(isolated) : null;
71743
+ }
71744
+ return parseDurationMs(msg);
71745
+ }
71746
+ function extractDurationSamples(records, opts) {
71747
+ const needle = opts.completionText?.toLowerCase();
71748
+ const out2 = [];
71749
+ for (const rec of records) {
71750
+ if (needle !== void 0) {
71751
+ const hay = (rec.message ?? "").toLowerCase();
71752
+ if (!hay.includes(needle)) continue;
71753
+ }
71754
+ const dimension = readDimension(rec, opts.dimension);
71755
+ if (dimension === null) continue;
71756
+ const durationMs = readDuration(rec, opts);
71757
+ if (durationMs === null || !Number.isFinite(durationMs)) continue;
71758
+ out2.push({ dimension, durationMs });
71759
+ }
71760
+ return out2;
71761
+ }
71762
+ function aggregateDurations(samples, dimensionName) {
71763
+ if (samples.length === 0) return null;
71764
+ const grouped = /* @__PURE__ */ new Map();
71765
+ for (const s5 of samples) {
71766
+ const list = grouped.get(s5.dimension);
71767
+ if (list === void 0) grouped.set(s5.dimension, [s5.durationMs]);
71768
+ else list.push(s5.durationMs);
71769
+ }
71770
+ const byValue = {};
71771
+ for (const [value, durations] of grouped) {
71772
+ byValue[value] = computeStat(durations);
71773
+ }
71774
+ return {
71775
+ dimension: dimensionName,
71776
+ unit: "ms",
71777
+ byValue,
71778
+ sampleCount: samples.length
71779
+ };
71780
+ }
71781
+ function durationsByDimension(records, opts) {
71782
+ const samples = extractDurationSamples(records, opts);
71783
+ return aggregateDurations(samples, opts.dimension.name);
71784
+ }
71785
+
71625
71786
  // ../../packages/connectors/src/factory.ts
71626
71787
  init_cjs_shims();
71627
71788
 
@@ -72522,6 +72683,33 @@ var ElasticsearchLogsProvider = class {
72522
72683
  }
72523
72684
  return analysis;
72524
72685
  }
72686
+ async analyzeDurations(opts) {
72687
+ try {
72688
+ const q2 = {
72689
+ level: opts.level ?? "info",
72690
+ limit: opts.limit ?? 500,
72691
+ ...opts.service !== void 0 ? { service: opts.service } : {},
72692
+ ...opts.from !== void 0 ? { from: opts.from } : {},
72693
+ ...opts.to !== void 0 ? { to: opts.to } : {},
72694
+ ...opts.completionText !== void 0 ? { text: opts.completionText, broadText: true } : {}
72695
+ };
72696
+ const records = await this.searchLogs(q2);
72697
+ const normalized = records.map((r) => ({
72698
+ message: r.message,
72699
+ fields: {
72700
+ ...r.raw,
72701
+ ...r.context !== void 0 ? { context: r.context } : {},
72702
+ ...r.eventCode !== void 0 ? { event_code: r.eventCode } : {},
72703
+ ...r.service !== void 0 ? { service_name: r.service } : {},
72704
+ ...r.detail !== void 0 ? { detail: r.detail } : {},
72705
+ message: r.message
72706
+ }
72707
+ }));
72708
+ return durationsByDimension(normalized, opts);
72709
+ } catch {
72710
+ return null;
72711
+ }
72712
+ }
72525
72713
  async checkCompatibility(opts) {
72526
72714
  const fields = [
72527
72715
  this.mapping.timestampField,
@@ -72742,6 +72930,85 @@ function compareWindows(baseline, current, spikeRatio = 1.5) {
72742
72930
  comparisons.sort((a2, b5) => b5.delta - a2.delta);
72743
72931
  return comparisons;
72744
72932
  }
72933
+ function quantileSorted(sortedAsc, p5) {
72934
+ const n4 = sortedAsc.length;
72935
+ if (n4 === 0) return NaN;
72936
+ if (n4 === 1) return sortedAsc[0];
72937
+ const pos = (n4 - 1) * p5;
72938
+ const lo = Math.floor(pos);
72939
+ const hi = Math.ceil(pos);
72940
+ if (lo === hi) return sortedAsc[lo];
72941
+ const frac = pos - lo;
72942
+ return sortedAsc[lo] * (1 - frac) + sortedAsc[hi] * frac;
72943
+ }
72944
+ function median(values2) {
72945
+ const finite = values2.filter((v4) => Number.isFinite(v4));
72946
+ if (finite.length === 0) return NaN;
72947
+ const sorted = [...finite].sort((a2, b5) => a2 - b5);
72948
+ return quantileSorted(sorted, 0.5);
72949
+ }
72950
+ function detectBimodalPopulation(comparisons, opts) {
72951
+ const spike = opts?.spike ?? 1.5;
72952
+ const drop = opts?.drop ?? 0.67;
72953
+ const minShare = opts?.minShare ?? 0.2;
72954
+ const minMinority = opts?.minMinority ?? 2;
72955
+ const minTotal = opts?.minTotal ?? 3;
72956
+ const gapFactor = opts?.gapFactor ?? 2;
72957
+ const total = comparisons.length;
72958
+ const empty = {
72959
+ bimodal: false,
72960
+ total,
72961
+ highCount: 0,
72962
+ lowCount: 0,
72963
+ degenerateCount: 0,
72964
+ highShare: 0,
72965
+ lowShare: 0,
72966
+ highRatio: NaN,
72967
+ lowRatio: NaN
72968
+ };
72969
+ const highRatios = [];
72970
+ const lowRatios = [];
72971
+ let degenerateCount = 0;
72972
+ for (const c4 of comparisons) {
72973
+ const r = c4.ratio;
72974
+ if (!Number.isFinite(r)) {
72975
+ degenerateCount += 1;
72976
+ } else if (r >= spike) {
72977
+ highRatios.push(r);
72978
+ } else if (r > 0 && r <= drop) {
72979
+ lowRatios.push(r);
72980
+ } else if (r === 0) {
72981
+ degenerateCount += 1;
72982
+ }
72983
+ }
72984
+ const highCount = highRatios.length;
72985
+ const lowCount = lowRatios.length;
72986
+ const counterCount = lowCount + degenerateCount;
72987
+ const result = {
72988
+ ...empty,
72989
+ highCount,
72990
+ lowCount,
72991
+ degenerateCount,
72992
+ highShare: total > 0 ? highCount / total : 0,
72993
+ lowShare: total > 0 ? counterCount / total : 0,
72994
+ highRatio: median(highRatios),
72995
+ lowRatio: median(lowRatios)
72996
+ };
72997
+ if (total < minTotal) return result;
72998
+ if (highCount < 1 || counterCount < 1) return result;
72999
+ if (counterCount < minMinority) return result;
73000
+ if (result.lowShare < minShare) return result;
73001
+ if (highCount < minMinority && result.highShare < minShare) return result;
73002
+ if (lowCount > 0) {
73003
+ const hi = result.highRatio;
73004
+ const lo = result.lowRatio;
73005
+ if (Number.isFinite(hi) && Number.isFinite(lo) && lo > 0 && hi / lo < gapFactor) {
73006
+ return result;
73007
+ }
73008
+ }
73009
+ result.bimodal = true;
73010
+ return result;
73011
+ }
72745
73012
 
72746
73013
  // ../../packages/connectors/src/grafana/panels.ts
72747
73014
  init_cjs_shims();
@@ -72869,8 +73136,19 @@ function classifyAnomaly(kind, cmp, opts) {
72869
73136
  }
72870
73137
  function buildFindings(dashboardUid, panelTitle, kind, baseline, current) {
72871
73138
  const comparisons = compareWindows(baseline, current);
73139
+ const bimodal = detectBimodalPopulation(comparisons);
73140
+ const spike = 1.5;
73141
+ const drop = 0.67;
73142
+ const isBimodalMember = (cmp) => {
73143
+ const r = cmp.ratio;
73144
+ if (!Number.isFinite(r)) return true;
73145
+ if (r >= spike) return true;
73146
+ if (r === 0) return true;
73147
+ return r > 0 && r <= drop;
73148
+ };
72872
73149
  return comparisons.map((cmp) => {
72873
- const anomaly = classifyAnomaly(kind, cmp);
73150
+ const baseAnomaly = classifyAnomaly(kind, cmp);
73151
+ const anomaly = bimodal.bimodal && baseAnomaly !== "none" && isBimodalMember(cmp) ? "bimodal-population" : baseAnomaly;
72874
73152
  const currentSeries = current.find((s5) => {
72875
73153
  const keys = Object.keys(cmp.labels);
72876
73154
  return keys.every((k3) => s5.labels[k3] === cmp.labels[k3]);
@@ -72901,6 +73179,8 @@ function anomalyLabel(anomaly) {
72901
73179
  return "Queue Growth";
72902
73180
  case "change":
72903
73181
  return "Change";
73182
+ case "bimodal-population":
73183
+ return "Bimodal Population (possible per-segment variance)";
72904
73184
  case "none":
72905
73185
  return "No Anomaly";
72906
73186
  }
@@ -72915,6 +73195,11 @@ function anomalyRelevance(anomaly) {
72915
73195
  return 0.8;
72916
73196
  case "change":
72917
73197
  return 0.55;
73198
+ // Lower than a confident single-cause spike: a two-population pattern is a
73199
+ // hypothesis ("per-segment variance"), not a uniform regression — keep the
73200
+ // engine honest by NOT presenting it with spike-level weight (HOR-435).
73201
+ case "bimodal-population":
73202
+ return 0.6;
72918
73203
  case "none":
72919
73204
  return 0;
72920
73205
  }
@@ -74045,6 +74330,37 @@ var AxiomProvider = class {
74045
74330
  return [];
74046
74331
  }
74047
74332
  }
74333
+ /**
74334
+ * INFO-level duration coverage (HOR-434). Query NON-error completion/duration rows
74335
+ * (e.g. `Completed MANAGE_SALES:KSA ~2m10s`) and aggregate duration by an extracted
74336
+ * dimension (region / market / tenant) — from a structured field or a regex over the
74337
+ * job id / message. Returns per-dimension stats `{ region: { KSA: {avg,p95,count,…} } }`
74338
+ * so the engine sees per-segment variance the error-biased `collect()` path misses.
74339
+ *
74340
+ * Graceful: no completion rows, no parseable duration, or no extractable dimension
74341
+ * → `null`. Never throws.
74342
+ */
74343
+ async analyzeDurations(opts) {
74344
+ try {
74345
+ const to = opts.to ?? (/* @__PURE__ */ new Date()).toISOString();
74346
+ const from = opts.from ?? new Date(
74347
+ Date.parse(to) - (this.opts.windowMs ?? DEFAULT_WINDOW_MS)
74348
+ ).toISOString();
74349
+ const apl = buildApl(
74350
+ this.opts.dataset,
74351
+ opts.completionText !== void 0 ? [opts.completionText] : [],
74352
+ opts.limit ?? 500
74353
+ );
74354
+ const rows = await this.client.query(apl, from, to);
74355
+ const normalized = rows.map((rec) => ({
74356
+ message: pickField2(rec.fields, MESSAGE_FIELDS) ?? "",
74357
+ fields: rec.fields
74358
+ }));
74359
+ return durationsByDimension(normalized, opts);
74360
+ } catch {
74361
+ return null;
74362
+ }
74363
+ }
74048
74364
  async health() {
74049
74365
  return this.client.health();
74050
74366
  }
@@ -92806,6 +93122,36 @@ function generateHypotheses(evidence2, _correlation, ctx) {
92806
93122
  contradictingEvidenceIds: [],
92807
93123
  missingEvidence: infraSupport.length > 0 ? [] : ["Infrastructure metrics + datastore/cache state (`horus metrics`)"]
92808
93124
  });
93125
+ const benignSupport = [
93126
+ .../* @__PURE__ */ new Set([
93127
+ ...ctx.perDimensionDurationEvIds ?? [],
93128
+ ...ctx.bimodalMetricEvIds ?? []
93129
+ ])
93130
+ ];
93131
+ if (benignSupport.length > 0 || ctx.benignVarianceApplicable === true) {
93132
+ hyps.push({
93133
+ id: globalThis.crypto.randomUUID(),
93134
+ category: "benign-variance",
93135
+ statement: "The anomaly may reflect expected per-segment/per-region variance (or a skewed average), not a failure.",
93136
+ confidence: 0.1,
93137
+ supportingEvidenceIds: benignSupport,
93138
+ contradictingEvidenceIds: [],
93139
+ missingEvidence: benignSupport.length > 0 ? [] : [
93140
+ "Per-segment duration breakdown (region/market/tenant) or a bimodal metric distribution (`horus logs` / `horus metrics`)"
93141
+ ]
93142
+ });
93143
+ }
93144
+ const suggested = new Set(ctx.alertSuggestedCategories ?? []);
93145
+ if (suggested.size > 0) {
93146
+ for (const h3 of hyps) {
93147
+ if (suggested.has(h3.category) && h3.supportingEvidenceIds.length === 0) {
93148
+ h3.missingEvidence = [
93149
+ ...h3.missingEvidence,
93150
+ "Suggested by the alert/hint text \u2014 confidence requires independent evidence, not the alert wording itself"
93151
+ ];
93152
+ }
93153
+ }
93154
+ }
92809
93155
  hyps.sort((a2, b5) => b5.confidence - a2.confidence);
92810
93156
  return hyps;
92811
93157
  }
@@ -92853,6 +93199,26 @@ function validateHypotheses(hyps, evidence2) {
92853
93199
  rationale
92854
93200
  };
92855
93201
  });
93202
+ const SHARED_EVIDENCE_CEILING = 0.75;
93203
+ const bySupport = /* @__PURE__ */ new Map();
93204
+ for (const v4 of validated) {
93205
+ if (v4.contradictingPresent > 0) continue;
93206
+ const presentSupport = v4.supportingEvidenceIds.filter((id) => present.has(id)).sort();
93207
+ if (presentSupport.length === 0) continue;
93208
+ const key = presentSupport.join("|");
93209
+ const list = bySupport.get(key);
93210
+ if (list === void 0) bySupport.set(key, [v4]);
93211
+ else list.push(v4);
93212
+ }
93213
+ for (const cohort of bySupport.values()) {
93214
+ if (cohort.length < 2) continue;
93215
+ for (const v4 of cohort) {
93216
+ if (v4.confidence > SHARED_EVIDENCE_CEILING) {
93217
+ v4.confidence = SHARED_EVIDENCE_CEILING;
93218
+ v4.rationale = v4.rationale.replace(/\.$/, "") + `; capped at ${SHARED_EVIDENCE_CEILING} \u2014 shares all supporting evidence with ${cohort.length - 1} other hypothesis(es) and nothing distinguishes them.`;
93219
+ }
93220
+ }
93221
+ }
92856
93222
  validated.sort((a2, b5) => {
92857
93223
  const aElim = a2.verdict === "eliminated" ? 1 : 0;
92858
93224
  const bElim = b5.verdict === "eliminated" ? 1 : 0;
@@ -95004,6 +95370,46 @@ function looksPerformance(hint) {
95004
95370
  hint
95005
95371
  );
95006
95372
  }
95373
+ function looksDurationThemed(hint) {
95374
+ return looksPerformance(hint) || /\b(duration|took|elapsed|anomal(?:y|ies|ous)|spike|variance|regress(?:ion|ed)?|p\d{2})\b/i.test(
95375
+ hint
95376
+ );
95377
+ }
95378
+ function parseSuggestedCauses(hint) {
95379
+ const SUGGESTIVE = /\b(could indicate|may be|might be|maybe|possibly|suggest(?:s|ing|ed)?|likely (?:caused|due)|points? to|consistent with|indicat(?:es|ive)|appears? to be|seems? to be|probably)\b/i;
95380
+ if (!SUGGESTIVE.test(hint)) return [];
95381
+ const CATEGORY_CUES = [
95382
+ ["deployment-regression", /\b(deploy(?:ment|ed)?|release|rollout|regression|recent change|shipped)\b/i],
95383
+ ["infrastructure", /\b(database|db|infra(?:structure)?|network|cache|disk|memory|cpu|oom|out of memory|connection pool|datastore|redis|mongo|postgres)\b/i],
95384
+ ["external-api-latency", /\b(upstream|external api|third[- ]party|downstream|api latency|timeout|slow (?:api|service)|gateway)\b/i],
95385
+ ["retry-storm", /\b(retr(?:y|ies)|retry storm|thundering herd|amplif)/i],
95386
+ ["queue-backlog", /\b(queue|backlog|consumer lag|enqueue)\b/i],
95387
+ ["worker-slowdown", /\b(worker|consumer)\b/i],
95388
+ ["benign-variance", /\b(per[- ]segment|per[- ]region|skew(?:ed)?|variance|expected variation|noise)\b/i]
95389
+ ];
95390
+ const out2 = [];
95391
+ for (const [category, cue] of CATEGORY_CUES) {
95392
+ if (cue.test(hint)) out2.push(category);
95393
+ }
95394
+ return out2;
95395
+ }
95396
+ function formatDurationMs(ms) {
95397
+ if (!Number.isFinite(ms) || ms < 0) return "?";
95398
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
95399
+ const totalSec = ms / 1e3;
95400
+ if (totalSec < 60) {
95401
+ const s5 = Math.round(totalSec * 10) / 10;
95402
+ return `${s5}s`;
95403
+ }
95404
+ const totalMin = Math.floor(totalSec / 60);
95405
+ const remSec = Math.round(totalSec % 60);
95406
+ if (totalMin < 60) {
95407
+ return remSec > 0 ? `${totalMin}m${remSec}s` : `${totalMin}m`;
95408
+ }
95409
+ const h3 = Math.floor(totalMin / 60);
95410
+ const remMin = totalMin % 60;
95411
+ return remMin > 0 ? `${h3}h${remMin}m` : `${h3}h`;
95412
+ }
95007
95413
  var SOURCE_IMPACT_RE = [
95008
95414
  /\bwhat depends on\b/i,
95009
95415
  /\bblast[- ]?radius\b/i,
@@ -95154,6 +95560,7 @@ async function investigate(input, deps) {
95154
95560
  const hint = input.hint.trim();
95155
95561
  const intent = classifyIntent(hint, input.since);
95156
95562
  const namedSymbols = parseNamedSymbols(hint);
95563
+ const alertSuggestedCategories = parseSuggestedCauses(hint);
95157
95564
  const sourceImpactHint = intent === "source-impact";
95158
95565
  const preferNamed = sourceImpactHint && namedSymbols.length > 0 ? namedSymbols[0] : void 0;
95159
95566
  const evidence2 = [];
@@ -95478,6 +95885,7 @@ async function investigate(input, deps) {
95478
95885
  const logEvIds = [];
95479
95886
  const directLogEvIds = [];
95480
95887
  const ambientLogEvIds = [];
95888
+ const perDimensionDurationEvIds = [];
95481
95889
  const directSignatures = [];
95482
95890
  const directEvIdByKey = /* @__PURE__ */ new Map();
95483
95891
  const seedEmittedJoins = [];
@@ -95896,6 +96304,55 @@ async function investigate(input, deps) {
95896
96304
  axiomCollected = false;
95897
96305
  }
95898
96306
  }
96307
+ if (looksDurationThemed(hint)) {
96308
+ const from = logWindowFrom(input.logsSince ?? input.since);
96309
+ const baseDurationOpts = input.durationDimension ?? {
96310
+ // Conservative default for the common `Completed <JOB>:<REGION> ~2m10s` shape: extract an
96311
+ // alphanumeric token after a colon as the dimension, scoped to completion lines, parsing
96312
+ // the duration out of the message. A non-matching corpus yields null (graceful).
96313
+ dimension: { name: "region", pattern: ":\\s*([A-Za-z][\\w-]+)" },
96314
+ completionText: "Completed"
96315
+ };
96316
+ const durationOpts = {
96317
+ ...baseDurationOpts,
96318
+ from: baseDurationOpts.from ?? from,
96319
+ ...input.service !== void 0 && baseDurationOpts.service === void 0 ? { service: input.service } : {},
96320
+ level: baseDurationOpts.level ?? "info"
96321
+ };
96322
+ const durationProviders = [];
96323
+ if (deps.logs && typeof deps.logs.analyzeDurations === "function") {
96324
+ durationProviders.push({ source: "elasticsearch", analyze: (o5) => deps.logs.analyzeDurations(o5) });
96325
+ }
96326
+ if (deps.axiom && typeof deps.axiom.analyzeDurations === "function") {
96327
+ durationProviders.push({ source: "axiom", analyze: (o5) => deps.axiom.analyzeDurations(o5) });
96328
+ }
96329
+ for (const provider of durationProviders) {
96330
+ try {
96331
+ const result = await provider.analyze(durationOpts);
96332
+ if (result === null) continue;
96333
+ const values2 = Object.entries(result.byValue);
96334
+ if (values2.length === 0) continue;
96335
+ const summary2 = values2.sort((a2, b5) => b5[1].avg - a2[1].avg).map(([val, st3]) => `${val} ${formatDurationMs(st3.avg)}`).join(", ");
96336
+ const ev = mkEv(
96337
+ "log",
96338
+ `Duration by ${result.dimension}: ${summary2}`.slice(0, 220),
96339
+ {
96340
+ source: provider.source,
96341
+ kind: "duration-by-dimension",
96342
+ dimension: result.dimension,
96343
+ unit: result.unit,
96344
+ byValue: result.byValue,
96345
+ sampleCount: result.sampleCount
96346
+ },
96347
+ {},
96348
+ void 0,
96349
+ 0.6
96350
+ );
96351
+ perDimensionDurationEvIds.push(ev.id);
96352
+ } catch {
96353
+ }
96354
+ }
96355
+ }
95899
96356
  let stateAnalysis = null;
95900
96357
  const stateEvIds = [];
95901
96358
  const stateCollections = /* @__PURE__ */ new Set();
@@ -95986,6 +96443,7 @@ async function investigate(input, deps) {
95986
96443
  const METRICS_TIMEOUT_MS = 3e4;
95987
96444
  const metricEvIds = [];
95988
96445
  const latencyMetricEvIds = [];
96446
+ const bimodalMetricEvIds = [];
95989
96447
  const queueMetricEvIds = [];
95990
96448
  const queueMetricEvIdsByQueue = /* @__PURE__ */ new Map();
95991
96449
  const nominalMetricEvIds = [];
@@ -96037,6 +96495,8 @@ async function investigate(input, deps) {
96037
96495
  );
96038
96496
  const genericPerf = serviceFilter.length === 0 && hintIsPerf && f5.anomaly === "latency-spike";
96039
96497
  if (matchesService || matchesHint || genericPerf) latencyMetricEvIds.push(ev.id);
96498
+ } else if (f5.anomaly === "bimodal-population") {
96499
+ bimodalMetricEvIds.push(ev.id);
96040
96500
  } else if (f5.anomaly === "queue-growth") {
96041
96501
  const panelNorm = f5.panelTitle.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
96042
96502
  const labelNorms = Object.values(f5.labels).map(
@@ -96107,7 +96567,13 @@ async function investigate(input, deps) {
96107
96567
  sinceProvided: input.since !== void 0,
96108
96568
  suppressRuntimeHypotheses: sourceImpactHint,
96109
96569
  recentChangeRelevant,
96110
- graph
96570
+ graph,
96571
+ // HOR-435: per-segment duration breakdown (#2) + bimodal-population metric (#3) support
96572
+ // the benign-variance hypothesis (#4); the suggested categories de-anchor alert text (#1).
96573
+ perDimensionDurationEvIds,
96574
+ bimodalMetricEvIds,
96575
+ benignVarianceApplicable: looksDurationThemed(hint) || perDimensionDurationEvIds.length > 0 || bimodalMetricEvIds.length > 0 || latencyMetricEvIds.length > 0,
96576
+ alertSuggestedCategories
96111
96577
  });
96112
96578
  const validated = validateHypotheses(hyps, evidence2);
96113
96579
  const causeChains = buildCauseChains(validated, evidence2, graph, label);
@@ -119235,6 +119701,8 @@ function anomalyLabel2(anomaly) {
119235
119701
  return "QUEUE-GROWTH";
119236
119702
  case "change":
119237
119703
  return "CHANGE";
119704
+ case "bimodal-population":
119705
+ return "BIMODAL-POPULATION";
119238
119706
  case "none":
119239
119707
  return "OK";
119240
119708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merittdev/horus",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "description": "Local-first, source-aware production-incident investigation engine",
5
5
  "type": "module",
6
6
  "bin": {