@obsunified/collector 2.0.1 → 2.1.1
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/index.d.ts +322 -26
- package/dist/index.js +1478 -415
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/package.json +5 -5
- 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 +16 -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 +60 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/housekeeping.test.ts +73 -0
- package/src/lib/housekeeping.ts +93 -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/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/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +64 -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/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 +165 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +14 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/eval-cases-routes.ts +8 -17
- 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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Migration 048: telemetry_spans composite primary key
|
|
2
|
+
-- (project_id, trace_id, span_id)
|
|
3
|
+
--
|
|
4
|
+
-- Mirrors ../migrations/048_telemetry_spans_project_composite_pk.sql. 001
|
|
5
|
+
-- keyed spans by (trace_id, span_id); 010 added project_id outside the key,
|
|
6
|
+
-- so ingest's INSERT OR IGNORE (ON CONFLICT DO NOTHING here) silently
|
|
7
|
+
-- dropped a span whose ids already existed under another project. Swaps the
|
|
8
|
+
-- key for (project_id, trace_id, span_id) in place, keeping every row and
|
|
9
|
+
-- index. The old key was unique, so the composite key cannot collide.
|
|
10
|
+
--
|
|
11
|
+
-- Idempotent: does nothing when the key is already composite.
|
|
12
|
+
--
|
|
13
|
+
-- Operational note: ADD PRIMARY KEY builds a new unique index under an
|
|
14
|
+
-- ACCESS EXCLUSIVE lock. Retention keeps the table bounded, but on a busy
|
|
15
|
+
-- database apply it in a quiet window.
|
|
16
|
+
|
|
17
|
+
UPDATE telemetry_spans SET project_id = 'default' WHERE project_id IS NULL;
|
|
18
|
+
ALTER TABLE telemetry_spans ALTER COLUMN project_id SET NOT NULL;
|
|
19
|
+
|
|
20
|
+
DO $$
|
|
21
|
+
DECLARE
|
|
22
|
+
pk_name TEXT;
|
|
23
|
+
pk_def TEXT;
|
|
24
|
+
BEGIN
|
|
25
|
+
SELECT c.conname, pg_get_constraintdef(c.oid)
|
|
26
|
+
INTO pk_name, pk_def
|
|
27
|
+
FROM pg_constraint c
|
|
28
|
+
WHERE c.conrelid = 'telemetry_spans'::regclass AND c.contype = 'p';
|
|
29
|
+
|
|
30
|
+
IF pk_def IS DISTINCT FROM 'PRIMARY KEY (project_id, trace_id, span_id)' THEN
|
|
31
|
+
IF pk_name IS NOT NULL THEN
|
|
32
|
+
EXECUTE format('ALTER TABLE telemetry_spans DROP CONSTRAINT %I', pk_name);
|
|
33
|
+
END IF;
|
|
34
|
+
ALTER TABLE telemetry_spans ADD PRIMARY KEY (project_id, trace_id, span_id);
|
|
35
|
+
END IF;
|
|
36
|
+
END
|
|
37
|
+
$$;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Migration 049: alert_state.notified
|
|
2
|
+
--
|
|
3
|
+
-- Mirrors ../migrations/049_alert_state_notified.sql. INTEGER 0/1 like the
|
|
4
|
+
-- sibling alert_evaluations.notified (not BOOLEAN), so the shared store SQL
|
|
5
|
+
-- can test and set it the same way on both backends.
|
|
6
|
+
--
|
|
7
|
+
-- Backfill: existing rows default to 1 (delivered); the pre-049 evaluator
|
|
8
|
+
-- only stored a transition after the channels accepted it. Idempotent.
|
|
9
|
+
|
|
10
|
+
ALTER TABLE alert_state ADD COLUMN IF NOT EXISTS notified INTEGER NOT NULL DEFAULT 1;
|
|
@@ -32,9 +32,62 @@ remaining migrations are tracked in
|
|
|
32
32
|
[`docs/postgres-migration.md`](../../../../docs/postgres-migration.md); they'll
|
|
33
33
|
land alongside the first hosted-Postgres deployment.
|
|
34
34
|
|
|
35
|
-
##
|
|
35
|
+
## Column types the stores depend on
|
|
36
36
|
|
|
37
|
-
`
|
|
38
|
-
|
|
39
|
-
`
|
|
40
|
-
|
|
37
|
+
- `ingest_keys.key_hash` is `TEXT` on both backends. `ProjectsStore` binds the
|
|
38
|
+
SHA-256 hex digest as a string; an earlier Postgres version of `009` declared
|
|
39
|
+
the column `BYTEA`, which silently stored the ASCII bytes of the hex text and
|
|
40
|
+
made the `026` seed key unreachable. `041_ingest_key_hash_text.sql` converts
|
|
41
|
+
databases migrated with that version back to `TEXT` (fresh installs get
|
|
42
|
+
`TEXT` from `009` directly and `041` is a no-op). The D1 `041` file is an
|
|
43
|
+
intentional no-op that only keeps the numbering in parity.
|
|
44
|
+
- Timestamp columns (`received_at`, `expires_at`, …) are `TEXT` holding
|
|
45
|
+
`Date#toISOString()` output. Postgres will not compare `text` to
|
|
46
|
+
`timestamptz`, so `postgresDialect` and `translateD1Sql` render every time
|
|
47
|
+
window as ISO text (`to_char(…, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`).
|
|
48
|
+
`metric_exemplars` originally declared `received_at`/`expires_at` as
|
|
49
|
+
`TIMESTAMPTZ`, which made `MetricsStore.purgeExpired` fail with
|
|
50
|
+
`operator does not exist: timestamp with time zone < text`;
|
|
51
|
+
`043_metric_exemplars_text_timestamps.sql` converts those columns to UTC
|
|
52
|
+
ISO `TEXT` in place (fresh installs get `TEXT` from `035` and `043` is a
|
|
53
|
+
no-op; the D1 `043` is a numbering no-op).
|
|
54
|
+
- `actions.started_at` is ISO `TEXT` and drives action-graph retention (the
|
|
55
|
+
spine has no `expires_at`). `044_actions_started_at_index.sql` adds
|
|
56
|
+
`idx_actions_started_at` on both backends so the hourly sweep reads expired
|
|
57
|
+
trees in index order. Deletes run per tree inside `PostgresAdapter.batch`
|
|
58
|
+
(one transaction) and re-check eligibility in every statement, but under
|
|
59
|
+
READ COMMITTED a concurrent insert into an already-expired tree can still
|
|
60
|
+
land between that tree's leaf deletes and its spine delete; see the header
|
|
61
|
+
of `src/lib/action-retention.ts`.
|
|
62
|
+
- Row keys are project-scoped. `045`–`048` swap the single-column primary
|
|
63
|
+
keys for composite ones in place (`user_profiles (project_id, user_id)`;
|
|
64
|
+
`actions`, `agent_runs`, `tool_calls`, `retrieval_events`, `eval_results`,
|
|
65
|
+
`artifacts (project_id, id)`; `ai_span_payloads` and
|
|
66
|
+
`telemetry_spans (project_id, trace_id, span_id)`). Each file looks up the
|
|
67
|
+
current primary key by definition and is a no-op once the key is
|
|
68
|
+
composite; the D1 siblings rebuild the tables through `*_new` copies. The
|
|
69
|
+
stores upsert with `ON CONFLICT (project_id, …)`, so both backends must
|
|
70
|
+
carry these migrations before deploying collector 2.1.
|
|
71
|
+
- `049` adds `alert_state.notified INTEGER` (0/1, like
|
|
72
|
+
`alert_evaluations.notified`): whether the current state's transition has
|
|
73
|
+
been delivered to the rule's channels. Existing rows backfill to 1. The
|
|
74
|
+
store falls back to the pre-049 statement when the column is missing, so
|
|
75
|
+
the collector keeps evaluating during a rolling deploy, but it cannot
|
|
76
|
+
retry undelivered notifications until 049 is applied.
|
|
77
|
+
- Flags (`is_error`, `is_bot`, `payload_capture_enabled`) are `BOOLEAN` here
|
|
78
|
+
and `INTEGER 0/1` on D1. Shared SQL must test them as bare booleans
|
|
79
|
+
(`CASE WHEN is_error THEN 1 ELSE 0 END`, never `is_error = 1` or
|
|
80
|
+
`SUM(is_error)`). Hosts call `configurePostgresTypeParsers(pg.types)` so
|
|
81
|
+
BOOLEAN reads back as `0/1`, BIGINT/NUMERIC as numbers and JSON/JSONB as
|
|
82
|
+
text, matching what D1 returns.
|
|
83
|
+
|
|
84
|
+
## Verifying against a real database
|
|
85
|
+
|
|
86
|
+
`src/lib/sql-db-postgres.integration.test.ts` applies this directory into an
|
|
87
|
+
empty scratch database, then exercises ingest-key auth, AI/usage ingest, the
|
|
88
|
+
retention purge and every built-in analysis through `PostgresAdapter`:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
OBS_PG_TEST_URL=postgres://obs:obs@127.0.0.1:5432/obs_scratch \
|
|
92
|
+
pnpm --filter @obsunified/collector exec vitest run src/lib/sql-db-postgres.integration.test.ts
|
|
93
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { gzipSync } from "node:zlib";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { OtlpDecodeError, type ReadBodyLimits, readOtlpBody } from "./body";
|
|
5
|
+
|
|
6
|
+
const MIB = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const read = async (
|
|
9
|
+
body: BodyInit,
|
|
10
|
+
headers: Record<string, string>,
|
|
11
|
+
limits: ReadBodyLimits = {},
|
|
12
|
+
) => {
|
|
13
|
+
const app = new Hono().post("/t", async (c) => {
|
|
14
|
+
try {
|
|
15
|
+
const result = await readOtlpBody(c, limits);
|
|
16
|
+
return c.json({
|
|
17
|
+
bytes: result.bytes.byteLength,
|
|
18
|
+
wire: result.wireFormat,
|
|
19
|
+
});
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err instanceof OtlpDecodeError)
|
|
22
|
+
return c.json({ error: err.message }, err.status);
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
const res = await app.request("/t", { method: "POST", body, headers });
|
|
27
|
+
return {
|
|
28
|
+
status: res.status,
|
|
29
|
+
body: (await res.json()) as Record<string, unknown>,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("readOtlpBody limits", () => {
|
|
34
|
+
it("decompresses a normal gzip payload", async () => {
|
|
35
|
+
const json = JSON.stringify({ resourceSpans: [] });
|
|
36
|
+
const res = await read(gzipSync(json), {
|
|
37
|
+
"content-type": "application/json",
|
|
38
|
+
"content-encoding": "gzip",
|
|
39
|
+
});
|
|
40
|
+
expect(res).toEqual({
|
|
41
|
+
status: 200,
|
|
42
|
+
body: { bytes: json.length, wire: "json" },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("refuses a gzip bomb once the decompressed cap is crossed", async () => {
|
|
47
|
+
// 8 MiB of zeros gzips to a few KiB; the reader must stop at 1 MiB.
|
|
48
|
+
const bomb = gzipSync(Buffer.alloc(8 * MIB));
|
|
49
|
+
expect(bomb.byteLength).toBeLessThan(64 * 1024);
|
|
50
|
+
const res = await read(
|
|
51
|
+
bomb,
|
|
52
|
+
{ "content-type": "application/x-protobuf", "content-encoding": "gzip" },
|
|
53
|
+
{ maxDecompressedBytes: MIB },
|
|
54
|
+
);
|
|
55
|
+
expect(res.status).toBe(413);
|
|
56
|
+
expect(res.body.error).toMatch(/Decompressed payload too large/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("refuses a raw body over the configured cap", async () => {
|
|
60
|
+
const res = await read(
|
|
61
|
+
"x".repeat(2048),
|
|
62
|
+
{ "content-type": "application/json" },
|
|
63
|
+
{ maxBodyBytes: 1024 },
|
|
64
|
+
);
|
|
65
|
+
expect(res.status).toBe(413);
|
|
66
|
+
expect(res.body.error).toMatch(/Payload too large \(max 1024 bytes\)/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reports malformed gzip as 400, not 500", async () => {
|
|
70
|
+
const res = await read("definitely not gzip", {
|
|
71
|
+
"content-type": "application/json",
|
|
72
|
+
"content-encoding": "gzip",
|
|
73
|
+
});
|
|
74
|
+
expect(res.status).toBe(400);
|
|
75
|
+
});
|
|
76
|
+
});
|
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
|
+
});
|