@obsunified/collector 2.0.1 → 2.2.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/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +630 -27
- package/dist/index.js +5819 -853
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +5 -5
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +59 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +69 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +153 -0
- package/src/lib/housekeeping.ts +193 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/store/ingest.ts +94 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +68 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +181 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +285 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +65 -19
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
package/src/otlp/decode/body.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { JsonValue } from "@bufbuild/protobuf";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_BODY_BYTES,
|
|
5
|
+
DEFAULT_MAX_DECOMPRESSED_BYTES,
|
|
6
|
+
parseByteLimit,
|
|
7
|
+
} from "../../framework/limits";
|
|
3
8
|
|
|
4
9
|
export type OtlpWireFormat = "json" | "protobuf";
|
|
5
10
|
|
|
@@ -11,25 +16,50 @@ export interface ReadBodyResult {
|
|
|
11
16
|
export class OtlpDecodeError extends Error {
|
|
12
17
|
constructor(
|
|
13
18
|
message: string,
|
|
14
|
-
public readonly status: 400 | 415,
|
|
19
|
+
public readonly status: 400 | 413 | 415,
|
|
15
20
|
) {
|
|
16
21
|
super(message);
|
|
17
22
|
}
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
export interface ReadBodyLimits {
|
|
26
|
+
/** Raw (compressed) body cap; defaults to `MAX_BODY_BYTES` / 10 MiB. */
|
|
27
|
+
maxBodyBytes?: number;
|
|
28
|
+
/** Gunzipped payload cap; defaults to 32 MiB. */
|
|
29
|
+
maxDecompressedBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
/**
|
|
21
33
|
* Reads and decompresses an OTLP request body, returning the raw bytes plus
|
|
22
34
|
* the inferred wire format. Throws `OtlpDecodeError` for unsupported content-
|
|
23
|
-
* types or
|
|
35
|
+
* types, malformed gzip, or bodies over the raw / decompressed caps (413).
|
|
36
|
+
* The framework's body-limit middleware normally refuses oversized raw
|
|
37
|
+
* bodies first; the raw check here keeps the reader safe on its own, and
|
|
38
|
+
* the decompressed cap is what stops a gzip bomb.
|
|
24
39
|
*/
|
|
25
|
-
export const readOtlpBody = async (
|
|
40
|
+
export const readOtlpBody = async (
|
|
41
|
+
c: Context,
|
|
42
|
+
limits: ReadBodyLimits = {},
|
|
43
|
+
): Promise<ReadBodyResult> => {
|
|
26
44
|
const contentType = (c.req.header("content-type") ?? "").toLowerCase();
|
|
27
45
|
const wireFormat = detectWireFormat(contentType);
|
|
46
|
+
const env = (c.env ?? {}) as { MAX_BODY_BYTES?: string };
|
|
47
|
+
const maxBodyBytes =
|
|
48
|
+
limits.maxBodyBytes ??
|
|
49
|
+
parseByteLimit(env.MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
50
|
+
const maxDecompressedBytes =
|
|
51
|
+
limits.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
|
|
28
52
|
|
|
29
53
|
let body = await c.req.arrayBuffer();
|
|
54
|
+
if (body.byteLength > maxBodyBytes) {
|
|
55
|
+
throw new OtlpDecodeError(
|
|
56
|
+
`Payload too large (max ${maxBodyBytes} bytes)`,
|
|
57
|
+
413,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
30
60
|
const encoding = (c.req.header("content-encoding") ?? "").toLowerCase();
|
|
31
61
|
if (encoding === "gzip") {
|
|
32
|
-
body = await gunzip(body);
|
|
62
|
+
body = await gunzip(body, maxDecompressedBytes);
|
|
33
63
|
} else if (encoding && encoding !== "identity") {
|
|
34
64
|
throw new OtlpDecodeError(`Unsupported content-encoding: ${encoding}`, 415);
|
|
35
65
|
}
|
|
@@ -46,12 +76,43 @@ const detectWireFormat = (contentType: string): OtlpWireFormat => {
|
|
|
46
76
|
);
|
|
47
77
|
};
|
|
48
78
|
|
|
49
|
-
|
|
79
|
+
/** Gunzip with a hard cap on output size; stops reading once exceeded. */
|
|
80
|
+
const gunzip = async (
|
|
81
|
+
input: ArrayBuffer,
|
|
82
|
+
maxBytes: number,
|
|
83
|
+
): Promise<ArrayBuffer> => {
|
|
50
84
|
const stream = new Response(input).body?.pipeThrough(
|
|
51
85
|
new DecompressionStream("gzip"),
|
|
52
86
|
);
|
|
53
87
|
if (!stream) throw new OtlpDecodeError("Empty gzip body", 400);
|
|
54
|
-
|
|
88
|
+
const reader = stream.getReader();
|
|
89
|
+
const chunks: Uint8Array[] = [];
|
|
90
|
+
let total = 0;
|
|
91
|
+
try {
|
|
92
|
+
for (;;) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
total += value.byteLength;
|
|
96
|
+
if (total > maxBytes) {
|
|
97
|
+
await reader.cancel().catch(() => {});
|
|
98
|
+
throw new OtlpDecodeError(
|
|
99
|
+
`Decompressed payload too large (max ${maxBytes} bytes)`,
|
|
100
|
+
413,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
chunks.push(value);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err instanceof OtlpDecodeError) throw err;
|
|
107
|
+
throw new OtlpDecodeError("Malformed gzip body", 400);
|
|
108
|
+
}
|
|
109
|
+
const out = new Uint8Array(total);
|
|
110
|
+
let offset = 0;
|
|
111
|
+
for (const chunk of chunks) {
|
|
112
|
+
out.set(chunk, offset);
|
|
113
|
+
offset += chunk.byteLength;
|
|
114
|
+
}
|
|
115
|
+
return out.buffer;
|
|
55
116
|
};
|
|
56
117
|
|
|
57
118
|
export const decodeJsonBody = (bytes: Uint8Array): JsonValue => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a request body that must be a JSON object. `c.req.json()` happily
|
|
3
|
+
* returns `null`, arrays and primitives for bodies like `null` or `"x"`, and
|
|
4
|
+
* the first property access in the handler then threw a TypeError that
|
|
5
|
+
* surfaced as a 500. Returns `null` for anything that is not a plain object
|
|
6
|
+
* so routes can answer 400 uniformly.
|
|
7
|
+
*/
|
|
8
|
+
export async function readJsonObject<T extends object>(c: {
|
|
9
|
+
req: { json(): Promise<unknown> };
|
|
10
|
+
}): Promise<T | null> {
|
|
11
|
+
let parsed: unknown;
|
|
12
|
+
try {
|
|
13
|
+
parsed = await c.req.json();
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return parsed as T;
|
|
21
|
+
}
|
|
@@ -907,8 +907,7 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
|
|
|
907
907
|
span_id, session_id, interaction_id, user_id, agent_run_id, step_id,
|
|
908
908
|
tool_call_id, prompt_version, model_name, provider, total_cost_usd, attrs_json
|
|
909
909
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
910
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
911
|
-
project_id = excluded.project_id,
|
|
910
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
912
911
|
root_action_id = excluded.root_action_id,
|
|
913
912
|
caused_by_action_id = excluded.caused_by_action_id,
|
|
914
913
|
actor_type = excluded.actor_type,
|
|
@@ -972,8 +971,7 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
|
|
|
972
971
|
id, project_id, agent_id, agent_name, agent_version, goal, outcome,
|
|
973
972
|
autonomy_level, status, error_message, total_cost_usd, total_duration_ms, metadata_json
|
|
974
973
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
975
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
976
|
-
project_id = excluded.project_id,
|
|
974
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
977
975
|
agent_id = excluded.agent_id,
|
|
978
976
|
agent_name = excluded.agent_name,
|
|
979
977
|
agent_version = excluded.agent_version,
|
|
@@ -1015,9 +1013,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
|
|
|
1015
1013
|
mcp_audit_json, mutation_before_json, mutation_after_json,
|
|
1016
1014
|
mutation_diff_json, mutation_artifact_id
|
|
1017
1015
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1018
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
1016
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
1019
1017
|
action_id = excluded.action_id,
|
|
1020
|
-
project_id = excluded.project_id,
|
|
1021
1018
|
tool_name = excluded.tool_name,
|
|
1022
1019
|
args_hash = excluded.args_hash,
|
|
1023
1020
|
result_hash = excluded.result_hash,
|
|
@@ -1062,9 +1059,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
|
|
|
1062
1059
|
id, action_id, project_id, retriever_name, query_hash, documents_json,
|
|
1063
1060
|
total_results, max_relevance_score, duration_ms
|
|
1064
1061
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1065
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
1062
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
1066
1063
|
action_id = excluded.action_id,
|
|
1067
|
-
project_id = excluded.project_id,
|
|
1068
1064
|
retriever_name = excluded.retriever_name,
|
|
1069
1065
|
query_hash = excluded.query_hash,
|
|
1070
1066
|
documents_json = excluded.documents_json,
|
|
@@ -1095,9 +1091,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
|
|
|
1095
1091
|
id, action_id, project_id, evaluator_name, evaluator_version,
|
|
1096
1092
|
score, passed, reasoning, rubric_json
|
|
1097
1093
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1098
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
1094
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
1099
1095
|
action_id = excluded.action_id,
|
|
1100
|
-
project_id = excluded.project_id,
|
|
1101
1096
|
evaluator_name = excluded.evaluator_name,
|
|
1102
1097
|
evaluator_version = excluded.evaluator_version,
|
|
1103
1098
|
score = excluded.score,
|
|
@@ -1128,9 +1123,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
|
|
|
1128
1123
|
id, action_id, project_id, artifact_name, artifact_type,
|
|
1129
1124
|
storage_ref, size_bytes, sha256_hash, content_preview
|
|
1130
1125
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1131
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
1126
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
1132
1127
|
action_id = excluded.action_id,
|
|
1133
|
-
project_id = excluded.project_id,
|
|
1134
1128
|
artifact_name = excluded.artifact_name,
|
|
1135
1129
|
artifact_type = excluded.artifact_type,
|
|
1136
1130
|
storage_ref = excluded.storage_ref,
|
|
@@ -15,18 +15,15 @@ import type { CollectorPlugin } from "../framework/collector";
|
|
|
15
15
|
import { AIStore, type IngestEvaluation } from "../lib/ai-store";
|
|
16
16
|
import { sqlDbFor } from "../lib/sql-db";
|
|
17
17
|
import { getProjectId } from "./_context";
|
|
18
|
+
import { readJsonObject } from "./_json";
|
|
18
19
|
|
|
19
20
|
export const aiReceiverPlugin: CollectorPlugin = {
|
|
20
21
|
name: "ai-receiver",
|
|
21
22
|
register(app, _runtime) {
|
|
22
23
|
app.post("/v1/ai", async (c) => {
|
|
23
24
|
const projectId = getProjectId(c);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
payload = await c.req.json<AICallPayload>();
|
|
27
|
-
} catch {
|
|
28
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
29
|
-
}
|
|
25
|
+
const payload = await readJsonObject<AICallPayload>(c);
|
|
26
|
+
if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
|
|
30
27
|
if (!payload.calls || !Array.isArray(payload.calls)) {
|
|
31
28
|
return c.json({ error: "Missing calls array" }, 400);
|
|
32
29
|
}
|
|
@@ -126,12 +123,8 @@ export const aiReceiverPlugin: CollectorPlugin = {
|
|
|
126
123
|
// one or both of score/label, and optional explanation + metadata.
|
|
127
124
|
app.post("/v1/ai/evaluations", async (c) => {
|
|
128
125
|
const projectId = getProjectId(c);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
payload = await c.req.json<AIEvaluationPayload>();
|
|
132
|
-
} catch {
|
|
133
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
134
|
-
}
|
|
126
|
+
const payload = await readJsonObject<AIEvaluationPayload>(c);
|
|
127
|
+
if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
|
|
135
128
|
if (!payload.evaluations || !Array.isArray(payload.evaluations)) {
|
|
136
129
|
return c.json({ error: "Missing evaluations array" }, 400);
|
|
137
130
|
}
|
|
@@ -130,8 +130,7 @@ export const aiSpanPayloadsProcessorPlugin: CollectorPlugin = {
|
|
|
130
130
|
input_json, output_json, input_hash, output_hash, session_id, user_id,
|
|
131
131
|
received_at, expires_at, interaction_id, action_id
|
|
132
132
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
133
|
-
ON CONFLICT(trace_id, span_id) DO UPDATE SET
|
|
134
|
-
project_id = excluded.project_id,
|
|
133
|
+
ON CONFLICT(project_id, trace_id, span_id) DO UPDATE SET
|
|
135
134
|
span_kind = excluded.span_kind,
|
|
136
135
|
input_json = excluded.input_json,
|
|
137
136
|
output_json = excluded.output_json,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for the alert notification retry: the "still owed" decision
|
|
3
|
+
* lives on `alert_state.notified` (migration 049), not in alert_evaluations,
|
|
4
|
+
* which housekeeping purges after 720 hours. A long-lived firing rule whose
|
|
5
|
+
* evaluation history has been purged must not be re-notified every tick.
|
|
6
|
+
* Runs the real AlertsStore against the migrated SQLite schema; only the
|
|
7
|
+
* signal query and `fetch` are substituted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AlertRule } from "@obsunified/types";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { AlertsStore } from "../lib/alerts-store";
|
|
13
|
+
import {
|
|
14
|
+
ALERT_EVALUATIONS_MAX_AGE_HOURS,
|
|
15
|
+
purgeSecondaryRows,
|
|
16
|
+
} from "../lib/housekeeping";
|
|
17
|
+
import { ProjectsStore } from "../lib/projects-store";
|
|
18
|
+
import { createMigratedSqliteDb } from "../lib/test-utils/node-sqlite-db";
|
|
19
|
+
import { evaluateRuleBatch } from "./alerts-evaluator";
|
|
20
|
+
|
|
21
|
+
type EvaluatorStore = Parameters<typeof evaluateRuleBatch>[0];
|
|
22
|
+
|
|
23
|
+
const logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
24
|
+
const HOUR = 3_600_000;
|
|
25
|
+
|
|
26
|
+
/** Real store for state and evaluations; the signal value is injected. */
|
|
27
|
+
const evaluatorOver = (
|
|
28
|
+
store: AlertsStore,
|
|
29
|
+
value: () => number,
|
|
30
|
+
): EvaluatorStore => ({
|
|
31
|
+
listEnabledRules: () => store.listEnabledRules(),
|
|
32
|
+
evaluateRule: async () => value(),
|
|
33
|
+
getState: (id, project) => store.getState(id, project),
|
|
34
|
+
getAnalysisNarrative: (project, analysis) =>
|
|
35
|
+
store.getAnalysisNarrative(project, analysis),
|
|
36
|
+
transitionState: (id, project, next, now) =>
|
|
37
|
+
store.transitionState(id, project, next, now),
|
|
38
|
+
markNotified: (id, project, state, since) =>
|
|
39
|
+
store.markNotified(id, project, state, since),
|
|
40
|
+
recordEvaluation: (id, project, v, state, notified) =>
|
|
41
|
+
store.recordEvaluation(id, project, v, state, notified),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const setup = async () => {
|
|
45
|
+
const db = createMigratedSqliteDb();
|
|
46
|
+
const project = await new ProjectsStore(db).createProject({
|
|
47
|
+
name: "A",
|
|
48
|
+
slug: "a",
|
|
49
|
+
});
|
|
50
|
+
const store = new AlertsStore(db);
|
|
51
|
+
const rule = await store.createRule(project.id, {
|
|
52
|
+
name: "errors",
|
|
53
|
+
signal: "spans",
|
|
54
|
+
query: {},
|
|
55
|
+
threshold: 10,
|
|
56
|
+
windowMins: 5,
|
|
57
|
+
comparison: ">",
|
|
58
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/alert/x" }],
|
|
59
|
+
enabled: true,
|
|
60
|
+
});
|
|
61
|
+
const evaluations = () =>
|
|
62
|
+
db.count("alert_evaluations", "rule_id = ?", rule.id);
|
|
63
|
+
return { db, project, store, rule, evaluations };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const purgeAllEvaluations = async (
|
|
67
|
+
db: ReturnType<typeof createMigratedSqliteDb>,
|
|
68
|
+
) =>
|
|
69
|
+
purgeSecondaryRows(db, {
|
|
70
|
+
retentionHours: 72,
|
|
71
|
+
now: () => Date.now() + (ALERT_EVALUATIONS_MAX_AGE_HOURS + 1) * HOUR,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("alert notification retry survives evaluation retention", () => {
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.unstubAllGlobals();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not re-send a delivered firing notification after its evaluations are purged", async () => {
|
|
80
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
81
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
82
|
+
const { db, project, store, rule, evaluations } = await setup();
|
|
83
|
+
const evaluator = evaluatorOver(store, () => 42);
|
|
84
|
+
const rules: AlertRule[] = [rule];
|
|
85
|
+
|
|
86
|
+
// Breach: state recorded, webhook accepted, flag set.
|
|
87
|
+
expect(await evaluateRuleBatch(evaluator, rules, { logger })).toEqual({
|
|
88
|
+
evaluated: 1,
|
|
89
|
+
fired: 1,
|
|
90
|
+
resolved: 0,
|
|
91
|
+
redelivered: 0,
|
|
92
|
+
});
|
|
93
|
+
expect(await store.getState(rule.id, project.id)).toMatchObject({
|
|
94
|
+
current_state: "firing",
|
|
95
|
+
notified: 1,
|
|
96
|
+
});
|
|
97
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
98
|
+
|
|
99
|
+
// Still breaching for longer than evaluation retention: history goes.
|
|
100
|
+
await evaluateRuleBatch(evaluator, rules, { logger });
|
|
101
|
+
expect(await evaluations()).toBe(2);
|
|
102
|
+
const purged = await purgeAllEvaluations(db);
|
|
103
|
+
expect(purged.alert_evaluations).toBe(2);
|
|
104
|
+
expect(await evaluations()).toBe(0);
|
|
105
|
+
|
|
106
|
+
// The flag on the state row is the source of truth: no resend.
|
|
107
|
+
for (let tick = 0; tick < 3; tick += 1) {
|
|
108
|
+
expect(await evaluateRuleBatch(evaluator, rules, { logger })).toEqual({
|
|
109
|
+
evaluated: 1,
|
|
110
|
+
fired: 0,
|
|
111
|
+
resolved: 0,
|
|
112
|
+
redelivered: 0,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(await store.getState(rule.id, project.id)).toMatchObject({
|
|
117
|
+
current_state: "firing",
|
|
118
|
+
notified: 1,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps retrying an undelivered notification across the purge until a channel accepts it", async () => {
|
|
123
|
+
const statuses = [503, 503, 200];
|
|
124
|
+
const fetchMock = vi.fn(
|
|
125
|
+
async () => new Response(null, { status: statuses.shift() ?? 200 }),
|
|
126
|
+
);
|
|
127
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
128
|
+
const { db, project, store, rule } = await setup();
|
|
129
|
+
const evaluator = evaluatorOver(store, () => 42);
|
|
130
|
+
const rules: AlertRule[] = [rule];
|
|
131
|
+
|
|
132
|
+
await evaluateRuleBatch(evaluator, rules, { logger }); // 503
|
|
133
|
+
expect(await store.getState(rule.id, project.id)).toMatchObject({
|
|
134
|
+
current_state: "firing",
|
|
135
|
+
notified: 0,
|
|
136
|
+
});
|
|
137
|
+
await purgeAllEvaluations(db);
|
|
138
|
+
|
|
139
|
+
await evaluateRuleBatch(evaluator, rules, { logger }); // 503 again
|
|
140
|
+
expect(await evaluateRuleBatch(evaluator, rules, { logger })).toEqual({
|
|
141
|
+
evaluated: 1,
|
|
142
|
+
fired: 0,
|
|
143
|
+
resolved: 0,
|
|
144
|
+
redelivered: 1,
|
|
145
|
+
}); // 200
|
|
146
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
147
|
+
expect((await store.getState(rule.id, project.id))?.notified).toBe(1);
|
|
148
|
+
|
|
149
|
+
await evaluateRuleBatch(evaluator, rules, { logger });
|
|
150
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("listRules and getRule still expose the current state for the dashboard", async () => {
|
|
154
|
+
vi.stubGlobal(
|
|
155
|
+
"fetch",
|
|
156
|
+
vi.fn(async () => new Response(null, { status: 500 })),
|
|
157
|
+
);
|
|
158
|
+
const { project, store, rule } = await setup();
|
|
159
|
+
await evaluateRuleBatch(
|
|
160
|
+
evaluatorOver(store, () => 42),
|
|
161
|
+
[rule],
|
|
162
|
+
{ logger },
|
|
163
|
+
);
|
|
164
|
+
// Delivery failed, but the breach is visible.
|
|
165
|
+
expect((await store.getRule(rule.id, project.id))?.currentState).toBe(
|
|
166
|
+
"firing",
|
|
167
|
+
);
|
|
168
|
+
expect((await store.listRules(project.id))[0]?.currentState).toBe("firing");
|
|
169
|
+
});
|
|
170
|
+
});
|