@thotischner/observability-mcp 1.7.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/products.yaml.example +48 -0
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +108 -0
- package/dist/audit/log.js +200 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +53 -1
- package/dist/auth/credentials.test.js +46 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +68 -0
- package/dist/auth/local-users.js +154 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +121 -0
- package/dist/auth/middleware.d.ts +49 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +168 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +66 -0
- package/dist/auth/oidc/runtime.js +142 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +181 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +64 -0
- package/dist/auth/policy/engine.js +87 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +45 -0
- package/dist/auth/policy/loader.js +137 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +173 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +206 -0
- package/dist/auth/rbac.d.ts +62 -0
- package/dist/auth/rbac.js +162 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +183 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +45 -1
- package/dist/context.js +40 -1
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +2124 -73
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/net/egress-policy.js +2 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +654 -6
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +98 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +112 -0
- package/dist/products/loader.js +289 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +257 -0
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +97 -0
- package/dist/quota/limiter.js +161 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +205 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +3083 -88
- package/package.json +32 -5
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Auto-post-mortem synthesizer — Phase F19.
|
|
2
|
+
//
|
|
3
|
+
// Stitches together the existing observability primitives — anomaly
|
|
4
|
+
// history (F15), blast-radius (F13/topology), trace summaries (F13),
|
|
5
|
+
// log-derived error patterns (existing query_logs) — into a single
|
|
6
|
+
// markdown report a human (or LLM) can read in one shot.
|
|
7
|
+
//
|
|
8
|
+
// The synthesizer is pure-ish: it accepts the upstream queries as
|
|
9
|
+
// injected functions so the tool layer can compose them without the
|
|
10
|
+
// synthesizer depending on the entire ConnectorRegistry API. Tests
|
|
11
|
+
// inject fake data and don't need a live demo stack.
|
|
12
|
+
/** Synthesise one report from already-fetched primitives. Pure
|
|
13
|
+
* compute — no I/O. */
|
|
14
|
+
export function synthesizePostmortem(input) {
|
|
15
|
+
const timeline = [...input.anomalies]
|
|
16
|
+
.sort((a, b) => a.ts.localeCompare(b.ts))
|
|
17
|
+
.map((a) => ({ ts: a.ts, service: a.service, score: a.score, severity: a.severity, method: a.method }));
|
|
18
|
+
const contributingSignals = aggregateBySignal(input.anomalies);
|
|
19
|
+
const peakScore = input.anomalies.reduce((m, a) => Math.max(m, a.score), 0);
|
|
20
|
+
const errorTraces = input.traces.filter((t) => t.hasError).length;
|
|
21
|
+
const peakNode = input.blastRadius.nodes.find((n) => n.root) ?? input.blastRadius.nodes[0];
|
|
22
|
+
const blastSize = input.blastRadius.nodes.length;
|
|
23
|
+
const followUps = inferFollowUps(input, { peakScore, errorTraces, blastSize });
|
|
24
|
+
const synopsis = synopsisFor(input, peakScore, errorTraces, blastSize);
|
|
25
|
+
const markdown = renderMarkdown({
|
|
26
|
+
input,
|
|
27
|
+
timeline,
|
|
28
|
+
contributingSignals,
|
|
29
|
+
peakNode,
|
|
30
|
+
peakScore,
|
|
31
|
+
errorTraces,
|
|
32
|
+
blastSize,
|
|
33
|
+
followUps,
|
|
34
|
+
synopsis,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
service: input.service,
|
|
38
|
+
window: input.window,
|
|
39
|
+
fromIso: input.fromIso,
|
|
40
|
+
toIso: input.toIso,
|
|
41
|
+
synopsis,
|
|
42
|
+
markdown,
|
|
43
|
+
sections: {
|
|
44
|
+
timeline,
|
|
45
|
+
blastRadius: { nodes: input.blastRadius.nodes, edgeCount: input.blastRadius.edges.length },
|
|
46
|
+
topTraces: input.traces.slice(0, 10),
|
|
47
|
+
contributingSignals,
|
|
48
|
+
followUps,
|
|
49
|
+
logHighlights: input.logHighlights ?? [],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function aggregateBySignal(anomalies) {
|
|
54
|
+
const groups = new Map();
|
|
55
|
+
for (const a of anomalies) {
|
|
56
|
+
const sig = a.signal ?? a.method;
|
|
57
|
+
const prev = groups.get(sig);
|
|
58
|
+
if (prev)
|
|
59
|
+
prev.push(a.score);
|
|
60
|
+
else
|
|
61
|
+
groups.set(sig, [a.score]);
|
|
62
|
+
}
|
|
63
|
+
return [...groups.entries()]
|
|
64
|
+
.map(([signal, scores]) => ({
|
|
65
|
+
signal,
|
|
66
|
+
count: scores.length,
|
|
67
|
+
meanScore: Math.round((scores.reduce((s, x) => s + x, 0) / scores.length) * 100) / 100,
|
|
68
|
+
}))
|
|
69
|
+
.sort((a, b) => b.meanScore - a.meanScore);
|
|
70
|
+
}
|
|
71
|
+
function inferFollowUps(input, ctx) {
|
|
72
|
+
const out = [];
|
|
73
|
+
if (input.anomalies.length === 0) {
|
|
74
|
+
out.push("No anomaly history found for this service in the window — confirm OMCP_ANOMALY_HISTORY_REMOTE_WRITE is wired and Prometheus is scraping the same TSDB.");
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
if (ctx.peakScore >= 0.9) {
|
|
78
|
+
out.push(`Peak anomaly score ${ctx.peakScore} is critical — review the detector's threshold for service '${input.service}' and consider whether the chosen method (${dominantMethod(input.anomalies)}) suits this signal's distribution.`);
|
|
79
|
+
}
|
|
80
|
+
if (ctx.errorTraces > 0) {
|
|
81
|
+
out.push(`${ctx.errorTraces} trace(s) carried error spans during the window — drill into the slowest via \`query_traces(service="${input.service}", errorsOnly=true)\`.`);
|
|
82
|
+
}
|
|
83
|
+
if (ctx.blastSize > 5) {
|
|
84
|
+
out.push(`Blast radius spans ${ctx.blastSize} nodes — verify that the dependency edges are still accurate (a stale topology snapshot can blow up the radius and miss the real cause).`);
|
|
85
|
+
}
|
|
86
|
+
if ((input.logHighlights ?? []).length > 0) {
|
|
87
|
+
out.push("Log highlights above point at concrete error patterns — promote the recurring ones to an alert or SLO so the next regression catches itself.");
|
|
88
|
+
}
|
|
89
|
+
if (out.length === 0) {
|
|
90
|
+
out.push("All signals look stable for this window — consider closing the incident as a transient anomaly or expanding the time window.");
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function dominantMethod(anomalies) {
|
|
95
|
+
const c = new Map();
|
|
96
|
+
for (const a of anomalies)
|
|
97
|
+
c.set(a.method, (c.get(a.method) ?? 0) + 1);
|
|
98
|
+
return [...c.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
|
|
99
|
+
}
|
|
100
|
+
function synopsisFor(input, peakScore, errorTraces, blastSize) {
|
|
101
|
+
const anomalyCount = input.anomalies.length;
|
|
102
|
+
if (anomalyCount === 0) {
|
|
103
|
+
return `No anomalies recorded for service '${input.service}' between ${input.fromIso} and ${input.toIso}. Either the window was clean, or the history sink wasn't writing at the time.`;
|
|
104
|
+
}
|
|
105
|
+
return [
|
|
106
|
+
`Service '${input.service}' produced ${anomalyCount} anomaly sample(s) between ${input.fromIso} and ${input.toIso}, peaking at ${peakScore}.`,
|
|
107
|
+
`Blast radius at peak covered ${blastSize} node(s); ${errorTraces} trace(s) carried error spans.`,
|
|
108
|
+
].join(" ");
|
|
109
|
+
}
|
|
110
|
+
function renderMarkdown(ctx) {
|
|
111
|
+
const { input, timeline, contributingSignals, peakNode, peakScore, errorTraces, followUps, synopsis } = ctx;
|
|
112
|
+
const lines = [];
|
|
113
|
+
lines.push(`# Post-mortem — ${input.service}`);
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(`> **Window:** \`${input.fromIso}\` → \`${input.toIso}\` (\`${input.window}\`) `);
|
|
116
|
+
lines.push(`> **Tenant:** \`${input.tenant}\` `);
|
|
117
|
+
lines.push(`> **Generated by:** observability-mcp \`generate_postmortem\``);
|
|
118
|
+
lines.push("");
|
|
119
|
+
lines.push("## Synopsis");
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push(synopsis);
|
|
122
|
+
lines.push("");
|
|
123
|
+
lines.push("## Anomaly timeline");
|
|
124
|
+
lines.push("");
|
|
125
|
+
if (timeline.length === 0) {
|
|
126
|
+
lines.push("_No anomaly samples in this window._");
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
lines.push("| ts | service | score | severity | method |");
|
|
130
|
+
lines.push("|---|---|---|---|---|");
|
|
131
|
+
for (const t of timeline.slice(0, 20)) {
|
|
132
|
+
lines.push(`| \`${t.ts}\` | \`${t.service}\` | ${t.score} | ${t.severity} | ${t.method} |`);
|
|
133
|
+
}
|
|
134
|
+
if (timeline.length > 20)
|
|
135
|
+
lines.push(`| … | _${timeline.length - 20} more rows_ | | | |`);
|
|
136
|
+
}
|
|
137
|
+
lines.push("");
|
|
138
|
+
lines.push("## Blast radius at peak");
|
|
139
|
+
lines.push("");
|
|
140
|
+
if (peakNode) {
|
|
141
|
+
lines.push(`Root node: **\`${peakNode.name}\`** (\`${peakNode.kind}\`).`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
lines.push("_Topology snapshot empty._");
|
|
145
|
+
}
|
|
146
|
+
lines.push("");
|
|
147
|
+
if (input.blastRadius.nodes.length > 0) {
|
|
148
|
+
lines.push("| node | kind |");
|
|
149
|
+
lines.push("|---|---|");
|
|
150
|
+
for (const n of input.blastRadius.nodes.slice(0, 30)) {
|
|
151
|
+
lines.push(`| \`${n.name}\`${n.root ? " *(root)*" : ""} | \`${n.kind}\` |`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
lines.push("");
|
|
155
|
+
lines.push(`Edges in radius: **${input.blastRadius.edges.length}**.`);
|
|
156
|
+
lines.push("");
|
|
157
|
+
lines.push("## Contributing signals (ranked)");
|
|
158
|
+
lines.push("");
|
|
159
|
+
if (contributingSignals.length === 0) {
|
|
160
|
+
lines.push("_No anomaly samples to rank._");
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
lines.push("| signal | samples | mean score |");
|
|
164
|
+
lines.push("|---|---|---|");
|
|
165
|
+
for (const s of contributingSignals.slice(0, 10)) {
|
|
166
|
+
lines.push(`| \`${s.signal}\` | ${s.count} | ${s.meanScore} |`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push("## Related traces");
|
|
171
|
+
lines.push("");
|
|
172
|
+
if (input.traces.length === 0) {
|
|
173
|
+
lines.push("_No traces returned for the window. Configure a Tempo / Jaeger source if traces are expected._");
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
lines.push("| trace | service | duration ms | error |");
|
|
177
|
+
lines.push("|---|---|---|---|");
|
|
178
|
+
for (const t of input.traces.slice(0, 10)) {
|
|
179
|
+
lines.push(`| \`${t.traceId}\` | \`${t.rootService}\` | ${t.durationMs} | ${t.hasError ? "yes" : "no"} |`);
|
|
180
|
+
}
|
|
181
|
+
if (errorTraces > 0)
|
|
182
|
+
lines.push(`\n_${errorTraces} of the returned traces carried error spans._`);
|
|
183
|
+
}
|
|
184
|
+
lines.push("");
|
|
185
|
+
if ((input.logHighlights ?? []).length > 0) {
|
|
186
|
+
lines.push("## Log highlights");
|
|
187
|
+
lines.push("");
|
|
188
|
+
for (const l of input.logHighlights)
|
|
189
|
+
lines.push(`- ${l}`);
|
|
190
|
+
lines.push("");
|
|
191
|
+
}
|
|
192
|
+
lines.push("## Suggested follow-ups");
|
|
193
|
+
lines.push("");
|
|
194
|
+
for (const f of followUps)
|
|
195
|
+
lines.push(`- ${f}`);
|
|
196
|
+
lines.push("");
|
|
197
|
+
lines.push("---");
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push(`*Generated by observability-mcp \`generate_postmortem\` — see \`docs/postmortems.md\` for the prompt sources.*`);
|
|
200
|
+
lines.push("");
|
|
201
|
+
// Bound the chunk to keep memory predictable; the rendered report
|
|
202
|
+
// is normally a few KB but a pathological 10k-sample timeline
|
|
203
|
+
// could approach MB without the slice() caps above.
|
|
204
|
+
return lines.join("\n");
|
|
205
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { synthesizePostmortem, } from "./synthesizer.js";
|
|
4
|
+
function input(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
service: "payment",
|
|
7
|
+
window: "1h",
|
|
8
|
+
tenant: "default",
|
|
9
|
+
fromIso: "2026-06-06T00:00:00.000Z",
|
|
10
|
+
toIso: "2026-06-06T01:00:00.000Z",
|
|
11
|
+
anomalies: [],
|
|
12
|
+
blastRadius: { nodes: [], edges: [] },
|
|
13
|
+
traces: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function anomaly(ts, score, method = "mad", severity = "warn", signal) {
|
|
18
|
+
return { ts, service: "payment", score, method, severity, signal };
|
|
19
|
+
}
|
|
20
|
+
test("synthesizePostmortem: empty input returns synopsis + 'no anomalies' follow-up", () => {
|
|
21
|
+
const r = synthesizePostmortem(input());
|
|
22
|
+
assert.match(r.synopsis, /No anomalies recorded/);
|
|
23
|
+
assert.equal(r.sections.timeline.length, 0);
|
|
24
|
+
assert.equal(r.sections.followUps.length, 1);
|
|
25
|
+
assert.match(r.sections.followUps[0], /OMCP_ANOMALY_HISTORY_REMOTE_WRITE/);
|
|
26
|
+
});
|
|
27
|
+
test("synthesizePostmortem: timeline is sorted by ts ascending", () => {
|
|
28
|
+
const r = synthesizePostmortem(input({
|
|
29
|
+
anomalies: [
|
|
30
|
+
anomaly("2026-06-06T00:30:00Z", 0.5),
|
|
31
|
+
anomaly("2026-06-06T00:10:00Z", 0.4),
|
|
32
|
+
anomaly("2026-06-06T00:50:00Z", 0.9),
|
|
33
|
+
],
|
|
34
|
+
}));
|
|
35
|
+
assert.deepEqual(r.sections.timeline.map((t) => t.ts), ["2026-06-06T00:10:00Z", "2026-06-06T00:30:00Z", "2026-06-06T00:50:00Z"]);
|
|
36
|
+
});
|
|
37
|
+
test("synthesizePostmortem: contributing signals aggregated by signal label + ranked by mean score desc", () => {
|
|
38
|
+
const r = synthesizePostmortem(input({
|
|
39
|
+
anomalies: [
|
|
40
|
+
anomaly("2026-06-06T00:10Z", 0.5, "mad", "warn", "request_latency"),
|
|
41
|
+
anomaly("2026-06-06T00:20Z", 0.4, "mad", "warn", "request_latency"),
|
|
42
|
+
anomaly("2026-06-06T00:30Z", 0.95, "seasonality", "critical", "error_rate"),
|
|
43
|
+
],
|
|
44
|
+
}));
|
|
45
|
+
const sigs = r.sections.contributingSignals;
|
|
46
|
+
assert.equal(sigs.length, 2);
|
|
47
|
+
// error_rate (0.95 mean) ranks above request_latency (0.45 mean)
|
|
48
|
+
assert.equal(sigs[0].signal, "error_rate");
|
|
49
|
+
assert.equal(sigs[0].count, 1);
|
|
50
|
+
assert.equal(sigs[0].meanScore, 0.95);
|
|
51
|
+
assert.equal(sigs[1].signal, "request_latency");
|
|
52
|
+
assert.equal(sigs[1].count, 2);
|
|
53
|
+
assert.equal(sigs[1].meanScore, 0.45);
|
|
54
|
+
});
|
|
55
|
+
test("synthesizePostmortem: missing signal label falls back to method", () => {
|
|
56
|
+
const r = synthesizePostmortem(input({ anomalies: [anomaly("2026-06-06T00:10Z", 0.6, "correlator")] }));
|
|
57
|
+
assert.equal(r.sections.contributingSignals[0].signal, "correlator");
|
|
58
|
+
});
|
|
59
|
+
test("synthesizePostmortem: critical peak triggers a follow-up mentioning the threshold", () => {
|
|
60
|
+
const r = synthesizePostmortem(input({ anomalies: [anomaly("2026-06-06T00:30Z", 0.95)] }));
|
|
61
|
+
assert.ok(r.sections.followUps.some((f) => /Peak anomaly score 0\.95/.test(f)));
|
|
62
|
+
});
|
|
63
|
+
test("synthesizePostmortem: errors-in-traces triggers errorsOnly drill-in suggestion", () => {
|
|
64
|
+
const r = synthesizePostmortem(input({
|
|
65
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.6)],
|
|
66
|
+
traces: [
|
|
67
|
+
{ traceId: "aaa", rootName: "GET /pay", rootService: "payment", durationMs: 800, hasError: true },
|
|
68
|
+
],
|
|
69
|
+
}));
|
|
70
|
+
assert.ok(r.sections.followUps.some((f) => /errorsOnly=true/.test(f)));
|
|
71
|
+
});
|
|
72
|
+
test("synthesizePostmortem: large blast radius triggers stale-topology hint", () => {
|
|
73
|
+
const nodes = Array.from({ length: 7 }, (_, i) => ({ id: `n${i}`, kind: "pod", name: `n${i}`, root: i === 0 }));
|
|
74
|
+
const r = synthesizePostmortem(input({
|
|
75
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.6)],
|
|
76
|
+
blastRadius: { nodes, edges: [{ from: "n0", to: "n1", relation: "CALLS" }] },
|
|
77
|
+
}));
|
|
78
|
+
assert.ok(r.sections.followUps.some((f) => /7 nodes/.test(f) && /stale topology/i.test(f)));
|
|
79
|
+
});
|
|
80
|
+
test("synthesizePostmortem: clean window returns a 'stable, consider closing' follow-up", () => {
|
|
81
|
+
// The "all signals stable" branch fires only when:
|
|
82
|
+
// anomalies present (not zero)
|
|
83
|
+
// peak < 0.9
|
|
84
|
+
// no error traces
|
|
85
|
+
// blast radius <= 5
|
|
86
|
+
// no log highlights
|
|
87
|
+
const r = synthesizePostmortem(input({
|
|
88
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.3)],
|
|
89
|
+
blastRadius: { nodes: [{ id: "n0", kind: "pod", name: "n0", root: true }], edges: [] },
|
|
90
|
+
}));
|
|
91
|
+
assert.ok(r.sections.followUps.some((f) => /stable for this window/.test(f)));
|
|
92
|
+
});
|
|
93
|
+
test("synthesizePostmortem: markdown contains every section header in order", () => {
|
|
94
|
+
const r = synthesizePostmortem(input({
|
|
95
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.7)],
|
|
96
|
+
blastRadius: {
|
|
97
|
+
nodes: [{ id: "p", kind: "deployment", name: "payment", root: true }],
|
|
98
|
+
edges: [{ from: "p", to: "rds", relation: "READS_FROM" }],
|
|
99
|
+
},
|
|
100
|
+
traces: [{ traceId: "t", rootName: "GET /pay", rootService: "payment", durationMs: 200, hasError: false }],
|
|
101
|
+
logHighlights: ["payment-service: 12 5xx in window"],
|
|
102
|
+
}));
|
|
103
|
+
for (const heading of [
|
|
104
|
+
"# Post-mortem — payment",
|
|
105
|
+
"## Synopsis",
|
|
106
|
+
"## Anomaly timeline",
|
|
107
|
+
"## Blast radius at peak",
|
|
108
|
+
"## Contributing signals (ranked)",
|
|
109
|
+
"## Related traces",
|
|
110
|
+
"## Log highlights",
|
|
111
|
+
"## Suggested follow-ups",
|
|
112
|
+
]) {
|
|
113
|
+
assert.ok(r.markdown.includes(heading), `markdown missing section: ${heading}`);
|
|
114
|
+
}
|
|
115
|
+
// The order check — anomaly timeline should appear before blast radius
|
|
116
|
+
assert.ok(r.markdown.indexOf("## Anomaly timeline") < r.markdown.indexOf("## Blast radius at peak"));
|
|
117
|
+
});
|
|
118
|
+
test("synthesizePostmortem: timeline > 20 rows is truncated with an ellipsis row", () => {
|
|
119
|
+
const anomalies = Array.from({ length: 25 }, (_, i) => anomaly(`2026-06-06T00:${String(i).padStart(2, "0")}:00Z`, 0.5 + i * 0.01));
|
|
120
|
+
const r = synthesizePostmortem(input({ anomalies }));
|
|
121
|
+
// The structured section has all 25
|
|
122
|
+
assert.equal(r.sections.timeline.length, 25);
|
|
123
|
+
// The markdown table is capped at 20 data rows + an ellipsis row
|
|
124
|
+
// — count rows specifically inside the Anomaly timeline section
|
|
125
|
+
// (other sections also use | ` ... | tables and would inflate a
|
|
126
|
+
// global grep).
|
|
127
|
+
const md = r.markdown;
|
|
128
|
+
const timelineStart = md.indexOf("## Anomaly timeline");
|
|
129
|
+
const blastStart = md.indexOf("## Blast radius at peak");
|
|
130
|
+
const timelineSection = md.slice(timelineStart, blastStart);
|
|
131
|
+
const tableRows = timelineSection.split("\n").filter((l) => l.startsWith("| `")).length;
|
|
132
|
+
assert.equal(tableRows, 20);
|
|
133
|
+
assert.match(timelineSection, /_5 more rows_/);
|
|
134
|
+
});
|
|
135
|
+
test("synthesizePostmortem: report carries the input window + iso bounds back into the structured shape", () => {
|
|
136
|
+
const r = synthesizePostmortem(input({ window: "6h" }));
|
|
137
|
+
assert.equal(r.service, "payment");
|
|
138
|
+
assert.equal(r.window, "6h");
|
|
139
|
+
assert.equal(r.fromIso, "2026-06-06T00:00:00.000Z");
|
|
140
|
+
assert.equal(r.toIso, "2026-06-06T01:00:00.000Z");
|
|
141
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Products — curated, agent-facing collections of tools.
|
|
3
|
+
*
|
|
4
|
+
* A Product is a named bundle that ships with branding metadata
|
|
5
|
+
* (icon, description, version) plus a list of allowed MCP tools.
|
|
6
|
+
* The agent calling /mcp can be told which Product it belongs to
|
|
7
|
+
* (via a future header / arg, slice 2+), and the server can filter
|
|
8
|
+
* tools/list and tools/call responses accordingly.
|
|
9
|
+
*
|
|
10
|
+
* Today's surface (slice 1):
|
|
11
|
+
* - In-memory ProductsStore loaded from OMCP_PRODUCTS_FILE
|
|
12
|
+
* (YAML or JSON). Missing/empty file → empty catalog.
|
|
13
|
+
* - Strict validation: unknown action / unknown resource /
|
|
14
|
+
* unexpected keys reject loudly.
|
|
15
|
+
* - Mtime-poll hot-reload: callers (e.g. each /api/products
|
|
16
|
+
* handler) `await store.maybeReload()` before reading. If the
|
|
17
|
+
* file mtime advanced since the last load, the store re-parses
|
|
18
|
+
* and atomically swaps the in-memory file; parse errors keep
|
|
19
|
+
* the previous good state and log loudly. One `stat()` call per
|
|
20
|
+
* reload-aware request — too cheap to matter vs. the network
|
|
21
|
+
* round-trip, no FSWatcher platform fragility (WSL / NFS).
|
|
22
|
+
*/
|
|
23
|
+
export interface Product {
|
|
24
|
+
/** Stable identifier — used in URLs, audit entries, /api/products/{id}. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Display name shown in the UI / agent dropdown. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** One-sentence description. */
|
|
29
|
+
description?: string;
|
|
30
|
+
/** Allowed MCP tool names. Empty / undefined → all tools allowed. */
|
|
31
|
+
tools?: string[];
|
|
32
|
+
/** Operator-defined version label, e.g. "1.0.0" or "preview". */
|
|
33
|
+
version?: string;
|
|
34
|
+
/** Free-form branding metadata for the UI — icon URL, theme colour, etc. */
|
|
35
|
+
branding?: {
|
|
36
|
+
iconUrl?: string;
|
|
37
|
+
color?: string;
|
|
38
|
+
};
|
|
39
|
+
/** Lifecycle stage: published = visible to agents; staging = admin-only. */
|
|
40
|
+
status?: "published" | "staging";
|
|
41
|
+
/** Tenant this product belongs to. Omitted → "default". */
|
|
42
|
+
tenant?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ProductsFile {
|
|
45
|
+
products: Product[];
|
|
46
|
+
}
|
|
47
|
+
export declare class ProductsLoadError extends Error {
|
|
48
|
+
constructor(msg: string);
|
|
49
|
+
}
|
|
50
|
+
export declare function readProductsFile(path: string | undefined): Promise<ProductsFile>;
|
|
51
|
+
export declare function parseProductsText(text: string, origin: string): ProductsFile;
|
|
52
|
+
/** In-memory store with tenant- and status-aware queries. */
|
|
53
|
+
export declare class ProductsStore {
|
|
54
|
+
private file;
|
|
55
|
+
/** Optional source file path. When set, `maybeReload()` polls its
|
|
56
|
+
* mtime and re-parses on change. Mutations via upsert/delete update
|
|
57
|
+
* `lastMtimeMs` after the caller persists, so the store does not
|
|
58
|
+
* reload its own writes. */
|
|
59
|
+
private path?;
|
|
60
|
+
private lastMtimeMs;
|
|
61
|
+
constructor(file?: ProductsFile, opts?: {
|
|
62
|
+
path?: string;
|
|
63
|
+
initialMtimeMs?: number;
|
|
64
|
+
});
|
|
65
|
+
/** Re-read the source file if its mtime has advanced since the last
|
|
66
|
+
* load. No-op when no path was supplied at construction. Parse or
|
|
67
|
+
* IO errors are logged and the previous good state is kept — the
|
|
68
|
+
* invariant is "the store always reflects a valid catalogue", so a
|
|
69
|
+
* broken edit on disk never takes the running server down. */
|
|
70
|
+
maybeReload(): Promise<{
|
|
71
|
+
reloaded: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
/** Re-stat the source file and pin the mtime cursor to its current
|
|
74
|
+
* value. Call this after a successful write so the store does not
|
|
75
|
+
* treat its own change as an external reload trigger. Best-effort:
|
|
76
|
+
* if the stat fails, the next maybeReload() will simply reload the
|
|
77
|
+
* file once and find it identical. */
|
|
78
|
+
pinMtimeAfterWrite(): Promise<void>;
|
|
79
|
+
/** Return the product list. When `tenant` is set, filters to that
|
|
80
|
+
* tenant (entries without a tenant field treated as "default").
|
|
81
|
+
* When `includeStaging` is false (default), staging products are
|
|
82
|
+
* hidden from the result — admins should pass true. */
|
|
83
|
+
list(opts?: {
|
|
84
|
+
tenant?: string;
|
|
85
|
+
includeStaging?: boolean;
|
|
86
|
+
}): Product[];
|
|
87
|
+
/** Lookup by id. Cross-tenant gets return undefined when `tenant` set. */
|
|
88
|
+
get(id: string, tenant?: string): Product | undefined;
|
|
89
|
+
count(tenant?: string): number;
|
|
90
|
+
replace(file: ProductsFile): void;
|
|
91
|
+
/** Upsert (replace if id exists, else append). Returns the new
|
|
92
|
+
* ProductsFile so the caller can persist it. */
|
|
93
|
+
upsert(product: Product): ProductsFile;
|
|
94
|
+
/** Remove by id. Returns true when the product existed, false
|
|
95
|
+
* otherwise. Caller persists the resulting file. */
|
|
96
|
+
delete(id: string): {
|
|
97
|
+
removed: boolean;
|
|
98
|
+
file: ProductsFile;
|
|
99
|
+
};
|
|
100
|
+
/** Snapshot of the current file (for tests / persistence). */
|
|
101
|
+
snapshot(): ProductsFile;
|
|
102
|
+
}
|
|
103
|
+
/** Validate a single product entry by routing it through the same
|
|
104
|
+
* parser as the file format. Throws ProductsLoadError on any
|
|
105
|
+
* shape problem. Used by PUT /api/products/:id so a typo / wrong
|
|
106
|
+
* type / unknown key gets the same loud rejection a malformed
|
|
107
|
+
* file would. */
|
|
108
|
+
export declare function validateProduct(input: unknown, origin?: string): Product;
|
|
109
|
+
/** Atomic write of the products file. Same tmp+rename pattern as
|
|
110
|
+
* the audit-chain + token-budget snapshot, so a crash mid-write
|
|
111
|
+
* leaves the previous file intact. */
|
|
112
|
+
export declare function writeProductsFile(path: string, file: ProductsFile): Promise<void>;
|