@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,48 @@
|
|
|
1
|
+
# MCP Products catalog — copy to products.yaml + set OMCP_PRODUCTS_FILE
|
|
2
|
+
# to its path. See docs/products.md for the field reference.
|
|
3
|
+
#
|
|
4
|
+
# Every entry needs at minimum an id + name. Everything else is
|
|
5
|
+
# optional. The server validates this file at boot via the same
|
|
6
|
+
# parseProductsText pipeline the loader test suite exercises — a
|
|
7
|
+
# typo (e.g. `toolss:` instead of `tools:`) fails the boot loudly
|
|
8
|
+
# rather than silently dropping a grant.
|
|
9
|
+
|
|
10
|
+
products:
|
|
11
|
+
- id: ops-bundle
|
|
12
|
+
name: Operations Bundle
|
|
13
|
+
description: Incident-response tools for the on-call SRE agent.
|
|
14
|
+
tools:
|
|
15
|
+
- query_logs
|
|
16
|
+
- query_metrics
|
|
17
|
+
- get_service_health
|
|
18
|
+
version: "1.0.0"
|
|
19
|
+
status: published
|
|
20
|
+
branding:
|
|
21
|
+
iconUrl: https://example.com/icons/ops.svg
|
|
22
|
+
color: "#3178c6"
|
|
23
|
+
|
|
24
|
+
- id: dev-bundle
|
|
25
|
+
name: Developer Bundle
|
|
26
|
+
description: Discovery + topology tools for the coding agent. Read-only.
|
|
27
|
+
tools:
|
|
28
|
+
- list_services
|
|
29
|
+
- get_topology
|
|
30
|
+
status: published
|
|
31
|
+
|
|
32
|
+
- id: experimental-bundle
|
|
33
|
+
name: Experimental Bundle
|
|
34
|
+
description: New tools being trialled internally. Hidden from agents.
|
|
35
|
+
tools:
|
|
36
|
+
- query_logs
|
|
37
|
+
- get_topology
|
|
38
|
+
status: staging # admin-only until promoted
|
|
39
|
+
|
|
40
|
+
# Multi-tenant example: a product visible only to the "acme" tenant.
|
|
41
|
+
# Remove the tenant: line (or leave it unset) to land in "default".
|
|
42
|
+
- id: acme-compliance
|
|
43
|
+
name: Acme Compliance Read-Only
|
|
44
|
+
description: Logs query for Acme's compliance team agent.
|
|
45
|
+
tools:
|
|
46
|
+
- query_logs
|
|
47
|
+
status: published
|
|
48
|
+
tenant: acme
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface AnomalyRecord {
|
|
2
|
+
/** ISO-8601 timestamp (the moment the score was computed). */
|
|
3
|
+
ts: string;
|
|
4
|
+
service: string;
|
|
5
|
+
tenant: string;
|
|
6
|
+
/** Anomaly score, 0..1 typically. Numeric — the sample written to the TSDB. */
|
|
7
|
+
score: number;
|
|
8
|
+
/** "mad" | "seasonality" | "correlator" — the method that produced the score. */
|
|
9
|
+
method: string;
|
|
10
|
+
/** "info" | "warn" | "critical". */
|
|
11
|
+
severity: string;
|
|
12
|
+
/** Optional source label (which signal the score applied to). */
|
|
13
|
+
signal?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface AnomalyHistoryConfig {
|
|
16
|
+
/** Remote-write URL. Setting this enables the history sink. */
|
|
17
|
+
url?: string;
|
|
18
|
+
/** Comma-separated key=value pairs for extra request headers. */
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
/** Bearer token forwarded as Authorization header. */
|
|
21
|
+
bearerToken?: string;
|
|
22
|
+
/** Flush interval in ms. Default 10 000. */
|
|
23
|
+
flushIntervalMs?: number;
|
|
24
|
+
/** Max buffer size before a synchronous flush. Default 500. */
|
|
25
|
+
maxBufferSize?: number;
|
|
26
|
+
/** Per-request timeout (ms). Default 5 000. */
|
|
27
|
+
requestTimeoutMs?: number;
|
|
28
|
+
/** Inject fetch for tests. */
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
export declare function fromEnv(env?: NodeJS.ProcessEnv): AnomalyHistoryConfig;
|
|
32
|
+
/**
|
|
33
|
+
* In-process buffer + remote-write client. Use one instance per
|
|
34
|
+
* gateway process; `record()` is called from the anomaly detector;
|
|
35
|
+
* `flush()` runs on the interval AND on SIGTERM.
|
|
36
|
+
*/
|
|
37
|
+
export declare class AnomalyHistory {
|
|
38
|
+
private readonly url?;
|
|
39
|
+
private readonly headers;
|
|
40
|
+
private readonly bearerToken?;
|
|
41
|
+
private readonly flushIntervalMs;
|
|
42
|
+
private readonly maxBufferSize;
|
|
43
|
+
private readonly requestTimeoutMs;
|
|
44
|
+
private readonly fetchImpl;
|
|
45
|
+
private buffer;
|
|
46
|
+
private timer?;
|
|
47
|
+
private flushing;
|
|
48
|
+
constructor(cfg?: AnomalyHistoryConfig);
|
|
49
|
+
/** Whether the history sink is enabled (URL configured). */
|
|
50
|
+
isEnabled(): boolean;
|
|
51
|
+
/** Begin the flush timer. Idempotent. No-op when sink is disabled. */
|
|
52
|
+
start(): void;
|
|
53
|
+
/** Stop the flush timer + flush one last time. */
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
/** Add one anomaly to the buffer. Silently drops when disabled.
|
|
56
|
+
* Triggers a synchronous flush if the buffer crosses maxBufferSize. */
|
|
57
|
+
record(entry: AnomalyRecord): Promise<void>;
|
|
58
|
+
/** Send the current buffer to the remote-write endpoint. Drops the
|
|
59
|
+
* buffer on success OR failure — history is best-effort. */
|
|
60
|
+
flush(): Promise<void>;
|
|
61
|
+
/** Test seam: number of currently-buffered entries. */
|
|
62
|
+
bufferSize(): number;
|
|
63
|
+
/** Visible-for-test: format the buffer as a JSON payload mirroring
|
|
64
|
+
* the Prometheus remote-write metric shape. Real remote-write uses
|
|
65
|
+
* Snappy-compressed protobuf — we ship JSON here as a portable
|
|
66
|
+
* baseline that any TSDB-receiving collector can ingest via a tiny
|
|
67
|
+
* shim. A protobuf+Snappy fast path is a follow-up. */
|
|
68
|
+
formatBatch(batch: AnomalyRecord[]): unknown;
|
|
69
|
+
private sendBatch;
|
|
70
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Anomaly history — persists per-anomaly scores to an external TSDB
|
|
2
|
+
// via Prometheus remote-write so post-mortems can replay what the
|
|
3
|
+
// gateway saw at a specific time. Opt-in via
|
|
4
|
+
// OMCP_ANOMALY_HISTORY_REMOTE_WRITE; default OFF preserves the
|
|
5
|
+
// pre-F15 "scores live in process memory only" behaviour.
|
|
6
|
+
//
|
|
7
|
+
// Wire format: a single time-series sample per recorded anomaly,
|
|
8
|
+
// labelled with service / tenant / signal / method (mad / seasonality
|
|
9
|
+
// / correlator) / severity. The TSDB-side query then becomes a
|
|
10
|
+
// `omcp_anomaly_score{service="payment"}` PromQL.
|
|
11
|
+
//
|
|
12
|
+
// Buffering: writes are batched on a fixed flush interval so the
|
|
13
|
+
// remote-write side never sees a request per anomaly. Failure to
|
|
14
|
+
// flush logs once and drops the buffer — the gateway must never
|
|
15
|
+
// block on a sick TSDB.
|
|
16
|
+
export function fromEnv(env = process.env) {
|
|
17
|
+
const url = env.OMCP_ANOMALY_HISTORY_REMOTE_WRITE?.trim();
|
|
18
|
+
const headers = {};
|
|
19
|
+
const raw = env.OMCP_ANOMALY_HISTORY_HEADERS;
|
|
20
|
+
if (raw) {
|
|
21
|
+
for (const part of raw.split(",")) {
|
|
22
|
+
const [k, ...rest] = part.split("=");
|
|
23
|
+
const key = k?.trim();
|
|
24
|
+
const value = rest.join("=").trim();
|
|
25
|
+
if (key && value)
|
|
26
|
+
headers[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
url: url || undefined,
|
|
31
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
32
|
+
bearerToken: env.OMCP_ANOMALY_HISTORY_TOKEN?.trim() || undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* In-process buffer + remote-write client. Use one instance per
|
|
37
|
+
* gateway process; `record()` is called from the anomaly detector;
|
|
38
|
+
* `flush()` runs on the interval AND on SIGTERM.
|
|
39
|
+
*/
|
|
40
|
+
export class AnomalyHistory {
|
|
41
|
+
url;
|
|
42
|
+
headers;
|
|
43
|
+
bearerToken;
|
|
44
|
+
flushIntervalMs;
|
|
45
|
+
maxBufferSize;
|
|
46
|
+
requestTimeoutMs;
|
|
47
|
+
fetchImpl;
|
|
48
|
+
buffer = [];
|
|
49
|
+
timer;
|
|
50
|
+
flushing = false;
|
|
51
|
+
constructor(cfg = {}) {
|
|
52
|
+
this.url = cfg.url;
|
|
53
|
+
this.headers = cfg.headers ?? {};
|
|
54
|
+
this.bearerToken = cfg.bearerToken;
|
|
55
|
+
this.flushIntervalMs = cfg.flushIntervalMs ?? 10_000;
|
|
56
|
+
this.maxBufferSize = cfg.maxBufferSize ?? 500;
|
|
57
|
+
this.requestTimeoutMs = cfg.requestTimeoutMs ?? 5_000;
|
|
58
|
+
this.fetchImpl = cfg.fetchImpl ?? fetch;
|
|
59
|
+
}
|
|
60
|
+
/** Whether the history sink is enabled (URL configured). */
|
|
61
|
+
isEnabled() {
|
|
62
|
+
return Boolean(this.url);
|
|
63
|
+
}
|
|
64
|
+
/** Begin the flush timer. Idempotent. No-op when sink is disabled. */
|
|
65
|
+
start() {
|
|
66
|
+
if (!this.isEnabled())
|
|
67
|
+
return;
|
|
68
|
+
if (this.timer)
|
|
69
|
+
return;
|
|
70
|
+
this.timer = setInterval(() => {
|
|
71
|
+
void this.flush().catch(() => {
|
|
72
|
+
/* swallow — flush() already logs */
|
|
73
|
+
});
|
|
74
|
+
}, this.flushIntervalMs);
|
|
75
|
+
// unref so the timer doesn't keep the process alive when the
|
|
76
|
+
// main loop is otherwise idle.
|
|
77
|
+
this.timer.unref?.();
|
|
78
|
+
}
|
|
79
|
+
/** Stop the flush timer + flush one last time. */
|
|
80
|
+
async stop() {
|
|
81
|
+
if (this.timer)
|
|
82
|
+
clearInterval(this.timer);
|
|
83
|
+
this.timer = undefined;
|
|
84
|
+
if (this.isEnabled())
|
|
85
|
+
await this.flush().catch(() => undefined);
|
|
86
|
+
}
|
|
87
|
+
/** Add one anomaly to the buffer. Silently drops when disabled.
|
|
88
|
+
* Triggers a synchronous flush if the buffer crosses maxBufferSize. */
|
|
89
|
+
async record(entry) {
|
|
90
|
+
if (!this.isEnabled())
|
|
91
|
+
return;
|
|
92
|
+
this.buffer.push(entry);
|
|
93
|
+
if (this.buffer.length >= this.maxBufferSize) {
|
|
94
|
+
await this.flush().catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Send the current buffer to the remote-write endpoint. Drops the
|
|
98
|
+
* buffer on success OR failure — history is best-effort. */
|
|
99
|
+
async flush() {
|
|
100
|
+
if (!this.isEnabled() || this.buffer.length === 0 || this.flushing)
|
|
101
|
+
return;
|
|
102
|
+
this.flushing = true;
|
|
103
|
+
const batch = this.buffer.splice(0, this.buffer.length);
|
|
104
|
+
try {
|
|
105
|
+
await this.sendBatch(batch);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
console.warn("AnomalyHistory: flush dropped %d entries (%s). History is best-effort; check the remote-write endpoint.", batch.length, err instanceof Error ? err.message : String(err));
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
this.flushing = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Test seam: number of currently-buffered entries. */
|
|
115
|
+
bufferSize() {
|
|
116
|
+
return this.buffer.length;
|
|
117
|
+
}
|
|
118
|
+
/** Visible-for-test: format the buffer as a JSON payload mirroring
|
|
119
|
+
* the Prometheus remote-write metric shape. Real remote-write uses
|
|
120
|
+
* Snappy-compressed protobuf — we ship JSON here as a portable
|
|
121
|
+
* baseline that any TSDB-receiving collector can ingest via a tiny
|
|
122
|
+
* shim. A protobuf+Snappy fast path is a follow-up. */
|
|
123
|
+
formatBatch(batch) {
|
|
124
|
+
return {
|
|
125
|
+
// The schema mirrors prometheus.WriteRequest as a JSON object
|
|
126
|
+
// so collectors that already know "labels + samples" can ingest
|
|
127
|
+
// it directly.
|
|
128
|
+
timeseries: batch.map((r) => ({
|
|
129
|
+
labels: {
|
|
130
|
+
__name__: "omcp_anomaly_score",
|
|
131
|
+
service: r.service,
|
|
132
|
+
tenant: r.tenant,
|
|
133
|
+
method: r.method,
|
|
134
|
+
severity: r.severity,
|
|
135
|
+
...(r.signal ? { signal: r.signal } : {}),
|
|
136
|
+
},
|
|
137
|
+
samples: [{ value: r.score, timestamp: Date.parse(r.ts) }],
|
|
138
|
+
})),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async sendBatch(batch) {
|
|
142
|
+
if (!this.url)
|
|
143
|
+
return;
|
|
144
|
+
const body = JSON.stringify(this.formatBatch(batch));
|
|
145
|
+
const headers = {
|
|
146
|
+
"content-type": "application/json",
|
|
147
|
+
...this.headers,
|
|
148
|
+
};
|
|
149
|
+
if (this.bearerToken)
|
|
150
|
+
headers["authorization"] = `Bearer ${this.bearerToken}`;
|
|
151
|
+
const ctl = new AbortController();
|
|
152
|
+
const t = setTimeout(() => ctl.abort(), this.requestTimeoutMs).unref?.();
|
|
153
|
+
try {
|
|
154
|
+
const res = await this.fetchImpl(this.url, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers,
|
|
157
|
+
body,
|
|
158
|
+
signal: ctl.signal,
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const snippet = (await res.text().catch(() => "")).slice(0, 200);
|
|
162
|
+
throw new Error(`remote-write returned ${res.status} ${res.statusText}: ${snippet}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
if (typeof t === "object" && t)
|
|
167
|
+
clearTimeout(t);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { AnomalyHistory, fromEnv } from "./history.js";
|
|
4
|
+
function entry(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
ts: "2026-06-06T00:00:00.000Z",
|
|
7
|
+
service: "payment",
|
|
8
|
+
tenant: "default",
|
|
9
|
+
score: 0.87,
|
|
10
|
+
method: "mad",
|
|
11
|
+
severity: "warn",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function captureFetch(captureBody) {
|
|
16
|
+
const f = (async (_url, init) => {
|
|
17
|
+
if (captureBody) {
|
|
18
|
+
captureBody.body = JSON.parse(init.body);
|
|
19
|
+
captureBody.headers = init.headers;
|
|
20
|
+
}
|
|
21
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => "" };
|
|
22
|
+
});
|
|
23
|
+
return f;
|
|
24
|
+
}
|
|
25
|
+
test("fromEnv: missing URL → disabled config", () => {
|
|
26
|
+
assert.equal(fromEnv({}).url, undefined);
|
|
27
|
+
});
|
|
28
|
+
test("fromEnv: parses URL + headers + token", () => {
|
|
29
|
+
const cfg = fromEnv({
|
|
30
|
+
OMCP_ANOMALY_HISTORY_REMOTE_WRITE: "https://tsdb/api/v1/write",
|
|
31
|
+
OMCP_ANOMALY_HISTORY_HEADERS: "x-scope=tenant-a,x-extra=foo=bar",
|
|
32
|
+
OMCP_ANOMALY_HISTORY_TOKEN: "secret-token",
|
|
33
|
+
});
|
|
34
|
+
assert.equal(cfg.url, "https://tsdb/api/v1/write");
|
|
35
|
+
assert.deepEqual(cfg.headers, { "x-scope": "tenant-a", "x-extra": "foo=bar" });
|
|
36
|
+
assert.equal(cfg.bearerToken, "secret-token");
|
|
37
|
+
});
|
|
38
|
+
test("isEnabled: false without url, true with url", () => {
|
|
39
|
+
assert.equal(new AnomalyHistory({}).isEnabled(), false);
|
|
40
|
+
assert.equal(new AnomalyHistory({ url: "https://x" }).isEnabled(), true);
|
|
41
|
+
});
|
|
42
|
+
test("record: disabled instance silently drops, buffer stays empty", async () => {
|
|
43
|
+
const h = new AnomalyHistory({});
|
|
44
|
+
await h.record(entry());
|
|
45
|
+
assert.equal(h.bufferSize(), 0);
|
|
46
|
+
});
|
|
47
|
+
test("record + flush: posts the buffer as a remote-write-shaped JSON", async () => {
|
|
48
|
+
const captured = {};
|
|
49
|
+
const h = new AnomalyHistory({
|
|
50
|
+
url: "https://tsdb/api/v1/write",
|
|
51
|
+
fetchImpl: captureFetch(captured),
|
|
52
|
+
});
|
|
53
|
+
await h.record(entry({ score: 0.9 }));
|
|
54
|
+
await h.record(entry({ service: "orders", score: 0.42 }));
|
|
55
|
+
await h.flush();
|
|
56
|
+
const body = captured.body;
|
|
57
|
+
assert.equal(body.timeseries.length, 2);
|
|
58
|
+
assert.equal(body.timeseries[0].labels.__name__, "omcp_anomaly_score");
|
|
59
|
+
assert.equal(body.timeseries[0].labels.service, "payment");
|
|
60
|
+
assert.equal(body.timeseries[0].samples[0].value, 0.9);
|
|
61
|
+
assert.equal(body.timeseries[1].labels.service, "orders");
|
|
62
|
+
});
|
|
63
|
+
test("flush: bearer token forwarded as Authorization header", async () => {
|
|
64
|
+
const captured = {};
|
|
65
|
+
const h = new AnomalyHistory({
|
|
66
|
+
url: "https://tsdb/api/v1/write",
|
|
67
|
+
bearerToken: "tok-abc",
|
|
68
|
+
fetchImpl: captureFetch(captured),
|
|
69
|
+
});
|
|
70
|
+
await h.record(entry());
|
|
71
|
+
await h.flush();
|
|
72
|
+
assert.equal(captured.headers["authorization"], "Bearer tok-abc");
|
|
73
|
+
});
|
|
74
|
+
test("flush: clears the buffer on success", async () => {
|
|
75
|
+
const h = new AnomalyHistory({
|
|
76
|
+
url: "https://tsdb/api/v1/write",
|
|
77
|
+
fetchImpl: captureFetch(),
|
|
78
|
+
});
|
|
79
|
+
await h.record(entry());
|
|
80
|
+
await h.record(entry());
|
|
81
|
+
assert.equal(h.bufferSize(), 2);
|
|
82
|
+
await h.flush();
|
|
83
|
+
assert.equal(h.bufferSize(), 0);
|
|
84
|
+
});
|
|
85
|
+
test("flush: HTTP error logs + drops buffer (does NOT retry)", async () => {
|
|
86
|
+
const f = (async () => ({
|
|
87
|
+
ok: false,
|
|
88
|
+
status: 503,
|
|
89
|
+
statusText: "Service Unavailable",
|
|
90
|
+
text: async () => "tsdb overloaded",
|
|
91
|
+
}));
|
|
92
|
+
const h = new AnomalyHistory({
|
|
93
|
+
url: "https://tsdb/api/v1/write",
|
|
94
|
+
fetchImpl: f,
|
|
95
|
+
});
|
|
96
|
+
await h.record(entry());
|
|
97
|
+
await h.flush();
|
|
98
|
+
// Best-effort policy: buffer cleared even on error.
|
|
99
|
+
assert.equal(h.bufferSize(), 0);
|
|
100
|
+
});
|
|
101
|
+
test("flush: empty buffer is a no-op (no fetch call)", async () => {
|
|
102
|
+
let called = 0;
|
|
103
|
+
const f = (async () => {
|
|
104
|
+
called++;
|
|
105
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => "" };
|
|
106
|
+
});
|
|
107
|
+
const h = new AnomalyHistory({
|
|
108
|
+
url: "https://tsdb/api/v1/write",
|
|
109
|
+
fetchImpl: f,
|
|
110
|
+
});
|
|
111
|
+
await h.flush();
|
|
112
|
+
assert.equal(called, 0);
|
|
113
|
+
});
|
|
114
|
+
test("record: synchronous auto-flush triggers when buffer crosses maxBufferSize", async () => {
|
|
115
|
+
let calls = 0;
|
|
116
|
+
const f = (async () => {
|
|
117
|
+
calls++;
|
|
118
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => "" };
|
|
119
|
+
});
|
|
120
|
+
const h = new AnomalyHistory({
|
|
121
|
+
url: "https://tsdb/api/v1/write",
|
|
122
|
+
fetchImpl: f,
|
|
123
|
+
maxBufferSize: 3,
|
|
124
|
+
});
|
|
125
|
+
await h.record(entry());
|
|
126
|
+
await h.record(entry());
|
|
127
|
+
assert.equal(calls, 0);
|
|
128
|
+
await h.record(entry()); // crosses threshold → auto-flush
|
|
129
|
+
assert.equal(calls, 1);
|
|
130
|
+
assert.equal(h.bufferSize(), 0);
|
|
131
|
+
});
|
|
132
|
+
test("formatBatch: omits empty optional signal label", async () => {
|
|
133
|
+
const h = new AnomalyHistory({ url: "https://x" });
|
|
134
|
+
const out = h.formatBatch([entry({ signal: "" })]);
|
|
135
|
+
assert.equal("signal" in out.timeseries[0].labels, false);
|
|
136
|
+
});
|
|
137
|
+
test("formatBatch: includes signal label when set", () => {
|
|
138
|
+
const h = new AnomalyHistory({ url: "https://x" });
|
|
139
|
+
const out = h.formatBatch([entry({ signal: "request_latency" })]);
|
|
140
|
+
assert.equal(out.timeseries[0].labels.signal, "request_latency");
|
|
141
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only audit log for the management plane.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from the enterprise-gate audit (`enterprise/audit/`) which
|
|
5
|
+
* records every gated MCP tool call. This one records every mutating
|
|
6
|
+
* `/api/*` request (sources/settings/health-thresholds/connectors)
|
|
7
|
+
* so an operator can answer "who changed what, when".
|
|
8
|
+
*
|
|
9
|
+
* On-disk format is JSONL with one entry per line. Each line includes
|
|
10
|
+
* `prevHash` and `hash` (SHA-256 over the canonical JSON of the entry
|
|
11
|
+
* without the hash field itself, plus the previous hash) — a
|
|
12
|
+
* tamper-evident chain that `scripts/verify-audit.mjs` can walk.
|
|
13
|
+
*
|
|
14
|
+
* In-memory mode (no `OMCP_MGMT_AUDIT_FILE`) keeps the last
|
|
15
|
+
* `inMemoryCap` entries in a ring buffer so the UI's audit tab still
|
|
16
|
+
* shows something even without persistence configured. This is the
|
|
17
|
+
* default in the demo / single-user case.
|
|
18
|
+
*/
|
|
19
|
+
import type { AuditSink } from "./sinks/types.js";
|
|
20
|
+
export interface AuditEntry {
|
|
21
|
+
/** RFC-3339 UTC timestamp. */
|
|
22
|
+
ts: string;
|
|
23
|
+
/** Monotonically-increasing per-process sequence number. */
|
|
24
|
+
seq: number;
|
|
25
|
+
/** Identity that triggered the change. "anonymous" when auth is off. */
|
|
26
|
+
actor: {
|
|
27
|
+
sub: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
};
|
|
30
|
+
/** Tenant the actor belonged to at the time of the action.
|
|
31
|
+
* Omitted on pre-E7 entries; readers default to "default". */
|
|
32
|
+
tenant?: string;
|
|
33
|
+
/** Logical resource + action, mirrors the RBAC vocabulary. */
|
|
34
|
+
resource: string;
|
|
35
|
+
action: string;
|
|
36
|
+
/** Full request method + path for easy reading in the UI. */
|
|
37
|
+
method: string;
|
|
38
|
+
path: string;
|
|
39
|
+
/** HTTP status code emitted by the gated handler. */
|
|
40
|
+
status: number;
|
|
41
|
+
/** Source IP, best-effort (honours X-Forwarded-For when trust-proxy is set). */
|
|
42
|
+
ip?: string;
|
|
43
|
+
/** Optional resource identifier extracted from the path (`:name` param). */
|
|
44
|
+
target?: string;
|
|
45
|
+
/** Tamper-evident chain. */
|
|
46
|
+
prevHash: string;
|
|
47
|
+
hash: string;
|
|
48
|
+
}
|
|
49
|
+
export interface AuditLogConfig {
|
|
50
|
+
/** Absolute path to a JSONL file. When undefined, the log lives in
|
|
51
|
+
* memory only. */
|
|
52
|
+
file?: string;
|
|
53
|
+
/** How many recent entries to keep in memory regardless of file mode. */
|
|
54
|
+
inMemoryCap?: number;
|
|
55
|
+
/** Mirror every chained entry to one or more external sinks
|
|
56
|
+
* (Splunk/SIEM webhook, S3 archive, ...). The on-disk JSONL chain
|
|
57
|
+
* stays the authoritative master — sinks are best-effort mirrors
|
|
58
|
+
* and never block record(). */
|
|
59
|
+
sinks?: AuditSink[];
|
|
60
|
+
}
|
|
61
|
+
export declare const DEFAULT_IN_MEMORY_CAP = 500;
|
|
62
|
+
export declare class AuditLog {
|
|
63
|
+
private readonly cap;
|
|
64
|
+
private readonly file;
|
|
65
|
+
private readonly sinks;
|
|
66
|
+
private ring;
|
|
67
|
+
private lastHash;
|
|
68
|
+
private seq;
|
|
69
|
+
private writeQueue;
|
|
70
|
+
private bootstrapped;
|
|
71
|
+
constructor(cfg?: AuditLogConfig);
|
|
72
|
+
/**
|
|
73
|
+
* If a file is configured, replay it to recover seq + lastHash so a
|
|
74
|
+
* server restart picks up the chain exactly where it left off.
|
|
75
|
+
* Safe to call multiple times — bootstraps once and caches.
|
|
76
|
+
*/
|
|
77
|
+
bootstrap(): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Record an event. Returns the canonical entry (with chain fields)
|
|
80
|
+
* once enqueued; persistence to disk completes asynchronously.
|
|
81
|
+
*/
|
|
82
|
+
record(input: Omit<AuditEntry, "ts" | "seq" | "prevHash" | "hash">): Promise<AuditEntry>;
|
|
83
|
+
/** Flush every configured sink. Called from SIGTERM and tests. */
|
|
84
|
+
flushSinks(): Promise<void>;
|
|
85
|
+
/** Snapshot of the in-memory ring (most recent last). */
|
|
86
|
+
list(opts?: {
|
|
87
|
+
from?: string;
|
|
88
|
+
to?: string;
|
|
89
|
+
actor?: string;
|
|
90
|
+
action?: string;
|
|
91
|
+
limit?: number;
|
|
92
|
+
tenant?: string;
|
|
93
|
+
}): AuditEntry[];
|
|
94
|
+
/** For verification scripts. */
|
|
95
|
+
get tipHash(): string;
|
|
96
|
+
get nextSeq(): number;
|
|
97
|
+
}
|
|
98
|
+
/** Stable hash of an entry-without-hash, against `prevHash` already present. */
|
|
99
|
+
export declare function chainHash(entryWithoutHash: Omit<AuditEntry, "hash">): string;
|
|
100
|
+
/** Walk a JSONL file end-to-end and confirm every entry's hash matches
|
|
101
|
+
* the chain. Used by the offline verifier CLI. */
|
|
102
|
+
export declare function verifyChain(entries: AuditEntry[]): {
|
|
103
|
+
ok: true;
|
|
104
|
+
} | {
|
|
105
|
+
ok: false;
|
|
106
|
+
brokenAt: number;
|
|
107
|
+
reason: string;
|
|
108
|
+
};
|