@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
package/dist/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
D1Adapter,
|
|
3
3
|
ProjectsStore,
|
|
4
|
+
SQLITE_ISO_FORMAT,
|
|
4
5
|
createIngestAuth,
|
|
5
6
|
dialectFor,
|
|
6
7
|
postgresDialect,
|
|
8
|
+
postgresIsoText,
|
|
7
9
|
randomHex,
|
|
8
10
|
sha256Hex,
|
|
9
11
|
sqlDbFor
|
|
10
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-KMEZKGSO.js";
|
|
11
13
|
import {
|
|
12
14
|
LlmCallError,
|
|
13
15
|
aiEvaluationEvidenceReferences,
|
|
@@ -21,6 +23,224 @@ import {
|
|
|
21
23
|
// src/framework/collector.ts
|
|
22
24
|
import { getConfiguredRetentionHours } from "@obsunified/types/constants";
|
|
23
25
|
import { Hono } from "hono";
|
|
26
|
+
import { bodyLimit } from "hono/body-limit";
|
|
27
|
+
|
|
28
|
+
// src/lib/action-retention.ts
|
|
29
|
+
var DEFAULT_MAX_ACTIONS_PER_BATCH = 500;
|
|
30
|
+
var DEFAULT_MAX_TREES_PER_BATCH = 25;
|
|
31
|
+
var DEFAULT_MAX_BATCHES = 10;
|
|
32
|
+
var OVERSIZED_REPORT_LIMIT = 20;
|
|
33
|
+
var treeEligibleSql = (project, root, cutoff) => `
|
|
34
|
+
NOT EXISTS (
|
|
35
|
+
SELECT 1 FROM actions t
|
|
36
|
+
WHERE t.project_id = ${project} AND t.root_action_id = ${root}
|
|
37
|
+
AND (t.started_at >= ${cutoff} OR t.ended_at IS NULL)
|
|
38
|
+
)
|
|
39
|
+
AND NOT EXISTS (
|
|
40
|
+
SELECT 1 FROM actions t
|
|
41
|
+
JOIN agent_runs r ON r.project_id = t.project_id AND r.id = t.agent_run_id
|
|
42
|
+
WHERE t.project_id = ${project} AND t.root_action_id = ${root}
|
|
43
|
+
AND r.status = 'running'
|
|
44
|
+
)
|
|
45
|
+
AND NOT EXISTS (
|
|
46
|
+
SELECT 1 FROM actions t
|
|
47
|
+
JOIN eval_cases c
|
|
48
|
+
ON c.project_id = t.project_id
|
|
49
|
+
AND (c.source_action_id = t.id OR c.source_agent_run_id = t.agent_run_id)
|
|
50
|
+
WHERE t.project_id = ${project} AND t.root_action_id = ${root}
|
|
51
|
+
)
|
|
52
|
+
AND NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM actions t
|
|
54
|
+
JOIN tool_calls tc ON tc.project_id = t.project_id AND tc.action_id = t.id
|
|
55
|
+
JOIN eval_cases c ON c.project_id = tc.project_id AND c.source_tool_call_id = tc.id
|
|
56
|
+
WHERE t.project_id = ${project} AND t.root_action_id = ${root}
|
|
57
|
+
)`;
|
|
58
|
+
var TREE_ELIGIBLE_SQL = treeEligibleSql("?", "?", "?");
|
|
59
|
+
var treeEligibleBinds = (projectId, rootActionId, cutoff) => [
|
|
60
|
+
projectId,
|
|
61
|
+
rootActionId,
|
|
62
|
+
cutoff,
|
|
63
|
+
projectId,
|
|
64
|
+
rootActionId,
|
|
65
|
+
projectId,
|
|
66
|
+
rootActionId,
|
|
67
|
+
projectId,
|
|
68
|
+
rootActionId
|
|
69
|
+
];
|
|
70
|
+
var candidateTreesSql = (sizeOp) => `
|
|
71
|
+
SELECT a.project_id, a.root_action_id, COUNT(*) AS action_count,
|
|
72
|
+
MIN(a.started_at) AS oldest_started_at
|
|
73
|
+
FROM actions a
|
|
74
|
+
WHERE a.started_at < ?
|
|
75
|
+
GROUP BY a.project_id, a.root_action_id
|
|
76
|
+
HAVING COUNT(*) ${sizeOp} ?
|
|
77
|
+
AND ${treeEligibleSql("a.project_id", "a.root_action_id", "?")}
|
|
78
|
+
ORDER BY oldest_started_at ASC
|
|
79
|
+
LIMIT ?
|
|
80
|
+
`;
|
|
81
|
+
var SELECT_DELETABLE_TREES_SQL = candidateTreesSql("<=");
|
|
82
|
+
var SELECT_OVERSIZED_TREES_SQL = candidateTreesSql(">");
|
|
83
|
+
var LEAF_TABLES = [
|
|
84
|
+
"tool_calls",
|
|
85
|
+
"retrieval_events",
|
|
86
|
+
"eval_results",
|
|
87
|
+
"artifacts"
|
|
88
|
+
];
|
|
89
|
+
var emptySummary = () => ({
|
|
90
|
+
actions: 0,
|
|
91
|
+
agent_runs: 0,
|
|
92
|
+
tool_calls: 0,
|
|
93
|
+
retrieval_events: 0,
|
|
94
|
+
eval_results: 0,
|
|
95
|
+
artifacts: 0,
|
|
96
|
+
trees: 0,
|
|
97
|
+
batches: 0,
|
|
98
|
+
truncated: false,
|
|
99
|
+
oversized_trees: 0,
|
|
100
|
+
oversized: []
|
|
101
|
+
});
|
|
102
|
+
var actionRetentionCutoff = (retentionHours, nowMs = Date.now()) => new Date(nowMs - retentionHours * 60 * 60 * 1e3).toISOString();
|
|
103
|
+
var selectTrees = async (db, sql, cutoff, sizeCap, limit) => {
|
|
104
|
+
const { results } = await db.prepare(sql).bind(cutoff, sizeCap, cutoff, limit).all();
|
|
105
|
+
return results.map((row) => ({
|
|
106
|
+
projectId: row.project_id,
|
|
107
|
+
rootActionId: row.root_action_id,
|
|
108
|
+
actions: Number(row.action_count)
|
|
109
|
+
}));
|
|
110
|
+
};
|
|
111
|
+
var packBatch = (candidates, maxActions) => {
|
|
112
|
+
const batch = [];
|
|
113
|
+
let used = 0;
|
|
114
|
+
for (const tree of candidates) {
|
|
115
|
+
if (batch.length > 0 && used + tree.actions > maxActions) break;
|
|
116
|
+
batch.push(tree);
|
|
117
|
+
used += tree.actions;
|
|
118
|
+
}
|
|
119
|
+
return batch;
|
|
120
|
+
};
|
|
121
|
+
async function purgeExpiredActionGraph(db, options) {
|
|
122
|
+
const maxActions = Math.max(
|
|
123
|
+
1,
|
|
124
|
+
options.maxActionsPerBatch ?? DEFAULT_MAX_ACTIONS_PER_BATCH
|
|
125
|
+
);
|
|
126
|
+
const maxTrees = Math.max(
|
|
127
|
+
1,
|
|
128
|
+
options.maxTreesPerBatch ?? DEFAULT_MAX_TREES_PER_BATCH
|
|
129
|
+
);
|
|
130
|
+
const maxBatches = Math.max(1, options.maxBatches ?? DEFAULT_MAX_BATCHES);
|
|
131
|
+
const nowMs = (options.now ?? Date.now)();
|
|
132
|
+
const cutoff = actionRetentionCutoff(options.retentionHours, nowMs);
|
|
133
|
+
const summary = emptySummary();
|
|
134
|
+
let exhausted = false;
|
|
135
|
+
for (let batch = 0; batch < maxBatches && !exhausted; batch += 1) {
|
|
136
|
+
const candidates = await selectTrees(
|
|
137
|
+
db,
|
|
138
|
+
SELECT_DELETABLE_TREES_SQL,
|
|
139
|
+
cutoff,
|
|
140
|
+
maxActions,
|
|
141
|
+
maxTrees
|
|
142
|
+
);
|
|
143
|
+
if (candidates.length === 0) {
|
|
144
|
+
exhausted = true;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const trees = packBatch(candidates, maxActions);
|
|
148
|
+
summary.batches += 1;
|
|
149
|
+
const deleted = await deleteTrees(db, trees, cutoff);
|
|
150
|
+
for (const table of LEAF_TABLES) summary[table] += deleted[table];
|
|
151
|
+
summary.agent_runs += deleted.agent_runs;
|
|
152
|
+
summary.actions += deleted.actions;
|
|
153
|
+
summary.trees += deleted.trees;
|
|
154
|
+
exhausted = candidates.length < maxTrees && trees.length === candidates.length;
|
|
155
|
+
}
|
|
156
|
+
if (!exhausted) {
|
|
157
|
+
const remaining = await selectTrees(
|
|
158
|
+
db,
|
|
159
|
+
SELECT_DELETABLE_TREES_SQL,
|
|
160
|
+
cutoff,
|
|
161
|
+
maxActions,
|
|
162
|
+
1
|
|
163
|
+
);
|
|
164
|
+
summary.truncated = remaining.length > 0;
|
|
165
|
+
}
|
|
166
|
+
const oversized = await selectTrees(
|
|
167
|
+
db,
|
|
168
|
+
SELECT_OVERSIZED_TREES_SQL,
|
|
169
|
+
cutoff,
|
|
170
|
+
maxActions,
|
|
171
|
+
OVERSIZED_REPORT_LIMIT
|
|
172
|
+
);
|
|
173
|
+
summary.oversized_trees = oversized.length;
|
|
174
|
+
summary.oversized = oversized.map((tree) => ({
|
|
175
|
+
project_id: tree.projectId,
|
|
176
|
+
root_action_id: tree.rootActionId,
|
|
177
|
+
actions: tree.actions
|
|
178
|
+
}));
|
|
179
|
+
return summary;
|
|
180
|
+
}
|
|
181
|
+
async function deleteTrees(db, trees, cutoff) {
|
|
182
|
+
const statements = [];
|
|
183
|
+
const owners = [];
|
|
184
|
+
trees.forEach(({ projectId, rootActionId }, treeIndex) => {
|
|
185
|
+
const guard = treeEligibleBinds(projectId, rootActionId, cutoff);
|
|
186
|
+
const push = (table, stmt) => {
|
|
187
|
+
statements.push(stmt);
|
|
188
|
+
owners.push({ table, tree: treeIndex });
|
|
189
|
+
};
|
|
190
|
+
for (const table of LEAF_TABLES) {
|
|
191
|
+
push(
|
|
192
|
+
table,
|
|
193
|
+
db.prepare(
|
|
194
|
+
`DELETE FROM ${table}
|
|
195
|
+
WHERE project_id = ?
|
|
196
|
+
AND action_id IN (
|
|
197
|
+
SELECT id FROM actions WHERE project_id = ? AND root_action_id = ?
|
|
198
|
+
)
|
|
199
|
+
AND ${TREE_ELIGIBLE_SQL}`
|
|
200
|
+
).bind(projectId, projectId, rootActionId, ...guard)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
push(
|
|
204
|
+
"agent_runs",
|
|
205
|
+
db.prepare(
|
|
206
|
+
`DELETE FROM agent_runs
|
|
207
|
+
WHERE project_id = ?
|
|
208
|
+
AND id IN (
|
|
209
|
+
SELECT id FROM actions WHERE project_id = ? AND root_action_id = ?
|
|
210
|
+
)
|
|
211
|
+
AND ${TREE_ELIGIBLE_SQL}`
|
|
212
|
+
).bind(projectId, projectId, rootActionId, ...guard)
|
|
213
|
+
);
|
|
214
|
+
push(
|
|
215
|
+
"actions",
|
|
216
|
+
db.prepare(
|
|
217
|
+
`DELETE FROM actions
|
|
218
|
+
WHERE project_id = ? AND root_action_id = ?
|
|
219
|
+
AND ${TREE_ELIGIBLE_SQL}`
|
|
220
|
+
).bind(projectId, rootActionId, ...guard)
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
const results = await db.batch(statements);
|
|
224
|
+
const counts = {
|
|
225
|
+
tool_calls: 0,
|
|
226
|
+
retrieval_events: 0,
|
|
227
|
+
eval_results: 0,
|
|
228
|
+
artifacts: 0,
|
|
229
|
+
agent_runs: 0,
|
|
230
|
+
actions: 0,
|
|
231
|
+
trees: 0
|
|
232
|
+
};
|
|
233
|
+
const spineDeleted = /* @__PURE__ */ new Set();
|
|
234
|
+
results.forEach((result, index) => {
|
|
235
|
+
const owner = owners[index];
|
|
236
|
+
if (!owner) return;
|
|
237
|
+
const changes2 = result?.meta?.changes ?? 0;
|
|
238
|
+
counts[owner.table] += changes2;
|
|
239
|
+
if (owner.table === "actions" && changes2 > 0) spineDeleted.add(owner.tree);
|
|
240
|
+
});
|
|
241
|
+
counts.trees = spineDeleted.size;
|
|
242
|
+
return counts;
|
|
243
|
+
}
|
|
24
244
|
|
|
25
245
|
// src/lib/ai-store/types.ts
|
|
26
246
|
var clampInt = (value, min, max, fallback) => {
|
|
@@ -102,7 +322,7 @@ async function getAICallsOverview(db, options) {
|
|
|
102
322
|
SUM(total_cost_usd) as totalCostUsd,
|
|
103
323
|
SUM(prompt_tokens) as totalPromptTokens,
|
|
104
324
|
SUM(completion_tokens) as totalCompletionTokens,
|
|
105
|
-
SUM(is_error) as errorCalls
|
|
325
|
+
SUM(CASE WHEN is_error THEN 1 ELSE 0 END) as errorCalls
|
|
106
326
|
FROM ai_calls WHERE project_id = ? AND received_at >= ${dialect.sinceHours("?")}
|
|
107
327
|
${options.service ? "AND service_name = ?" : ""}
|
|
108
328
|
`;
|
|
@@ -124,7 +344,7 @@ async function getAICallsOverview(db, options) {
|
|
|
124
344
|
completionTokens: row.completion_tokens,
|
|
125
345
|
totalCostUsd: row.total_cost_usd,
|
|
126
346
|
latencyMs: row.latency_ms,
|
|
127
|
-
isError: row.is_error === 1,
|
|
347
|
+
isError: row.is_error === 1 || row.is_error === true,
|
|
128
348
|
errorMessage: row.error_message,
|
|
129
349
|
occurredAt: row.occurred_at,
|
|
130
350
|
receivedAt: row.received_at,
|
|
@@ -371,7 +591,7 @@ async function listAISessions(db, options) {
|
|
|
371
591
|
COUNT(DISTINCT s.trace_id) AS trace_count
|
|
372
592
|
FROM ai_span_payloads p
|
|
373
593
|
INNER JOIN telemetry_spans s
|
|
374
|
-
ON s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
594
|
+
ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
375
595
|
WHERE p.project_id = ?
|
|
376
596
|
AND p.session_id IS NOT NULL
|
|
377
597
|
AND p.received_at >= ${dialect.sinceHours("?")}
|
|
@@ -434,7 +654,7 @@ async function getAISessionDetail(db, projectId, sessionId) {
|
|
|
434
654
|
p.user_id AS user_id
|
|
435
655
|
FROM ai_span_payloads p
|
|
436
656
|
INNER JOIN telemetry_spans s
|
|
437
|
-
ON s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
657
|
+
ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
438
658
|
WHERE p.project_id = ? AND p.session_id = ?
|
|
439
659
|
ORDER BY s.start_time ASC
|
|
440
660
|
LIMIT 1000
|
|
@@ -478,7 +698,7 @@ async function loadSessionPreviews(db, dialect, projectId, hours, sessionIds) {
|
|
|
478
698
|
SELECT p.session_id, p.input_json, s.start_time
|
|
479
699
|
FROM ai_span_payloads p
|
|
480
700
|
INNER JOIN telemetry_spans s
|
|
481
|
-
ON s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
701
|
+
ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
482
702
|
WHERE p.project_id = ?
|
|
483
703
|
AND p.session_id IS NOT NULL
|
|
484
704
|
AND p.session_id IN (${sessionIds.map(() => "?").join(", ")})
|
|
@@ -572,7 +792,7 @@ async function getAISpansOverview(db, options) {
|
|
|
572
792
|
p.output_json AS output_json
|
|
573
793
|
FROM ai_span_payloads p
|
|
574
794
|
INNER JOIN telemetry_spans s
|
|
575
|
-
ON s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
795
|
+
ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
|
|
576
796
|
WHERE p.project_id = ?
|
|
577
797
|
AND p.received_at >= ${dialect.sinceHours("?")}
|
|
578
798
|
`;
|
|
@@ -844,13 +1064,19 @@ var AnalysesStore = class {
|
|
|
844
1064
|
if (analysisIds.length === 0) return /* @__PURE__ */ new Map();
|
|
845
1065
|
const placeholders2 = analysisIds.map(() => "?").join(", ");
|
|
846
1066
|
const result = await this.db.prepare(
|
|
847
|
-
`SELECT id, project_id, analysis_id, generated_at, params_hash,
|
|
848
|
-
status, primary_value, baseline_value, delta_pct, payload_json,
|
|
849
|
-
narrative, narrative_signature, duration_ms, expires_at
|
|
850
|
-
FROM analysis_results
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1067
|
+
`SELECT r.id, r.project_id, r.analysis_id, r.generated_at, r.params_hash,
|
|
1068
|
+
r.status, r.primary_value, r.baseline_value, r.delta_pct, r.payload_json,
|
|
1069
|
+
r.narrative, r.narrative_signature, r.duration_ms, r.expires_at
|
|
1070
|
+
FROM analysis_results r
|
|
1071
|
+
JOIN (
|
|
1072
|
+
SELECT analysis_id, MAX(generated_at) AS latest_at
|
|
1073
|
+
FROM analysis_results
|
|
1074
|
+
WHERE project_id = ? AND analysis_id IN (${placeholders2})
|
|
1075
|
+
GROUP BY analysis_id
|
|
1076
|
+
) m ON m.analysis_id = r.analysis_id AND m.latest_at = r.generated_at
|
|
1077
|
+
WHERE r.project_id = ?
|
|
1078
|
+
ORDER BY r.generated_at DESC, r.id DESC`
|
|
1079
|
+
).bind(projectId, ...analysisIds, projectId).all();
|
|
854
1080
|
const latest2 = /* @__PURE__ */ new Map();
|
|
855
1081
|
for (const row of result.results ?? []) {
|
|
856
1082
|
if (!latest2.has(row.analysis_id)) {
|
|
@@ -979,7 +1205,7 @@ var fetchShape = async (projectId, db) => {
|
|
|
979
1205
|
FROM telemetry_spans
|
|
980
1206
|
WHERE project_id = ?
|
|
981
1207
|
AND service_name IS NOT NULL
|
|
982
|
-
AND received_at >=
|
|
1208
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')`
|
|
983
1209
|
).bind(projectId).all();
|
|
984
1210
|
const services = servicesRes.results.map((row) => row.service_name).filter(
|
|
985
1211
|
(name) => typeof name === "string" && name.length > 0
|
|
@@ -993,7 +1219,7 @@ var fetchShape = async (projectId, db) => {
|
|
|
993
1219
|
AND parent.trace_id = child.trace_id
|
|
994
1220
|
AND parent.span_id = child.parent_span_id
|
|
995
1221
|
WHERE child.project_id = ?
|
|
996
|
-
AND child.received_at >=
|
|
1222
|
+
AND child.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
997
1223
|
AND parent.service_name IS NOT NULL
|
|
998
1224
|
AND child.service_name IS NOT NULL
|
|
999
1225
|
AND parent.service_name <> child.service_name`
|
|
@@ -1003,7 +1229,7 @@ var fetchShape = async (projectId, db) => {
|
|
|
1003
1229
|
`SELECT DISTINCT json_extract(attributes_json, '$."messaging.destination"') AS topic
|
|
1004
1230
|
FROM telemetry_spans
|
|
1005
1231
|
WHERE project_id = ?
|
|
1006
|
-
AND received_at >=
|
|
1232
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1007
1233
|
AND json_extract(attributes_json, '$."messaging.destination"') IS NOT NULL`
|
|
1008
1234
|
).bind(projectId).all();
|
|
1009
1235
|
const messagingTopics = topicsRes.results.map((row) => row.topic).filter(
|
|
@@ -1013,7 +1239,7 @@ var fetchShape = async (projectId, db) => {
|
|
|
1013
1239
|
`SELECT 1
|
|
1014
1240
|
FROM telemetry_spans
|
|
1015
1241
|
WHERE project_id = ?
|
|
1016
|
-
AND received_at >=
|
|
1242
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1017
1243
|
AND (
|
|
1018
1244
|
json_extract(attributes_json, '$."openinference.span.kind"') = 'LLM'
|
|
1019
1245
|
OR json_extract(attributes_json, '$."gen_ai.system"') IS NOT NULL
|
|
@@ -1071,7 +1297,7 @@ var makeServiceErrorRate = (projectId, service) => ({
|
|
|
1071
1297
|
FROM telemetry_spans
|
|
1072
1298
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1073
1299
|
AND service_name = '${sqlEscape(service)}'
|
|
1074
|
-
AND received_at >=
|
|
1300
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1075
1301
|
),
|
|
1076
1302
|
base_window AS (
|
|
1077
1303
|
SELECT
|
|
@@ -1080,8 +1306,8 @@ var makeServiceErrorRate = (projectId, service) => ({
|
|
|
1080
1306
|
FROM telemetry_spans
|
|
1081
1307
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1082
1308
|
AND service_name = '${sqlEscape(service)}'
|
|
1083
|
-
AND received_at >=
|
|
1084
|
-
AND received_at <
|
|
1309
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1310
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1085
1311
|
),
|
|
1086
1312
|
spark AS (
|
|
1087
1313
|
SELECT
|
|
@@ -1091,7 +1317,7 @@ var makeServiceErrorRate = (projectId, service) => ({
|
|
|
1091
1317
|
FROM telemetry_spans
|
|
1092
1318
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1093
1319
|
AND service_name = '${sqlEscape(service)}'
|
|
1094
|
-
AND received_at >=
|
|
1320
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
|
|
1095
1321
|
GROUP BY minute
|
|
1096
1322
|
ORDER BY minute ASC
|
|
1097
1323
|
)
|
|
@@ -1153,7 +1379,7 @@ var makeServiceLatencyP95 = (projectId, service) => ({
|
|
|
1153
1379
|
FROM telemetry_spans
|
|
1154
1380
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1155
1381
|
AND service_name = '${sqlEscape(service)}'
|
|
1156
|
-
AND received_at >=
|
|
1382
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1157
1383
|
),
|
|
1158
1384
|
base AS (
|
|
1159
1385
|
SELECT duration_ms,
|
|
@@ -1162,8 +1388,8 @@ var makeServiceLatencyP95 = (projectId, service) => ({
|
|
|
1162
1388
|
FROM telemetry_spans
|
|
1163
1389
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1164
1390
|
AND service_name = '${sqlEscape(service)}'
|
|
1165
|
-
AND received_at >=
|
|
1166
|
-
AND received_at <
|
|
1391
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-25 hours')
|
|
1392
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')
|
|
1167
1393
|
),
|
|
1168
1394
|
cur_p95 AS (
|
|
1169
1395
|
SELECT duration_ms FROM cur
|
|
@@ -1228,7 +1454,7 @@ var makeDependencyHealth = (projectId, source, target) => ({
|
|
|
1228
1454
|
WHERE child.project_id = '${sqlEscape(projectId)}'
|
|
1229
1455
|
AND parent.service_name = '${sqlEscape(source)}'
|
|
1230
1456
|
AND child.service_name = '${sqlEscape(target)}'
|
|
1231
|
-
AND child.received_at >=
|
|
1457
|
+
AND child.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1232
1458
|
),
|
|
1233
1459
|
edge_base AS (
|
|
1234
1460
|
SELECT child.status_code AS sc
|
|
@@ -1240,8 +1466,8 @@ var makeDependencyHealth = (projectId, source, target) => ({
|
|
|
1240
1466
|
WHERE child.project_id = '${sqlEscape(projectId)}'
|
|
1241
1467
|
AND parent.service_name = '${sqlEscape(source)}'
|
|
1242
1468
|
AND child.service_name = '${sqlEscape(target)}'
|
|
1243
|
-
AND child.received_at >=
|
|
1244
|
-
AND child.received_at <
|
|
1469
|
+
AND child.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1470
|
+
AND child.received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1245
1471
|
),
|
|
1246
1472
|
now_agg AS (
|
|
1247
1473
|
SELECT COUNT(*) AS total,
|
|
@@ -1302,7 +1528,7 @@ var makeMessagingLag = (projectId, topic) => ({
|
|
|
1302
1528
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1303
1529
|
AND span_kind = 4
|
|
1304
1530
|
AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
|
|
1305
|
-
AND received_at >=
|
|
1531
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
|
|
1306
1532
|
),
|
|
1307
1533
|
consumers AS (
|
|
1308
1534
|
SELECT trace_id,
|
|
@@ -1312,7 +1538,7 @@ var makeMessagingLag = (projectId, topic) => ({
|
|
|
1312
1538
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1313
1539
|
AND span_kind = 5
|
|
1314
1540
|
AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
|
|
1315
|
-
AND received_at >=
|
|
1541
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
|
|
1316
1542
|
),
|
|
1317
1543
|
gaps AS (
|
|
1318
1544
|
SELECT
|
|
@@ -1377,15 +1603,15 @@ var makeAiCostBurn = (projectId) => ({
|
|
|
1377
1603
|
COUNT(*) AS n
|
|
1378
1604
|
FROM ai_calls
|
|
1379
1605
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1380
|
-
AND received_at >=
|
|
1606
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1381
1607
|
),
|
|
1382
1608
|
prev AS (
|
|
1383
1609
|
SELECT COALESCE(SUM(total_cost_usd), 0) AS cost,
|
|
1384
1610
|
COUNT(*) AS n
|
|
1385
1611
|
FROM ai_calls
|
|
1386
1612
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1387
|
-
AND received_at >=
|
|
1388
|
-
AND received_at <
|
|
1613
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-2 hours')
|
|
1614
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1389
1615
|
),
|
|
1390
1616
|
delta AS (
|
|
1391
1617
|
SELECT CASE
|
|
@@ -1422,25 +1648,26 @@ var makeAiErrorRate = (projectId) => ({
|
|
|
1422
1648
|
// AI error rate is per-call; 5 min cadence balances signal vs cost on
|
|
1423
1649
|
// installs with hundreds of LLM spans.
|
|
1424
1650
|
refreshSeconds: 300,
|
|
1425
|
-
// is_error
|
|
1651
|
+
// is_error fraction in the last 5 minutes vs trailing 1h baseline. Tested
|
|
1652
|
+
// as a bare boolean so it works for INTEGER 0/1 (D1) and BOOLEAN (Postgres).
|
|
1426
1653
|
// Same thresholds as overall_error_rate.
|
|
1427
1654
|
sql: `
|
|
1428
1655
|
WITH now_window AS (
|
|
1429
1656
|
SELECT
|
|
1430
1657
|
COUNT(*) AS total,
|
|
1431
|
-
SUM(CASE WHEN is_error
|
|
1658
|
+
SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS errors
|
|
1432
1659
|
FROM ai_calls
|
|
1433
1660
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1434
|
-
AND received_at >=
|
|
1661
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1435
1662
|
),
|
|
1436
1663
|
base_window AS (
|
|
1437
1664
|
SELECT
|
|
1438
1665
|
COUNT(*) AS total,
|
|
1439
|
-
SUM(CASE WHEN is_error
|
|
1666
|
+
SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS errors
|
|
1440
1667
|
FROM ai_calls
|
|
1441
1668
|
WHERE project_id = '${sqlEscape(projectId)}'
|
|
1442
|
-
AND received_at >=
|
|
1443
|
-
AND received_at <
|
|
1669
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1670
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1444
1671
|
)
|
|
1445
1672
|
SELECT
|
|
1446
1673
|
CASE
|
|
@@ -1505,7 +1732,7 @@ var errorTopOffenders = {
|
|
|
1505
1732
|
COUNT(*) AS errors
|
|
1506
1733
|
FROM telemetry_spans
|
|
1507
1734
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1508
|
-
AND received_at >=
|
|
1735
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1509
1736
|
AND status_code = 2
|
|
1510
1737
|
GROUP BY service_name
|
|
1511
1738
|
ORDER BY errors DESC
|
|
@@ -1516,8 +1743,8 @@ var errorTopOffenders = {
|
|
|
1516
1743
|
COUNT(*) AS errors
|
|
1517
1744
|
FROM telemetry_spans
|
|
1518
1745
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1519
|
-
AND received_at >=
|
|
1520
|
-
AND received_at <
|
|
1746
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1747
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1521
1748
|
AND status_code = 2
|
|
1522
1749
|
GROUP BY service_name
|
|
1523
1750
|
ORDER BY errors DESC
|
|
@@ -1579,7 +1806,7 @@ var latencyOutlierAttribution = {
|
|
|
1579
1806
|
COUNT(*) OVER () AS n
|
|
1580
1807
|
FROM telemetry_spans
|
|
1581
1808
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1582
|
-
AND received_at >=
|
|
1809
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
|
|
1583
1810
|
),
|
|
1584
1811
|
p95_cut AS (
|
|
1585
1812
|
-- Conservatively call "tail" the slowest 5% of the window.
|
|
@@ -1647,12 +1874,12 @@ var logAnomalySummary = {
|
|
|
1647
1874
|
(SELECT l2.message FROM logs l2
|
|
1648
1875
|
WHERE l2.project_id = '{{PROJECT_ID}}'
|
|
1649
1876
|
AND COALESCE(l2.logger_name, '<unknown>') = COALESCE(l1.logger_name, '<unknown>')
|
|
1650
|
-
AND l2.received_at >=
|
|
1877
|
+
AND l2.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1651
1878
|
AND l2.severity_number >= 17
|
|
1652
1879
|
ORDER BY l2.received_at DESC LIMIT 1) AS sample_message
|
|
1653
1880
|
FROM logs l1
|
|
1654
1881
|
WHERE l1.project_id = '{{PROJECT_ID}}'
|
|
1655
|
-
AND l1.received_at >=
|
|
1882
|
+
AND l1.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1656
1883
|
AND l1.severity_number >= 17
|
|
1657
1884
|
GROUP BY l1.logger_name
|
|
1658
1885
|
),
|
|
@@ -1660,8 +1887,8 @@ var logAnomalySummary = {
|
|
|
1660
1887
|
SELECT DISTINCT COALESCE(logger_name, '<unknown>') AS logger
|
|
1661
1888
|
FROM logs
|
|
1662
1889
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1663
|
-
AND received_at >=
|
|
1664
|
-
AND received_at <
|
|
1890
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1891
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1665
1892
|
AND severity_number >= 17
|
|
1666
1893
|
),
|
|
1667
1894
|
newcomers AS (
|
|
@@ -1738,7 +1965,7 @@ var overallErrorRate = {
|
|
|
1738
1965
|
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
|
|
1739
1966
|
FROM telemetry_spans
|
|
1740
1967
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1741
|
-
AND received_at >=
|
|
1968
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1742
1969
|
),
|
|
1743
1970
|
base_window AS (
|
|
1744
1971
|
SELECT
|
|
@@ -1746,8 +1973,8 @@ var overallErrorRate = {
|
|
|
1746
1973
|
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
|
|
1747
1974
|
FROM telemetry_spans
|
|
1748
1975
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1749
|
-
AND received_at >=
|
|
1750
|
-
AND received_at <
|
|
1976
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1977
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1751
1978
|
),
|
|
1752
1979
|
spark AS (
|
|
1753
1980
|
-- per-minute error rate over the last 30 minutes for sparkline rendering
|
|
@@ -1757,7 +1984,7 @@ var overallErrorRate = {
|
|
|
1757
1984
|
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
|
|
1758
1985
|
FROM telemetry_spans
|
|
1759
1986
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1760
|
-
AND received_at >=
|
|
1987
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
|
|
1761
1988
|
GROUP BY minute
|
|
1762
1989
|
ORDER BY minute ASC
|
|
1763
1990
|
)
|
|
@@ -1814,7 +2041,7 @@ var topErrorServices = {
|
|
|
1814
2041
|
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
|
|
1815
2042
|
FROM telemetry_spans
|
|
1816
2043
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1817
|
-
AND received_at >=
|
|
2044
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
1818
2045
|
GROUP BY service
|
|
1819
2046
|
),
|
|
1820
2047
|
top5 AS (
|
|
@@ -1866,7 +2093,7 @@ var latencyP95Overall = {
|
|
|
1866
2093
|
COUNT(*) OVER () AS n
|
|
1867
2094
|
FROM telemetry_spans
|
|
1868
2095
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1869
|
-
AND received_at >=
|
|
2096
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1870
2097
|
),
|
|
1871
2098
|
base AS (
|
|
1872
2099
|
SELECT duration_ms,
|
|
@@ -1874,8 +2101,8 @@ var latencyP95Overall = {
|
|
|
1874
2101
|
COUNT(*) OVER () AS n
|
|
1875
2102
|
FROM telemetry_spans
|
|
1876
2103
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1877
|
-
AND received_at >=
|
|
1878
|
-
AND received_at <
|
|
2104
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-25 hours')
|
|
2105
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')
|
|
1879
2106
|
),
|
|
1880
2107
|
cur_p95 AS (
|
|
1881
2108
|
SELECT duration_ms FROM cur
|
|
@@ -1897,7 +2124,7 @@ var latencyP95Overall = {
|
|
|
1897
2124
|
COUNT(*) AS n
|
|
1898
2125
|
FROM telemetry_spans
|
|
1899
2126
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1900
|
-
AND received_at >=
|
|
2127
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
1901
2128
|
GROUP BY bucket
|
|
1902
2129
|
ORDER BY bucket ASC
|
|
1903
2130
|
),
|
|
@@ -1949,7 +2176,7 @@ var throughputSlope = {
|
|
|
1949
2176
|
COUNT(*) OVER () AS n
|
|
1950
2177
|
FROM telemetry_spans
|
|
1951
2178
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1952
|
-
AND received_at >=
|
|
2179
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
|
|
1953
2180
|
),
|
|
1954
2181
|
prev AS (
|
|
1955
2182
|
SELECT duration_ms,
|
|
@@ -1957,8 +2184,8 @@ var throughputSlope = {
|
|
|
1957
2184
|
COUNT(*) OVER () AS n
|
|
1958
2185
|
FROM telemetry_spans
|
|
1959
2186
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
1960
|
-
AND received_at >=
|
|
1961
|
-
AND received_at <
|
|
2187
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-60 minutes')
|
|
2188
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
|
|
1962
2189
|
),
|
|
1963
2190
|
cur_n AS (SELECT COALESCE((SELECT n FROM cur LIMIT 1), 0) AS n),
|
|
1964
2191
|
prev_n AS (SELECT COALESCE((SELECT n FROM prev LIMIT 1), 0) AS n),
|
|
@@ -2020,7 +2247,7 @@ var activeSessions = {
|
|
|
2020
2247
|
SELECT COUNT(DISTINCT session_id) AS n
|
|
2021
2248
|
FROM usage_events
|
|
2022
2249
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
2023
|
-
AND received_at >=
|
|
2250
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
|
|
2024
2251
|
),
|
|
2025
2252
|
hourly AS (
|
|
2026
2253
|
SELECT
|
|
@@ -2028,7 +2255,7 @@ var activeSessions = {
|
|
|
2028
2255
|
COUNT(DISTINCT session_id) AS sessions
|
|
2029
2256
|
FROM usage_events
|
|
2030
2257
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
2031
|
-
AND received_at >=
|
|
2258
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')
|
|
2032
2259
|
GROUP BY hour
|
|
2033
2260
|
ORDER BY hour ASC
|
|
2034
2261
|
)
|
|
@@ -2066,7 +2293,7 @@ var logErrorRate = {
|
|
|
2066
2293
|
SUM(CASE WHEN severity_number >= 17 THEN 1 ELSE 0 END) AS errors
|
|
2067
2294
|
FROM logs
|
|
2068
2295
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
2069
|
-
AND received_at >=
|
|
2296
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
2070
2297
|
),
|
|
2071
2298
|
base_window AS (
|
|
2072
2299
|
SELECT
|
|
@@ -2074,8 +2301,8 @@ var logErrorRate = {
|
|
|
2074
2301
|
SUM(CASE WHEN severity_number >= 17 THEN 1 ELSE 0 END) AS errors
|
|
2075
2302
|
FROM logs
|
|
2076
2303
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
2077
|
-
AND received_at >=
|
|
2078
|
-
AND received_at <
|
|
2304
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
2305
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
2079
2306
|
),
|
|
2080
2307
|
spark AS (
|
|
2081
2308
|
SELECT
|
|
@@ -2084,7 +2311,7 @@ var logErrorRate = {
|
|
|
2084
2311
|
SUM(CASE WHEN severity_number >= 17 THEN 1 ELSE 0 END) AS errors
|
|
2085
2312
|
FROM logs
|
|
2086
2313
|
WHERE project_id = '{{PROJECT_ID}}'
|
|
2087
|
-
AND received_at >=
|
|
2314
|
+
AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
|
|
2088
2315
|
GROUP BY minute
|
|
2089
2316
|
ORDER BY minute ASC
|
|
2090
2317
|
)
|
|
@@ -2144,19 +2371,19 @@ var serviceCpuUtilization = {
|
|
|
2144
2371
|
WHERE s.project_id = '{{PROJECT_ID}}'
|
|
2145
2372
|
AND s.name = 'process.cpu.utilization'
|
|
2146
2373
|
AND s.service_name IS NOT NULL
|
|
2147
|
-
AND p.received_at >=
|
|
2374
|
+
AND p.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
|
|
2148
2375
|
),
|
|
2149
2376
|
now_window AS (
|
|
2150
2377
|
SELECT service_name, AVG(value) AS avg_util
|
|
2151
2378
|
FROM cpu_points
|
|
2152
|
-
WHERE received_at >=
|
|
2379
|
+
WHERE received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
2153
2380
|
GROUP BY service_name
|
|
2154
2381
|
),
|
|
2155
2382
|
base_window AS (
|
|
2156
2383
|
SELECT service_name, AVG(value) AS avg_util
|
|
2157
2384
|
FROM cpu_points
|
|
2158
|
-
WHERE received_at >=
|
|
2159
|
-
AND received_at <
|
|
2385
|
+
WHERE received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
|
|
2386
|
+
AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
|
|
2160
2387
|
GROUP BY service_name
|
|
2161
2388
|
),
|
|
2162
2389
|
top_now AS (
|
|
@@ -2497,7 +2724,35 @@ async function runAllDueAnalyses(ctx, _runtime) {
|
|
|
2497
2724
|
const logger = ctx.logger ?? consoleLogger;
|
|
2498
2725
|
const db = sqlDbFor(ctx.env);
|
|
2499
2726
|
const store = new AnalysesStore(db);
|
|
2500
|
-
const
|
|
2727
|
+
const totals = { ran: 0, failed: 0, refreshed: 0, narrated: 0, skipped: 0 };
|
|
2728
|
+
for (const projectId of await listAnalysisProjects(db, logger)) {
|
|
2729
|
+
const summary = await runDueAnalysesForProject(projectId, {
|
|
2730
|
+
ctx,
|
|
2731
|
+
db,
|
|
2732
|
+
store,
|
|
2733
|
+
logger
|
|
2734
|
+
});
|
|
2735
|
+
totals.ran += summary.ran;
|
|
2736
|
+
totals.failed += summary.failed;
|
|
2737
|
+
totals.refreshed += summary.refreshed;
|
|
2738
|
+
totals.narrated += summary.narrated;
|
|
2739
|
+
totals.skipped += summary.skipped ?? 0;
|
|
2740
|
+
}
|
|
2741
|
+
return totals;
|
|
2742
|
+
}
|
|
2743
|
+
async function listAnalysisProjects(db, logger) {
|
|
2744
|
+
try {
|
|
2745
|
+
const ids = (await new ProjectsStore(db).listProjects()).map((p) => p.id).filter((id) => typeof id === "string" && id.length > 0);
|
|
2746
|
+
if (ids.length > 0) return ids;
|
|
2747
|
+
} catch (error) {
|
|
2748
|
+
logger.error("[analyses] failed to list projects; running default only", {
|
|
2749
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
return ["default"];
|
|
2753
|
+
}
|
|
2754
|
+
async function runDueAnalysesForProject(projectId, deps) {
|
|
2755
|
+
const { ctx, db, store, logger } = deps;
|
|
2501
2756
|
const now = Date.now();
|
|
2502
2757
|
const expiresAt = now + ctx.retentionHours * 3600 * 1e3;
|
|
2503
2758
|
let registered = [];
|
|
@@ -2618,6 +2873,42 @@ async function runAllDueAnalyses(ctx, _runtime) {
|
|
|
2618
2873
|
return { ran, failed, refreshed, narrated, skipped };
|
|
2619
2874
|
}
|
|
2620
2875
|
|
|
2876
|
+
// src/lib/housekeeping.ts
|
|
2877
|
+
var ALERT_EVALUATIONS_MAX_AGE_HOURS = 720;
|
|
2878
|
+
var ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS = 30 * 24;
|
|
2879
|
+
var changes = (r) => r.meta?.changes ?? 0;
|
|
2880
|
+
async function purgeSecondaryRows(db, options) {
|
|
2881
|
+
const nowMs = options.now?.() ?? Date.now();
|
|
2882
|
+
const isoBefore = (hours) => new Date(nowMs - hours * 36e5).toISOString();
|
|
2883
|
+
const alertEvaluations = await db.prepare(`DELETE FROM alert_evaluations WHERE evaluated_at < ?`).bind(isoBefore(ALERT_EVALUATIONS_MAX_AGE_HOURS)).run();
|
|
2884
|
+
const askEvents = await db.prepare(`DELETE FROM ask_evidence_events WHERE asked_at < ?`).bind(nowMs - ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS * 36e5).run();
|
|
2885
|
+
const series = await db.prepare(
|
|
2886
|
+
`DELETE FROM metric_series
|
|
2887
|
+
WHERE created_at < ?
|
|
2888
|
+
AND NOT EXISTS (
|
|
2889
|
+
SELECT 1 FROM metric_point p WHERE p.series_id = metric_series.id
|
|
2890
|
+
)`
|
|
2891
|
+
).bind(isoBefore(options.retentionHours)).run();
|
|
2892
|
+
const refs = await db.prepare(
|
|
2893
|
+
`DELETE FROM evidence_retrieval_refs
|
|
2894
|
+
WHERE expires_at IS NOT NULL AND expires_at < ?`
|
|
2895
|
+
).bind(isoBefore(0)).run();
|
|
2896
|
+
const expansions = await db.prepare(
|
|
2897
|
+
`DELETE FROM evidence_ref_expansions
|
|
2898
|
+
WHERE NOT EXISTS (
|
|
2899
|
+
SELECT 1 FROM evidence_retrieval_refs r
|
|
2900
|
+
WHERE r.ref_id = evidence_ref_expansions.ref_id
|
|
2901
|
+
)`
|
|
2902
|
+
).run();
|
|
2903
|
+
return {
|
|
2904
|
+
alert_evaluations: changes(alertEvaluations),
|
|
2905
|
+
ask_evidence_events: changes(askEvents),
|
|
2906
|
+
metric_series: changes(series),
|
|
2907
|
+
evidence_refs: changes(refs),
|
|
2908
|
+
evidence_ref_expansions: changes(expansions)
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2621
2912
|
// src/lib/logs-store.ts
|
|
2622
2913
|
var LOG_SEVERITIES = /* @__PURE__ */ new Set([
|
|
2623
2914
|
"DEBUG",
|
|
@@ -2913,18 +3204,20 @@ var MetricsStore = class {
|
|
|
2913
3204
|
}
|
|
2914
3205
|
}
|
|
2915
3206
|
const insertStmt = this.db.prepare(`
|
|
2916
|
-
INSERT INTO metric_series (
|
|
3207
|
+
INSERT OR IGNORE INTO metric_series (
|
|
2917
3208
|
id, project_id, name, description, unit, type,
|
|
2918
3209
|
is_monotonic, temporality, scope_name, scope_version, service_name,
|
|
2919
3210
|
resource_attrs_json, attributes_json, identity, created_at
|
|
2920
3211
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2921
3212
|
`);
|
|
2922
3213
|
const toInsert = [];
|
|
3214
|
+
const attemptedIds = /* @__PURE__ */ new Set();
|
|
2923
3215
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2924
3216
|
for (const [identity, p] of byIdentity.entries()) {
|
|
2925
3217
|
if (existing.has(identity)) continue;
|
|
2926
3218
|
const id = crypto.randomUUID();
|
|
2927
3219
|
existing.set(identity, { id, identity });
|
|
3220
|
+
attemptedIds.add(identity);
|
|
2928
3221
|
toInsert.push(
|
|
2929
3222
|
insertStmt.bind(
|
|
2930
3223
|
id,
|
|
@@ -2945,7 +3238,20 @@ var MetricsStore = class {
|
|
|
2945
3238
|
)
|
|
2946
3239
|
);
|
|
2947
3240
|
}
|
|
2948
|
-
if (toInsert.length)
|
|
3241
|
+
if (toInsert.length === 0) return existing;
|
|
3242
|
+
await this.db.batch(toInsert);
|
|
3243
|
+
const attempted = identities.filter(
|
|
3244
|
+
(identity) => attemptedIds.has(identity)
|
|
3245
|
+
);
|
|
3246
|
+
for (let i = 0; i < attempted.length; i += 50) {
|
|
3247
|
+
const chunk = attempted.slice(i, i + 50);
|
|
3248
|
+
const placeholders2 = chunk.map(() => "?").join(",");
|
|
3249
|
+
const rows = await this.db.prepare(
|
|
3250
|
+
`SELECT id, identity FROM metric_series
|
|
3251
|
+
WHERE project_id = ? AND identity IN (${placeholders2})`
|
|
3252
|
+
).bind(projectId, ...chunk).all();
|
|
3253
|
+
for (const row of rows.results) existing.set(row.identity, row);
|
|
3254
|
+
}
|
|
2949
3255
|
return existing;
|
|
2950
3256
|
}
|
|
2951
3257
|
};
|
|
@@ -4645,6 +4951,15 @@ var UsageStore = class {
|
|
|
4645
4951
|
}
|
|
4646
4952
|
};
|
|
4647
4953
|
|
|
4954
|
+
// src/framework/limits.ts
|
|
4955
|
+
var DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
4956
|
+
var DEFAULT_MAX_AUTH_BODY_BYTES = 16 * 1024;
|
|
4957
|
+
var DEFAULT_MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
4958
|
+
var parseByteLimit = (raw, fallback) => {
|
|
4959
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
4960
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
4961
|
+
};
|
|
4962
|
+
|
|
4648
4963
|
// src/framework/collector.ts
|
|
4649
4964
|
var defaultSqlDbFactory = (env) => new D1Adapter(env.DB);
|
|
4650
4965
|
var CollectorRuntime = class {
|
|
@@ -4734,6 +5049,7 @@ var CollectorRuntime = class {
|
|
|
4734
5049
|
return nextEvents;
|
|
4735
5050
|
}
|
|
4736
5051
|
};
|
|
5052
|
+
var isDashboardLoginPath = (path) => path === "/dashboard/login" || path.startsWith("/dashboard/login/");
|
|
4737
5053
|
var createTelemetryCollectorApp = (pluginsOrConfig) => {
|
|
4738
5054
|
const config = Array.isArray(pluginsOrConfig) ? { plugins: pluginsOrConfig } : pluginsOrConfig;
|
|
4739
5055
|
const app = new Hono();
|
|
@@ -4773,19 +5089,43 @@ var createTelemetryCollectorApp = (pluginsOrConfig) => {
|
|
|
4773
5089
|
c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
|
|
4774
5090
|
}
|
|
4775
5091
|
});
|
|
5092
|
+
const bodyCap = (c, next) => {
|
|
5093
|
+
const maxSize = config.maxBodyBytes ?? parseByteLimit(c.env?.MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
5094
|
+
return bodyLimit({
|
|
5095
|
+
maxSize,
|
|
5096
|
+
onError: (ctx) => ctx.json({ error: `Payload too large (max ${maxSize} bytes)` }, 413)
|
|
5097
|
+
})(c, next);
|
|
5098
|
+
};
|
|
5099
|
+
app.use("/v1/*", bodyCap);
|
|
5100
|
+
app.use("/internal/*", bodyCap);
|
|
5101
|
+
const authMaxSize = config.maxAuthBodyBytes ?? DEFAULT_MAX_AUTH_BODY_BYTES;
|
|
5102
|
+
app.use(
|
|
5103
|
+
"/auth/*",
|
|
5104
|
+
bodyLimit({
|
|
5105
|
+
maxSize: authMaxSize,
|
|
5106
|
+
onError: (ctx) => ctx.json(
|
|
5107
|
+
{ error: `Payload too large (max ${authMaxSize} bytes)` },
|
|
5108
|
+
413
|
|
5109
|
+
)
|
|
5110
|
+
})
|
|
5111
|
+
);
|
|
4776
5112
|
if (config.auth?.middleware) {
|
|
4777
5113
|
app.use("/v1/*", config.auth.middleware);
|
|
4778
5114
|
} else if (config.auth?.ingest) {
|
|
4779
5115
|
app.use("/v1/*", config.auth.ingest);
|
|
4780
5116
|
}
|
|
4781
5117
|
if (config.dashboardAuth) {
|
|
5118
|
+
const { middleware } = config.dashboardAuth;
|
|
4782
5119
|
config.dashboardAuth.registerRoutes(app);
|
|
4783
|
-
|
|
4784
|
-
app.use("/
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
app.use(
|
|
5120
|
+
const guard = async (c, next) => isDashboardLoginPath(c.req.path) ? next() : middleware(c, next);
|
|
5121
|
+
app.use("/dashboard/*", guard);
|
|
5122
|
+
app.use("/internal/*", middleware);
|
|
5123
|
+
} else {
|
|
5124
|
+
app.use("/internal/*", async (c) => c.json({ error: "Unauthorized" }, 401));
|
|
5125
|
+
app.use(
|
|
5126
|
+
"/dashboard/*",
|
|
5127
|
+
async (c) => c.json({ error: "Unauthorized" }, 401)
|
|
5128
|
+
);
|
|
4789
5129
|
}
|
|
4790
5130
|
for (const plugin of config.plugins) {
|
|
4791
5131
|
runtime.registerPlugin(plugin);
|
|
@@ -4799,101 +5139,199 @@ var createTelemetryCollectorApp = (pluginsOrConfig) => {
|
|
|
4799
5139
|
});
|
|
4800
5140
|
return app;
|
|
4801
5141
|
};
|
|
5142
|
+
var PROFILE_PURGE_PAGE_SIZE = 500;
|
|
5143
|
+
var PROFILE_PURGE_MAX_PAGES = 10;
|
|
4802
5144
|
var createRetentionCleanupHandler = (options) => {
|
|
4803
5145
|
const logger = options?.logger ?? consoleLogger;
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
5146
|
+
const run = async (env) => {
|
|
5147
|
+
const db = sqlDbFor(env);
|
|
5148
|
+
const telemetryStore = new TelemetryStore(db);
|
|
5149
|
+
const usageStore = new UsageStore(db);
|
|
5150
|
+
const logsStore = new LogsStore(db);
|
|
5151
|
+
const aiStore = new AIStore(db);
|
|
5152
|
+
const metricsStore = new MetricsStore(db);
|
|
5153
|
+
const analysesStore = new AnalysesStore(db);
|
|
5154
|
+
const [
|
|
5155
|
+
telemetryPurged,
|
|
5156
|
+
usagePurged,
|
|
5157
|
+
logsPurged,
|
|
5158
|
+
aiPurged,
|
|
5159
|
+
metricsPurged,
|
|
5160
|
+
analysesPurged
|
|
5161
|
+
] = await Promise.all([
|
|
5162
|
+
telemetryStore.purgeExpired(),
|
|
5163
|
+
usageStore.purgeExpired(),
|
|
5164
|
+
logsStore.purgeExpired(),
|
|
5165
|
+
aiStore.purgeExpired(),
|
|
5166
|
+
metricsStore.purgeExpired(),
|
|
5167
|
+
analysesStore.purgeExpired()
|
|
5168
|
+
]);
|
|
5169
|
+
let profilesPurged = 0;
|
|
5170
|
+
try {
|
|
5171
|
+
const dialect = dialectFor(db);
|
|
5172
|
+
if (!env.PROFILES_BUCKET) {
|
|
5173
|
+
logger.warn(
|
|
5174
|
+
"[retention-cleanup] PROFILES_BUCKET is not bound; expired profile rows are removed without deleting their blobs"
|
|
5175
|
+
);
|
|
5176
|
+
}
|
|
5177
|
+
for (let page = 0; page < PROFILE_PURGE_MAX_PAGES; page += 1) {
|
|
5178
|
+
const expired = await db.prepare(
|
|
5179
|
+
`SELECT id, blob_url FROM profile_blobs WHERE expires_at < ${dialect.now()} LIMIT ${PROFILE_PURGE_PAGE_SIZE}`
|
|
4833
5180
|
).all();
|
|
4834
|
-
if (
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
5181
|
+
if (expired.results.length === 0) break;
|
|
5182
|
+
const removable = [];
|
|
5183
|
+
if (env.PROFILES_BUCKET) {
|
|
5184
|
+
const bucket = env.PROFILES_BUCKET;
|
|
5185
|
+
const settled = await Promise.allSettled(
|
|
5186
|
+
expired.results.map((p) => bucket.delete(p.blob_url))
|
|
4839
5187
|
);
|
|
5188
|
+
settled.forEach((outcome, i) => {
|
|
5189
|
+
const row = expired.results[i];
|
|
5190
|
+
if (!row) return;
|
|
5191
|
+
if (outcome.status === "fulfilled") removable.push(row.id);
|
|
5192
|
+
else
|
|
5193
|
+
logger.warn(
|
|
5194
|
+
"[retention-cleanup] profile blob delete failed; row kept for retry",
|
|
5195
|
+
{
|
|
5196
|
+
profile_id: row.id,
|
|
5197
|
+
error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
|
|
5198
|
+
}
|
|
5199
|
+
);
|
|
5200
|
+
});
|
|
5201
|
+
} else {
|
|
5202
|
+
removable.push(...expired.results.map((p) => p.id));
|
|
4840
5203
|
}
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
5204
|
+
for (let i = 0; i < removable.length; i += 90) {
|
|
5205
|
+
const chunk = removable.slice(i, i + 90);
|
|
5206
|
+
const deleted = await db.prepare(
|
|
5207
|
+
`DELETE FROM profile_blobs WHERE id IN (${chunk.map(() => "?").join(", ")})`
|
|
5208
|
+
).bind(...chunk).run();
|
|
5209
|
+
profilesPurged += deleted.meta.changes;
|
|
5210
|
+
}
|
|
5211
|
+
if (expired.results.length < PROFILE_PURGE_PAGE_SIZE || removable.length < expired.results.length)
|
|
5212
|
+
break;
|
|
4849
5213
|
}
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
logs: logsPurged,
|
|
4854
|
-
ai_calls: aiPurged,
|
|
4855
|
-
metric_points: metricsPurged,
|
|
4856
|
-
analysis_results: analysesPurged,
|
|
4857
|
-
profile_blobs: profilesPurged
|
|
5214
|
+
} catch (err) {
|
|
5215
|
+
logger.error("[retention-cleanup] profile purge failed", {
|
|
5216
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4858
5217
|
});
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
5218
|
+
}
|
|
5219
|
+
let housekeeping = {
|
|
5220
|
+
alert_evaluations: 0,
|
|
5221
|
+
ask_evidence_events: 0,
|
|
5222
|
+
metric_series: 0,
|
|
5223
|
+
evidence_refs: 0,
|
|
5224
|
+
evidence_ref_expansions: 0
|
|
5225
|
+
};
|
|
5226
|
+
try {
|
|
5227
|
+
housekeeping = await purgeSecondaryRows(db, {
|
|
5228
|
+
retentionHours: getConfiguredRetentionHours(env.RETENTION_HOURS)
|
|
5229
|
+
});
|
|
5230
|
+
} catch (err) {
|
|
5231
|
+
logger.error("[retention-cleanup] housekeeping purge failed", {
|
|
5232
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5233
|
+
});
|
|
5234
|
+
}
|
|
5235
|
+
let actionGraph;
|
|
5236
|
+
try {
|
|
5237
|
+
actionGraph = await purgeExpiredActionGraph(db, {
|
|
5238
|
+
retentionHours: getConfiguredRetentionHours(env.RETENTION_HOURS)
|
|
5239
|
+
});
|
|
5240
|
+
if (actionGraph.truncated) {
|
|
5241
|
+
logger.warn(
|
|
5242
|
+
"[retention-cleanup] action graph purge hit its batch cap; remaining expired trees wait for the next run",
|
|
5243
|
+
{ batches: actionGraph.batches, trees: actionGraph.trees }
|
|
5244
|
+
);
|
|
5245
|
+
}
|
|
5246
|
+
if (actionGraph.oversized_trees > 0) {
|
|
5247
|
+
logger.warn(
|
|
5248
|
+
"[retention-cleanup] expired action trees retained because they exceed the per-batch action cap",
|
|
5249
|
+
{
|
|
5250
|
+
oversized_trees: actionGraph.oversized_trees,
|
|
5251
|
+
trees: actionGraph.oversized
|
|
5252
|
+
}
|
|
5253
|
+
);
|
|
4869
5254
|
}
|
|
5255
|
+
} catch (err) {
|
|
5256
|
+
logger.error("[retention-cleanup] action graph purge failed", {
|
|
5257
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5258
|
+
});
|
|
5259
|
+
}
|
|
5260
|
+
const summary = {
|
|
5261
|
+
spans: telemetryPurged,
|
|
5262
|
+
usage: usagePurged,
|
|
5263
|
+
logs: logsPurged,
|
|
5264
|
+
ai_calls: aiPurged,
|
|
5265
|
+
metric_points: metricsPurged,
|
|
5266
|
+
analysis_results: analysesPurged,
|
|
5267
|
+
profile_blobs: profilesPurged,
|
|
5268
|
+
housekeeping,
|
|
5269
|
+
actions: actionGraph?.actions ?? 0,
|
|
5270
|
+
action_leaves: actionGraph ? actionGraph.agent_runs + actionGraph.tool_calls + actionGraph.retrieval_events + actionGraph.eval_results + actionGraph.artifacts : 0
|
|
5271
|
+
};
|
|
5272
|
+
logger.info("[retention-cleanup] purged expired rows", {
|
|
5273
|
+
...summary,
|
|
5274
|
+
...actionGraph ? {
|
|
5275
|
+
action_agent_runs: actionGraph.agent_runs,
|
|
5276
|
+
action_tool_calls: actionGraph.tool_calls,
|
|
5277
|
+
action_retrieval_events: actionGraph.retrieval_events,
|
|
5278
|
+
action_eval_results: actionGraph.eval_results,
|
|
5279
|
+
action_artifacts: actionGraph.artifacts,
|
|
5280
|
+
action_trees: actionGraph.trees,
|
|
5281
|
+
action_batches: actionGraph.batches,
|
|
5282
|
+
action_oversized_trees: actionGraph.oversized_trees
|
|
5283
|
+
} : {}
|
|
5284
|
+
});
|
|
5285
|
+
try {
|
|
5286
|
+
const propagation = await aggregatePropagation(db, /* @__PURE__ */ new Date(), logger);
|
|
5287
|
+
logger.info("[propagation-metric] hourly aggregation complete", {
|
|
5288
|
+
projects: propagation.projects,
|
|
5289
|
+
points_written: propagation.pointsWritten
|
|
5290
|
+
});
|
|
5291
|
+
} catch (err) {
|
|
5292
|
+
logger.error("[propagation-metric] aggregation failed", {
|
|
5293
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5294
|
+
});
|
|
5295
|
+
}
|
|
5296
|
+
return summary;
|
|
5297
|
+
};
|
|
5298
|
+
return {
|
|
5299
|
+
run,
|
|
5300
|
+
scheduled: async (_event, env, _ctx) => {
|
|
5301
|
+
await run(env);
|
|
4870
5302
|
}
|
|
4871
5303
|
};
|
|
4872
5304
|
};
|
|
4873
5305
|
var createAnalysesRunHandler = (options) => {
|
|
4874
5306
|
const logger = options?.logger ?? consoleLogger;
|
|
4875
5307
|
const tracer = options?.tracer;
|
|
5308
|
+
const run = async (env) => {
|
|
5309
|
+
try {
|
|
5310
|
+
const retentionHours = getConfiguredRetentionHours(env.RETENTION_HOURS);
|
|
5311
|
+
const summary = await runAllDueAnalyses({
|
|
5312
|
+
env,
|
|
5313
|
+
retentionHours,
|
|
5314
|
+
logger,
|
|
5315
|
+
tracer
|
|
5316
|
+
});
|
|
5317
|
+
logger.info("[analyses] scheduled run summary", {
|
|
5318
|
+
refreshed: summary.refreshed,
|
|
5319
|
+
ran: summary.ran,
|
|
5320
|
+
failed: summary.failed,
|
|
5321
|
+
narrated: summary.narrated
|
|
5322
|
+
});
|
|
5323
|
+
return summary;
|
|
5324
|
+
} catch (error) {
|
|
5325
|
+
logger.error("[analyses] scheduled run failed", {
|
|
5326
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5327
|
+
});
|
|
5328
|
+
return void 0;
|
|
5329
|
+
}
|
|
5330
|
+
};
|
|
4876
5331
|
return {
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
const summary = await runAllDueAnalyses({
|
|
4881
|
-
env,
|
|
4882
|
-
retentionHours,
|
|
4883
|
-
logger,
|
|
4884
|
-
tracer
|
|
4885
|
-
});
|
|
4886
|
-
logger.info("[analyses] scheduled run summary", {
|
|
4887
|
-
refreshed: summary.refreshed,
|
|
4888
|
-
ran: summary.ran,
|
|
4889
|
-
failed: summary.failed,
|
|
4890
|
-
narrated: summary.narrated
|
|
4891
|
-
});
|
|
4892
|
-
} catch (error) {
|
|
4893
|
-
logger.error("[analyses] scheduled run failed", {
|
|
4894
|
-
error: error instanceof Error ? error.message : String(error)
|
|
4895
|
-
});
|
|
4896
|
-
}
|
|
5332
|
+
run,
|
|
5333
|
+
scheduled: async (_event, env, _ctx) => {
|
|
5334
|
+
await run(env);
|
|
4897
5335
|
}
|
|
4898
5336
|
};
|
|
4899
5337
|
};
|
|
@@ -5666,8 +6104,7 @@ var actionGraphProcessorPlugin = {
|
|
|
5666
6104
|
span_id, session_id, interaction_id, user_id, agent_run_id, step_id,
|
|
5667
6105
|
tool_call_id, prompt_version, model_name, provider, total_cost_usd, attrs_json
|
|
5668
6106
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5669
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
5670
|
-
project_id = excluded.project_id,
|
|
6107
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
5671
6108
|
root_action_id = excluded.root_action_id,
|
|
5672
6109
|
caused_by_action_id = excluded.caused_by_action_id,
|
|
5673
6110
|
actor_type = excluded.actor_type,
|
|
@@ -5730,8 +6167,7 @@ var actionGraphProcessorPlugin = {
|
|
|
5730
6167
|
id, project_id, agent_id, agent_name, agent_version, goal, outcome,
|
|
5731
6168
|
autonomy_level, status, error_message, total_cost_usd, total_duration_ms, metadata_json
|
|
5732
6169
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5733
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
5734
|
-
project_id = excluded.project_id,
|
|
6170
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
5735
6171
|
agent_id = excluded.agent_id,
|
|
5736
6172
|
agent_name = excluded.agent_name,
|
|
5737
6173
|
agent_version = excluded.agent_version,
|
|
@@ -5772,9 +6208,8 @@ var actionGraphProcessorPlugin = {
|
|
|
5772
6208
|
mcp_audit_json, mutation_before_json, mutation_after_json,
|
|
5773
6209
|
mutation_diff_json, mutation_artifact_id
|
|
5774
6210
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5775
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
6211
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
5776
6212
|
action_id = excluded.action_id,
|
|
5777
|
-
project_id = excluded.project_id,
|
|
5778
6213
|
tool_name = excluded.tool_name,
|
|
5779
6214
|
args_hash = excluded.args_hash,
|
|
5780
6215
|
result_hash = excluded.result_hash,
|
|
@@ -5818,9 +6253,8 @@ var actionGraphProcessorPlugin = {
|
|
|
5818
6253
|
id, action_id, project_id, retriever_name, query_hash, documents_json,
|
|
5819
6254
|
total_results, max_relevance_score, duration_ms
|
|
5820
6255
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5821
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
6256
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
5822
6257
|
action_id = excluded.action_id,
|
|
5823
|
-
project_id = excluded.project_id,
|
|
5824
6258
|
retriever_name = excluded.retriever_name,
|
|
5825
6259
|
query_hash = excluded.query_hash,
|
|
5826
6260
|
documents_json = excluded.documents_json,
|
|
@@ -5850,9 +6284,8 @@ var actionGraphProcessorPlugin = {
|
|
|
5850
6284
|
id, action_id, project_id, evaluator_name, evaluator_version,
|
|
5851
6285
|
score, passed, reasoning, rubric_json
|
|
5852
6286
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5853
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
6287
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
5854
6288
|
action_id = excluded.action_id,
|
|
5855
|
-
project_id = excluded.project_id,
|
|
5856
6289
|
evaluator_name = excluded.evaluator_name,
|
|
5857
6290
|
evaluator_version = excluded.evaluator_version,
|
|
5858
6291
|
score = excluded.score,
|
|
@@ -5882,9 +6315,8 @@ var actionGraphProcessorPlugin = {
|
|
|
5882
6315
|
id, action_id, project_id, artifact_name, artifact_type,
|
|
5883
6316
|
storage_ref, size_bytes, sha256_hash, content_preview
|
|
5884
6317
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5885
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
6318
|
+
ON CONFLICT (project_id, id) DO UPDATE SET
|
|
5886
6319
|
action_id = excluded.action_id,
|
|
5887
|
-
project_id = excluded.project_id,
|
|
5888
6320
|
artifact_name = excluded.artifact_name,
|
|
5889
6321
|
artifact_type = excluded.artifact_type,
|
|
5890
6322
|
storage_ref = excluded.storage_ref,
|
|
@@ -6809,8 +7241,8 @@ async function manifestBySession(db, projectId, sessionId) {
|
|
|
6809
7241
|
db.prepare(
|
|
6810
7242
|
`SELECT first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
6811
7243
|
FROM session_replay_metadata
|
|
6812
|
-
WHERE session_id = ?`
|
|
6813
|
-
).bind(sessionId).first()
|
|
7244
|
+
WHERE project_id = ? AND session_id = ?`
|
|
7245
|
+
).bind(projectId, sessionId).first()
|
|
6814
7246
|
]);
|
|
6815
7247
|
return {
|
|
6816
7248
|
spans: spans.results.map(mapSpan),
|
|
@@ -7090,9 +7522,9 @@ async function manifestByAction(db, projectId, actionId) {
|
|
|
7090
7522
|
replayRes = await db.prepare(
|
|
7091
7523
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
7092
7524
|
FROM session_replay_metadata
|
|
7093
|
-
WHERE session_id IN (${sessionPlaceholders})
|
|
7525
|
+
WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
|
|
7094
7526
|
ORDER BY last_chunk_at DESC LIMIT 1`
|
|
7095
|
-
).bind(...sessionIds).first();
|
|
7527
|
+
).bind(projectId, ...sessionIds).first();
|
|
7096
7528
|
}
|
|
7097
7529
|
const sumAiCost = aiCallsRes.results.reduce(
|
|
7098
7530
|
(sum, a) => sum + (a.total_cost_usd ?? 0),
|
|
@@ -7231,9 +7663,9 @@ async function manifestByAgentRun(db, projectId, agentRunId) {
|
|
|
7231
7663
|
replayRes = await db.prepare(
|
|
7232
7664
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
7233
7665
|
FROM session_replay_metadata
|
|
7234
|
-
WHERE session_id IN (${sessionPlaceholders})
|
|
7666
|
+
WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
|
|
7235
7667
|
ORDER BY last_chunk_at DESC LIMIT 1`
|
|
7236
|
-
).bind(...sessionIds).first();
|
|
7668
|
+
).bind(projectId, ...sessionIds).first();
|
|
7237
7669
|
}
|
|
7238
7670
|
const sumAiCost = aiCallsRes.results.reduce(
|
|
7239
7671
|
(sum, a) => sum + (a.total_cost_usd ?? 0),
|
|
@@ -7375,9 +7807,9 @@ async function manifestByActor(db, projectId, actorType, actorId) {
|
|
|
7375
7807
|
replayRes = await db.prepare(
|
|
7376
7808
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
7377
7809
|
FROM session_replay_metadata
|
|
7378
|
-
WHERE session_id IN (${sessionPlaceholders})
|
|
7810
|
+
WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
|
|
7379
7811
|
ORDER BY last_chunk_at DESC LIMIT 1`
|
|
7380
|
-
).bind(...sessionIds).first();
|
|
7812
|
+
).bind(projectId, ...sessionIds).first();
|
|
7381
7813
|
}
|
|
7382
7814
|
return {
|
|
7383
7815
|
spans: spansRes.results.map(mapSpan),
|
|
@@ -7405,7 +7837,9 @@ async function manifestByActor(db, projectId, actorType, actorId) {
|
|
|
7405
7837
|
async function manifestByUser(db, projectId, userId, opts = {}) {
|
|
7406
7838
|
const limit = Math.min(opts.limit ?? 100, FETCH_LIMIT);
|
|
7407
7839
|
const sessionLimit = Math.max(1, Math.min(opts.sessions ?? 5, 20));
|
|
7408
|
-
const profile = await db.prepare(
|
|
7840
|
+
const profile = await db.prepare(
|
|
7841
|
+
`SELECT visitor_id FROM user_profiles WHERE project_id = ? AND user_id = ? LIMIT 1`
|
|
7842
|
+
).bind(projectId, userId).first();
|
|
7409
7843
|
if (!profile) {
|
|
7410
7844
|
return {
|
|
7411
7845
|
spans: [],
|
|
@@ -7469,9 +7903,9 @@ async function manifestByUser(db, projectId, userId, opts = {}) {
|
|
|
7469
7903
|
db.prepare(
|
|
7470
7904
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
7471
7905
|
FROM session_replay_metadata
|
|
7472
|
-
WHERE session_id IN (${placeholders2})
|
|
7906
|
+
WHERE project_id = ? AND session_id IN (${placeholders2})
|
|
7473
7907
|
ORDER BY last_chunk_at DESC LIMIT 1`
|
|
7474
|
-
).bind(...sessionIds).first()
|
|
7908
|
+
).bind(projectId, ...sessionIds).first()
|
|
7475
7909
|
]);
|
|
7476
7910
|
return {
|
|
7477
7911
|
spans: spans.results.map(mapSpan),
|
|
@@ -7779,17 +8213,29 @@ import {
|
|
|
7779
8213
|
TOOL_RESPONSE_CONTRACT_SCHEMA_VERSION
|
|
7780
8214
|
} from "@obsunified/types";
|
|
7781
8215
|
import { getConfiguredRetentionHours as getConfiguredRetentionHours3 } from "@obsunified/types/constants";
|
|
8216
|
+
|
|
8217
|
+
// src/plugins/_json.ts
|
|
8218
|
+
async function readJsonObject(c) {
|
|
8219
|
+
let parsed;
|
|
8220
|
+
try {
|
|
8221
|
+
parsed = await c.req.json();
|
|
8222
|
+
} catch {
|
|
8223
|
+
return null;
|
|
8224
|
+
}
|
|
8225
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8226
|
+
return null;
|
|
8227
|
+
}
|
|
8228
|
+
return parsed;
|
|
8229
|
+
}
|
|
8230
|
+
|
|
8231
|
+
// src/plugins/ai-receiver.ts
|
|
7782
8232
|
var aiReceiverPlugin = {
|
|
7783
8233
|
name: "ai-receiver",
|
|
7784
8234
|
register(app, _runtime) {
|
|
7785
8235
|
app.post("/v1/ai", async (c) => {
|
|
7786
8236
|
const projectId = getProjectId(c);
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
payload = await c.req.json();
|
|
7790
|
-
} catch {
|
|
7791
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
7792
|
-
}
|
|
8237
|
+
const payload = await readJsonObject(c);
|
|
8238
|
+
if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
|
|
7793
8239
|
if (!payload.calls || !Array.isArray(payload.calls)) {
|
|
7794
8240
|
return c.json({ error: "Missing calls array" }, 400);
|
|
7795
8241
|
}
|
|
@@ -7866,12 +8312,8 @@ var aiReceiverPlugin = {
|
|
|
7866
8312
|
});
|
|
7867
8313
|
app.post("/v1/ai/evaluations", async (c) => {
|
|
7868
8314
|
const projectId = getProjectId(c);
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
payload = await c.req.json();
|
|
7872
|
-
} catch {
|
|
7873
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
7874
|
-
}
|
|
8315
|
+
const payload = await readJsonObject(c);
|
|
8316
|
+
if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
|
|
7875
8317
|
if (!payload.evaluations || !Array.isArray(payload.evaluations)) {
|
|
7876
8318
|
return c.json({ error: "Missing evaluations array" }, 400);
|
|
7877
8319
|
}
|
|
@@ -8062,8 +8504,7 @@ var aiSpanPayloadsProcessorPlugin = {
|
|
|
8062
8504
|
input_json, output_json, input_hash, output_hash, session_id, user_id,
|
|
8063
8505
|
received_at, expires_at, interaction_id, action_id
|
|
8064
8506
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
8065
|
-
ON CONFLICT(trace_id, span_id) DO UPDATE SET
|
|
8066
|
-
project_id = excluded.project_id,
|
|
8507
|
+
ON CONFLICT(project_id, trace_id, span_id) DO UPDATE SET
|
|
8067
8508
|
span_kind = excluded.span_kind,
|
|
8068
8509
|
input_json = excluded.input_json,
|
|
8069
8510
|
output_json = excluded.output_json,
|
|
@@ -8110,6 +8551,100 @@ var aiSpanPayloadsProcessorPlugin = {
|
|
|
8110
8551
|
|
|
8111
8552
|
// src/lib/alerts-store.ts
|
|
8112
8553
|
import { EVIDENCE_REFERENCE_CONTRACT as EVIDENCE_REFERENCE_CONTRACT4 } from "@obsunified/types";
|
|
8554
|
+
|
|
8555
|
+
// src/lib/webhook-url.ts
|
|
8556
|
+
var WebhookUrlError = class extends Error {
|
|
8557
|
+
};
|
|
8558
|
+
var parseIpv4 = (host) => {
|
|
8559
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
8560
|
+
if (!m) return null;
|
|
8561
|
+
const octets = m.slice(1).map((o) => Number.parseInt(o, 10));
|
|
8562
|
+
return octets.every((o) => o <= 255) ? octets : null;
|
|
8563
|
+
};
|
|
8564
|
+
var isPrivateIpv4 = (o) => o[0] === 0 || // "this" network / unspecified
|
|
8565
|
+
o[0] === 10 || o[0] === 127 || o[0] === 100 && o[1] >= 64 && o[1] <= 127 || // CGNAT
|
|
8566
|
+
o[0] === 169 && o[1] === 254 || // link-local incl. cloud metadata
|
|
8567
|
+
o[0] === 172 && o[1] >= 16 && o[1] <= 31 || o[0] === 192 && o[1] === 168 || o[0] >= 224;
|
|
8568
|
+
var isPrivateIpv6 = (host) => {
|
|
8569
|
+
const literal = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
8570
|
+
if (literal === "::" || literal === "::1") return true;
|
|
8571
|
+
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(literal);
|
|
8572
|
+
if (dotted) {
|
|
8573
|
+
const v4 = parseIpv4(dotted[1]);
|
|
8574
|
+
return v4 ? isPrivateIpv4(v4) : true;
|
|
8575
|
+
}
|
|
8576
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(literal);
|
|
8577
|
+
if (hex) {
|
|
8578
|
+
const hi = Number.parseInt(hex[1], 16);
|
|
8579
|
+
const lo = Number.parseInt(hex[2], 16);
|
|
8580
|
+
return isPrivateIpv4([hi >> 8, hi & 255, lo >> 8, lo & 255]);
|
|
8581
|
+
}
|
|
8582
|
+
const first = Number.parseInt(literal.split(":")[0] || "0", 16);
|
|
8583
|
+
if (Number.isNaN(first)) return true;
|
|
8584
|
+
if ((first & 65024) === 64512) return true;
|
|
8585
|
+
if ((first & 65472) === 65152) return true;
|
|
8586
|
+
if ((first & 65280) === 65280) return true;
|
|
8587
|
+
return false;
|
|
8588
|
+
};
|
|
8589
|
+
var isPrivateWebhookHost = (hostname) => {
|
|
8590
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
8591
|
+
if (!host) return true;
|
|
8592
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
8593
|
+
if (host.endsWith(".local") || host.endsWith(".internal")) return true;
|
|
8594
|
+
if (host.startsWith("[")) return isPrivateIpv6(host);
|
|
8595
|
+
const v4 = parseIpv4(host);
|
|
8596
|
+
if (v4) return isPrivateIpv4(v4);
|
|
8597
|
+
return !host.includes(".");
|
|
8598
|
+
};
|
|
8599
|
+
var assertSafeWebhookUrl = (raw, options = {}) => {
|
|
8600
|
+
let url;
|
|
8601
|
+
try {
|
|
8602
|
+
url = new URL(raw);
|
|
8603
|
+
} catch {
|
|
8604
|
+
throw new WebhookUrlError("channel.url must be an absolute http(s) URL");
|
|
8605
|
+
}
|
|
8606
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
8607
|
+
throw new WebhookUrlError("channel.url must be http(s)://");
|
|
8608
|
+
}
|
|
8609
|
+
if (url.username || url.password) {
|
|
8610
|
+
throw new WebhookUrlError(
|
|
8611
|
+
"channel.url must not embed credentials; use channel.headers"
|
|
8612
|
+
);
|
|
8613
|
+
}
|
|
8614
|
+
if (!options.allowPrivateNetworks && isPrivateWebhookHost(url.hostname)) {
|
|
8615
|
+
throw new WebhookUrlError(
|
|
8616
|
+
`channel.url host "${url.hostname}" is not a public address (set ALERT_WEBHOOK_ALLOW_PRIVATE=true to allow internal receivers)`
|
|
8617
|
+
);
|
|
8618
|
+
}
|
|
8619
|
+
return url;
|
|
8620
|
+
};
|
|
8621
|
+
var resolveWebhookHost = async (hostname, lookup) => {
|
|
8622
|
+
if (parseIpv4(hostname) || hostname.startsWith("[")) return "public";
|
|
8623
|
+
let addresses;
|
|
8624
|
+
try {
|
|
8625
|
+
addresses = await lookup(hostname);
|
|
8626
|
+
} catch {
|
|
8627
|
+
return "unresolvable";
|
|
8628
|
+
}
|
|
8629
|
+
if (addresses.length === 0) return "unresolvable";
|
|
8630
|
+
const isPrivate = addresses.some((address) => {
|
|
8631
|
+
const v4 = parseIpv4(address);
|
|
8632
|
+
if (v4) return isPrivateIpv4(v4);
|
|
8633
|
+
return isPrivateIpv6(`[${address}]`);
|
|
8634
|
+
});
|
|
8635
|
+
return isPrivate ? "private" : "public";
|
|
8636
|
+
};
|
|
8637
|
+
var redactWebhookUrl = (raw) => {
|
|
8638
|
+
try {
|
|
8639
|
+
const url = new URL(raw);
|
|
8640
|
+
const hasPath = url.pathname !== "/" || url.search !== "";
|
|
8641
|
+
return `${url.protocol}//${url.host}${hasPath ? "/\u2026" : ""}`;
|
|
8642
|
+
} catch {
|
|
8643
|
+
return "<invalid url>";
|
|
8644
|
+
}
|
|
8645
|
+
};
|
|
8646
|
+
|
|
8647
|
+
// src/lib/alerts-store.ts
|
|
8113
8648
|
var parseJsonField = (value) => {
|
|
8114
8649
|
if (typeof value === "string") return JSON.parse(value);
|
|
8115
8650
|
return value;
|
|
@@ -8156,8 +8691,9 @@ function compareValue(value, threshold, comparison) {
|
|
|
8156
8691
|
}
|
|
8157
8692
|
}
|
|
8158
8693
|
var AlertsStore = class {
|
|
8159
|
-
constructor(db) {
|
|
8694
|
+
constructor(db, options = {}) {
|
|
8160
8695
|
this.db = db;
|
|
8696
|
+
this.options = options;
|
|
8161
8697
|
}
|
|
8162
8698
|
validateInput(input) {
|
|
8163
8699
|
if (!input.name?.trim()) throw new Error("name is required");
|
|
@@ -8174,8 +8710,16 @@ var AlertsStore = class {
|
|
|
8174
8710
|
for (const ch of input.channels) {
|
|
8175
8711
|
if (ch.type !== "webhook")
|
|
8176
8712
|
throw new Error(`unknown channel type: ${ch.type}`);
|
|
8177
|
-
if (!ch.url ||
|
|
8713
|
+
if (!ch.url || typeof ch.url !== "string")
|
|
8178
8714
|
throw new Error("channel.url must be http(s)://");
|
|
8715
|
+
try {
|
|
8716
|
+
assertSafeWebhookUrl(ch.url, {
|
|
8717
|
+
allowPrivateNetworks: this.options.allowPrivateWebhookTargets
|
|
8718
|
+
});
|
|
8719
|
+
} catch (err) {
|
|
8720
|
+
if (err instanceof WebhookUrlError) throw new Error(err.message);
|
|
8721
|
+
throw err;
|
|
8722
|
+
}
|
|
8179
8723
|
}
|
|
8180
8724
|
}
|
|
8181
8725
|
async listRules(projectId) {
|
|
@@ -8260,9 +8804,15 @@ var AlertsStore = class {
|
|
|
8260
8804
|
return this.getRule(id, projectId);
|
|
8261
8805
|
}
|
|
8262
8806
|
async deleteRule(id, projectId) {
|
|
8807
|
+
const owned = await this.db.prepare(
|
|
8808
|
+
`SELECT 1 AS ok FROM alert_rules WHERE id = ? AND project_id = ?`
|
|
8809
|
+
).bind(id, projectId).first();
|
|
8810
|
+
if (!owned) return false;
|
|
8811
|
+
await this.db.prepare(`DELETE FROM alert_state WHERE rule_id = ? AND project_id = ?`).bind(id, projectId).run();
|
|
8812
|
+
await this.db.prepare(
|
|
8813
|
+
`DELETE FROM alert_evaluations WHERE rule_id = ? AND project_id = ?`
|
|
8814
|
+
).bind(id, projectId).run();
|
|
8263
8815
|
const result = await this.db.prepare(`DELETE FROM alert_rules WHERE id = ? AND project_id = ?`).bind(id, projectId).run();
|
|
8264
|
-
await this.db.prepare(`DELETE FROM alert_state WHERE rule_id = ?`).bind(id).run();
|
|
8265
|
-
await this.db.prepare(`DELETE FROM alert_evaluations WHERE rule_id = ?`).bind(id).run();
|
|
8266
8816
|
return (result.meta?.changes ?? 0) > 0;
|
|
8267
8817
|
}
|
|
8268
8818
|
async listEnabledRules() {
|
|
@@ -8270,16 +8820,58 @@ var AlertsStore = class {
|
|
|
8270
8820
|
return (rs.results ?? []).map((r) => rowToRule(r));
|
|
8271
8821
|
}
|
|
8272
8822
|
// ── State ──
|
|
8273
|
-
async getState(ruleId) {
|
|
8274
|
-
const row = await this.db.prepare(`SELECT * FROM alert_state WHERE rule_id = ?`).bind(ruleId).first();
|
|
8823
|
+
async getState(ruleId, projectId) {
|
|
8824
|
+
const row = await this.db.prepare(`SELECT * FROM alert_state WHERE rule_id = ? AND project_id = ?`).bind(ruleId, projectId).first();
|
|
8275
8825
|
return row ?? null;
|
|
8276
8826
|
}
|
|
8827
|
+
/**
|
|
8828
|
+
* Record a state transition with its notification still outstanding
|
|
8829
|
+
* (`notified = 0`); `markNotified` flips it once a channel accepts the
|
|
8830
|
+
* alert. On a database that has not applied migration 049 the column is
|
|
8831
|
+
* missing: the transition is still written (legacy statement) so alerting
|
|
8832
|
+
* keeps working through a rolling deploy, and the evaluator treats a row
|
|
8833
|
+
* without the column as delivered.
|
|
8834
|
+
*/
|
|
8277
8835
|
async transitionState(ruleId, projectId, newState, now) {
|
|
8278
|
-
|
|
8279
|
-
|
|
8836
|
+
try {
|
|
8837
|
+
await this.db.prepare(
|
|
8838
|
+
`INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change, notified)
|
|
8839
|
+
VALUES (?, ?, ?, ?, 0)
|
|
8840
|
+
ON CONFLICT(rule_id) DO UPDATE SET
|
|
8841
|
+
current_state = excluded.current_state,
|
|
8842
|
+
last_state_change = excluded.last_state_change,
|
|
8843
|
+
notified = 0`
|
|
8844
|
+
).bind(ruleId, projectId, newState, now).run();
|
|
8845
|
+
} catch (err) {
|
|
8846
|
+
if (!isMissingNotifiedColumn(err)) throw err;
|
|
8847
|
+
await this.db.prepare(
|
|
8848
|
+
`INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change)
|
|
8280
8849
|
VALUES (?, ?, ?, ?)
|
|
8281
|
-
ON CONFLICT(rule_id) DO UPDATE SET
|
|
8282
|
-
|
|
8850
|
+
ON CONFLICT(rule_id) DO UPDATE SET
|
|
8851
|
+
current_state = excluded.current_state,
|
|
8852
|
+
last_state_change = excluded.last_state_change`
|
|
8853
|
+
).bind(ruleId, projectId, newState, now).run();
|
|
8854
|
+
}
|
|
8855
|
+
}
|
|
8856
|
+
/**
|
|
8857
|
+
* Mark the transition `(state, lastStateChange)` as delivered. Scoped to
|
|
8858
|
+
* the exact transition the caller notified for: if the rule changed state
|
|
8859
|
+
* again while the webhook was in flight, the newer transition keeps its
|
|
8860
|
+
* own outstanding flag. Returns false when nothing matched (stale or
|
|
8861
|
+
* already marked). On a pre-049 schema there is nothing to mark.
|
|
8862
|
+
*/
|
|
8863
|
+
async markNotified(ruleId, projectId, state, lastStateChange) {
|
|
8864
|
+
try {
|
|
8865
|
+
const result = await this.db.prepare(
|
|
8866
|
+
`UPDATE alert_state SET notified = 1
|
|
8867
|
+
WHERE rule_id = ? AND project_id = ? AND current_state = ?
|
|
8868
|
+
AND last_state_change = ? AND notified = 0`
|
|
8869
|
+
).bind(ruleId, projectId, state, lastStateChange).run();
|
|
8870
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
8871
|
+
} catch (err) {
|
|
8872
|
+
if (isMissingNotifiedColumn(err)) return false;
|
|
8873
|
+
throw err;
|
|
8874
|
+
}
|
|
8283
8875
|
}
|
|
8284
8876
|
async recordEvaluation(ruleId, projectId, value, state, notified) {
|
|
8285
8877
|
await this.db.prepare(
|
|
@@ -8413,16 +9005,48 @@ var AlertsStore = class {
|
|
|
8413
9005
|
sql += ` AND model_name = ?`;
|
|
8414
9006
|
binds.push(q.model);
|
|
8415
9007
|
}
|
|
8416
|
-
if (q.isError === true) sql += ` AND is_error
|
|
9008
|
+
if (q.isError === true) sql += ` AND is_error`;
|
|
8417
9009
|
const row = await this.db.prepare(sql).bind(...binds).first();
|
|
8418
9010
|
return row?.c ?? 0;
|
|
8419
9011
|
}
|
|
8420
9012
|
};
|
|
9013
|
+
var isMissingNotifiedColumn = (err) => {
|
|
9014
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
9015
|
+
return /notified/i.test(message) && /no such column|has no column|does not exist/i.test(message);
|
|
9016
|
+
};
|
|
8421
9017
|
|
|
8422
9018
|
// src/plugins/alerts-evaluator.ts
|
|
8423
9019
|
var DEFAULT_RULE_CONCURRENCY = 5;
|
|
8424
9020
|
var DEFAULT_RULE_TIMEOUT_MS = 1e4;
|
|
8425
|
-
async function fireWebhook(channel, payload, logger, tracer) {
|
|
9021
|
+
async function fireWebhook(channel, payload, logger, tracer, delivery = {}) {
|
|
9022
|
+
const target = redactWebhookUrl(channel.url);
|
|
9023
|
+
try {
|
|
9024
|
+
const url = assertSafeWebhookUrl(channel.url, {
|
|
9025
|
+
allowPrivateNetworks: delivery.allowPrivateWebhookTargets
|
|
9026
|
+
});
|
|
9027
|
+
if (!delivery.allowPrivateWebhookTargets && delivery.hostLookup) {
|
|
9028
|
+
const resolution = await resolveWebhookHost(
|
|
9029
|
+
url.hostname,
|
|
9030
|
+
delivery.hostLookup
|
|
9031
|
+
);
|
|
9032
|
+
if (resolution === "private") {
|
|
9033
|
+
throw new Error(
|
|
9034
|
+
`channel.url host "${url.hostname}" resolves to a private address`
|
|
9035
|
+
);
|
|
9036
|
+
}
|
|
9037
|
+
if (resolution === "unresolvable") {
|
|
9038
|
+
throw new Error(
|
|
9039
|
+
`channel.url host "${url.hostname}" could not be resolved; refusing to deliver blind`
|
|
9040
|
+
);
|
|
9041
|
+
}
|
|
9042
|
+
}
|
|
9043
|
+
} catch (err) {
|
|
9044
|
+
logger.error("[alerts-evaluator] webhook target refused", {
|
|
9045
|
+
url: target,
|
|
9046
|
+
error: err instanceof Error ? err.message : String(err)
|
|
9047
|
+
});
|
|
9048
|
+
return false;
|
|
9049
|
+
}
|
|
8426
9050
|
const exec = async (span) => {
|
|
8427
9051
|
try {
|
|
8428
9052
|
const response = await fetch(channel.url, {
|
|
@@ -8432,13 +9056,24 @@ async function fireWebhook(channel, payload, logger, tracer) {
|
|
|
8432
9056
|
...channel.headers ?? {}
|
|
8433
9057
|
},
|
|
8434
9058
|
body: JSON.stringify(payload),
|
|
9059
|
+
// Never follow redirects: a 3xx could re-aim the POST (and its
|
|
9060
|
+
// headers) at an arbitrary host. A redirecting hook is a
|
|
9061
|
+
// misconfiguration and counts as a failed delivery.
|
|
9062
|
+
redirect: "manual",
|
|
8435
9063
|
signal: AbortSignal.timeout(5e3)
|
|
8436
9064
|
});
|
|
8437
9065
|
if (span) span.setAttribute("http.response.status_code", response.status);
|
|
9066
|
+
if (response.status >= 300 && response.status < 400) {
|
|
9067
|
+
logger.error("[alerts-evaluator] webhook responded with a redirect", {
|
|
9068
|
+
url: target,
|
|
9069
|
+
status: response.status
|
|
9070
|
+
});
|
|
9071
|
+
return false;
|
|
9072
|
+
}
|
|
8438
9073
|
return response.ok;
|
|
8439
9074
|
} catch (err) {
|
|
8440
9075
|
logger.error("[alerts-evaluator] webhook delivery failed", {
|
|
8441
|
-
url:
|
|
9076
|
+
url: target,
|
|
8442
9077
|
error: err instanceof Error ? err.message : String(err)
|
|
8443
9078
|
});
|
|
8444
9079
|
return false;
|
|
@@ -8447,14 +9082,14 @@ async function fireWebhook(channel, payload, logger, tracer) {
|
|
|
8447
9082
|
if (!tracer) return exec(null);
|
|
8448
9083
|
return tracer("webhook.alert", async (span) => exec(span), {
|
|
8449
9084
|
"http.method": "POST",
|
|
8450
|
-
"http.url":
|
|
9085
|
+
"http.url": target,
|
|
8451
9086
|
"alert.state": payload.state
|
|
8452
9087
|
});
|
|
8453
9088
|
}
|
|
8454
|
-
async function fireChannels(channels, payload, logger, tracer) {
|
|
9089
|
+
async function fireChannels(channels, payload, logger, tracer, delivery = {}) {
|
|
8455
9090
|
if (channels.length === 0) return true;
|
|
8456
9091
|
const results = await Promise.all(
|
|
8457
|
-
channels.filter((ch) => ch.type === "webhook").map((ch) => fireWebhook(ch, payload, logger, tracer))
|
|
9092
|
+
channels.filter((ch) => ch.type === "webhook").map((ch) => fireWebhook(ch, payload, logger, tracer, delivery))
|
|
8458
9093
|
);
|
|
8459
9094
|
return results.every(Boolean);
|
|
8460
9095
|
}
|
|
@@ -8485,7 +9120,8 @@ async function mapWithConcurrency(items, concurrency, mapper) {
|
|
|
8485
9120
|
);
|
|
8486
9121
|
return results;
|
|
8487
9122
|
}
|
|
8488
|
-
async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
|
|
9123
|
+
async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, delivery) {
|
|
9124
|
+
const none = { fired: 0, resolved: 0, redelivered: 0 };
|
|
8489
9125
|
try {
|
|
8490
9126
|
const value = await withTimeout(
|
|
8491
9127
|
store.evaluateRule(rule),
|
|
@@ -8493,7 +9129,7 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
|
|
|
8493
9129
|
`rule evaluation timed out after ${ruleTimeoutMs}ms`
|
|
8494
9130
|
);
|
|
8495
9131
|
const shouldFire = compareValue(value, rule.threshold, rule.comparison);
|
|
8496
|
-
const priorState = await store.getState(rule.id);
|
|
9132
|
+
const priorState = await store.getState(rule.id, rule.projectId);
|
|
8497
9133
|
const previous = priorState?.current_state ?? "ok";
|
|
8498
9134
|
const next = shouldFire ? "firing" : "ok";
|
|
8499
9135
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -8504,51 +9140,58 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
|
|
|
8504
9140
|
status: n.status
|
|
8505
9141
|
} : void 0
|
|
8506
9142
|
).catch(() => void 0) : void 0;
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
|
|
8510
|
-
{
|
|
8511
|
-
rule: { id: rule.id, name: rule.name, signal: rule.signal },
|
|
8512
|
-
value,
|
|
8513
|
-
threshold: rule.threshold,
|
|
8514
|
-
comparison: rule.comparison,
|
|
8515
|
-
state: "firing",
|
|
8516
|
-
evaluatedAt: now,
|
|
8517
|
-
projectId: rule.projectId,
|
|
8518
|
-
analysis: analysisAttachment
|
|
8519
|
-
},
|
|
8520
|
-
logger,
|
|
8521
|
-
tracer
|
|
8522
|
-
);
|
|
8523
|
-
await store.transitionState(rule.id, rule.projectId, "firing", now);
|
|
8524
|
-
await store.recordEvaluation(
|
|
8525
|
-
rule.id,
|
|
8526
|
-
rule.projectId,
|
|
9143
|
+
const deliver = (state) => fireChannels(
|
|
9144
|
+
rule.channels,
|
|
9145
|
+
{
|
|
9146
|
+
rule: { id: rule.id, name: rule.name, signal: rule.signal },
|
|
8527
9147
|
value,
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
9148
|
+
threshold: rule.threshold,
|
|
9149
|
+
comparison: rule.comparison,
|
|
9150
|
+
state,
|
|
9151
|
+
evaluatedAt: now,
|
|
9152
|
+
projectId: rule.projectId,
|
|
9153
|
+
analysis: analysisAttachment
|
|
9154
|
+
},
|
|
9155
|
+
logger,
|
|
9156
|
+
tracer,
|
|
9157
|
+
delivery
|
|
9158
|
+
);
|
|
9159
|
+
if (previous !== next) {
|
|
9160
|
+
await store.transitionState(rule.id, rule.projectId, next, now);
|
|
9161
|
+
const ok = await deliver(next);
|
|
9162
|
+
if (ok) {
|
|
9163
|
+
await store.markNotified(rule.id, rule.projectId, next, now);
|
|
9164
|
+
}
|
|
9165
|
+
await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
|
|
9166
|
+
if (!ok) {
|
|
9167
|
+
logger.warn(
|
|
9168
|
+
"[alerts-evaluator] state recorded but delivery failed; will retry",
|
|
9169
|
+
{ rule_id: rule.id, project_id: rule.projectId, state: next }
|
|
9170
|
+
);
|
|
9171
|
+
}
|
|
9172
|
+
return next === "firing" ? { fired: 1, resolved: 0, redelivered: 0 } : { fired: 0, resolved: 1, redelivered: 0 };
|
|
8532
9173
|
}
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
logger,
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
9174
|
+
const owed = priorState !== null && priorState.notified === 0;
|
|
9175
|
+
if (owed) {
|
|
9176
|
+
const ok = await deliver(previous);
|
|
9177
|
+
if (ok) {
|
|
9178
|
+
await store.markNotified(
|
|
9179
|
+
rule.id,
|
|
9180
|
+
rule.projectId,
|
|
9181
|
+
previous,
|
|
9182
|
+
priorState.last_state_change
|
|
9183
|
+
);
|
|
9184
|
+
}
|
|
9185
|
+
await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
|
|
9186
|
+
if (!ok) {
|
|
9187
|
+
logger.warn("[alerts-evaluator] delivery retry failed; will retry", {
|
|
9188
|
+
rule_id: rule.id,
|
|
9189
|
+
project_id: rule.projectId,
|
|
9190
|
+
state: previous
|
|
9191
|
+
});
|
|
9192
|
+
return none;
|
|
9193
|
+
}
|
|
9194
|
+
return { fired: 0, resolved: 0, redelivered: 1 };
|
|
8552
9195
|
}
|
|
8553
9196
|
await store.recordEvaluation(rule.id, rule.projectId, value, next, false);
|
|
8554
9197
|
} catch (err) {
|
|
@@ -8558,10 +9201,14 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
|
|
|
8558
9201
|
error: err instanceof Error ? err.message : String(err)
|
|
8559
9202
|
});
|
|
8560
9203
|
}
|
|
8561
|
-
return
|
|
9204
|
+
return none;
|
|
8562
9205
|
}
|
|
8563
9206
|
async function evaluateRuleBatch(store, rules, options) {
|
|
8564
9207
|
const logger = options?.logger ?? consoleLogger;
|
|
9208
|
+
const delivery = {
|
|
9209
|
+
allowPrivateWebhookTargets: options?.allowPrivateWebhookTargets,
|
|
9210
|
+
hostLookup: options?.hostLookup
|
|
9211
|
+
};
|
|
8565
9212
|
const concurrency = Math.max(
|
|
8566
9213
|
1,
|
|
8567
9214
|
Math.floor(options?.concurrency ?? DEFAULT_RULE_CONCURRENCY)
|
|
@@ -8573,24 +9220,35 @@ async function evaluateRuleBatch(store, rules, options) {
|
|
|
8573
9220
|
const results = await mapWithConcurrency(
|
|
8574
9221
|
rules,
|
|
8575
9222
|
concurrency,
|
|
8576
|
-
(rule) => evaluateOneRule(
|
|
9223
|
+
(rule) => evaluateOneRule(
|
|
9224
|
+
store,
|
|
9225
|
+
rule,
|
|
9226
|
+
logger,
|
|
9227
|
+
options?.tracer,
|
|
9228
|
+
ruleTimeoutMs,
|
|
9229
|
+
delivery
|
|
9230
|
+
)
|
|
8577
9231
|
);
|
|
8578
9232
|
return {
|
|
8579
9233
|
evaluated: rules.length,
|
|
8580
9234
|
fired: results.reduce((sum, result) => sum + result.fired, 0),
|
|
8581
|
-
resolved: results.reduce((sum, result) => sum + result.resolved, 0)
|
|
9235
|
+
resolved: results.reduce((sum, result) => sum + result.resolved, 0),
|
|
9236
|
+
redelivered: results.reduce((sum, result) => sum + result.redelivered, 0)
|
|
8582
9237
|
};
|
|
8583
9238
|
}
|
|
8584
9239
|
async function evaluateAllRules(env, options) {
|
|
8585
9240
|
const logger = options?.logger ?? consoleLogger;
|
|
8586
9241
|
const tracer = options?.tracer;
|
|
8587
|
-
const
|
|
9242
|
+
const allowPrivateWebhookTargets = env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true";
|
|
9243
|
+
const store = new AlertsStore(sqlDbFor(env), { allowPrivateWebhookTargets });
|
|
8588
9244
|
const rules = await store.listEnabledRules();
|
|
8589
9245
|
return evaluateRuleBatch(store, rules, {
|
|
8590
9246
|
logger,
|
|
8591
9247
|
tracer,
|
|
8592
9248
|
concurrency: options?.concurrency,
|
|
8593
|
-
ruleTimeoutMs: options?.ruleTimeoutMs
|
|
9249
|
+
ruleTimeoutMs: options?.ruleTimeoutMs,
|
|
9250
|
+
allowPrivateWebhookTargets,
|
|
9251
|
+
hostLookup: options?.hostLookup
|
|
8594
9252
|
});
|
|
8595
9253
|
}
|
|
8596
9254
|
function createAlertEvaluatorHandler() {
|
|
@@ -8603,12 +9261,15 @@ function createAlertEvaluatorHandler() {
|
|
|
8603
9261
|
|
|
8604
9262
|
// src/plugins/alerts-routes.ts
|
|
8605
9263
|
import { EVIDENCE_REFERENCE_CONTRACT as EVIDENCE_REFERENCE_CONTRACT5 } from "@obsunified/types";
|
|
9264
|
+
var storeOptions = (env) => ({
|
|
9265
|
+
allowPrivateWebhookTargets: env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true"
|
|
9266
|
+
});
|
|
8606
9267
|
var alertsRoutesPlugin = {
|
|
8607
9268
|
name: "alerts-routes",
|
|
8608
9269
|
register(app) {
|
|
8609
9270
|
app.get("/internal/alerts/rules", async (c) => {
|
|
8610
9271
|
const projectId = getProjectId(c);
|
|
8611
|
-
const store = new AlertsStore(sqlDbFor(c.env));
|
|
9272
|
+
const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
|
|
8612
9273
|
const rules = await store.listRules(projectId);
|
|
8613
9274
|
return c.json({ rules });
|
|
8614
9275
|
});
|
|
@@ -8620,7 +9281,7 @@ var alertsRoutesPlugin = {
|
|
|
8620
9281
|
} catch {
|
|
8621
9282
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
8622
9283
|
}
|
|
8623
|
-
const store = new AlertsStore(sqlDbFor(c.env));
|
|
9284
|
+
const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
|
|
8624
9285
|
try {
|
|
8625
9286
|
const rule = await store.createRule(projectId, input);
|
|
8626
9287
|
return c.json({ rule }, 201);
|
|
@@ -8638,7 +9299,7 @@ var alertsRoutesPlugin = {
|
|
|
8638
9299
|
} catch {
|
|
8639
9300
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
8640
9301
|
}
|
|
8641
|
-
const store = new AlertsStore(sqlDbFor(c.env));
|
|
9302
|
+
const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
|
|
8642
9303
|
try {
|
|
8643
9304
|
const rule = await store.updateRule(id, projectId, patch);
|
|
8644
9305
|
if (!rule) return c.json({ error: "Rule not found" }, 404);
|
|
@@ -8651,7 +9312,7 @@ var alertsRoutesPlugin = {
|
|
|
8651
9312
|
app.delete("/internal/alerts/rules/:id", async (c) => {
|
|
8652
9313
|
const projectId = getProjectId(c);
|
|
8653
9314
|
const id = c.req.param("id");
|
|
8654
|
-
const store = new AlertsStore(sqlDbFor(c.env));
|
|
9315
|
+
const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
|
|
8655
9316
|
const deleted = await store.deleteRule(id, projectId);
|
|
8656
9317
|
if (!deleted) return c.json({ error: "Rule not found" }, 404);
|
|
8657
9318
|
return c.json({ success: true });
|
|
@@ -8664,7 +9325,7 @@ var alertsRoutesPlugin = {
|
|
|
8664
9325
|
1,
|
|
8665
9326
|
Math.min(720, Number.parseInt(c.req.query("hours") || "24", 10) || 24)
|
|
8666
9327
|
);
|
|
8667
|
-
const store = new AlertsStore(sqlDbFor(c.env));
|
|
9328
|
+
const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
|
|
8668
9329
|
const response = await store.listEvaluations({
|
|
8669
9330
|
ruleId,
|
|
8670
9331
|
projectId,
|
|
@@ -8675,7 +9336,7 @@ var alertsRoutesPlugin = {
|
|
|
8675
9336
|
app.post("/internal/alerts/rules/:id/test", async (c) => {
|
|
8676
9337
|
const projectId = getProjectId(c);
|
|
8677
9338
|
const id = c.req.param("id");
|
|
8678
|
-
const store = new AlertsStore(sqlDbFor(c.env));
|
|
9339
|
+
const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
|
|
8679
9340
|
const rule = await store.getRule(id, projectId);
|
|
8680
9341
|
if (!rule) return c.json({ error: "Rule not found" }, 404);
|
|
8681
9342
|
const value = await store.evaluateRule(rule);
|
|
@@ -9082,12 +9743,10 @@ var askRoutesPlugin = {
|
|
|
9082
9743
|
name: "ask-routes",
|
|
9083
9744
|
register(app, runtime) {
|
|
9084
9745
|
app.post("/internal/ask", async (c) => {
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
body = await c.req.json();
|
|
9088
|
-
} catch {
|
|
9746
|
+
const body = await readJsonObject(c);
|
|
9747
|
+
if (!body) {
|
|
9089
9748
|
return c.json(
|
|
9090
|
-
{ error: "Bad Request", message: "body must be JSON" },
|
|
9749
|
+
{ error: "Bad Request", message: "body must be a JSON object" },
|
|
9091
9750
|
400
|
|
9092
9751
|
);
|
|
9093
9752
|
}
|
|
@@ -9777,12 +10436,22 @@ var connectedRoutesPlugin = {
|
|
|
9777
10436
|
const otherSpans = traceManifest.spans.filter(
|
|
9778
10437
|
(s) => s.spanId !== span.spanId
|
|
9779
10438
|
);
|
|
10439
|
+
const interactionManifest = span.interactionId ? await index.byInteraction(projectId, span.interactionId) : null;
|
|
10440
|
+
const parentSessionId = interactionManifest?.usageEvents.find(
|
|
10441
|
+
(e) => Boolean(e.sessionId)
|
|
10442
|
+
)?.sessionId;
|
|
9780
10443
|
manifest.up = [
|
|
9781
10444
|
{
|
|
9782
10445
|
label: "Trace",
|
|
9783
10446
|
links: [linkToTrace(span.traceId, "Parent trace")]
|
|
9784
10447
|
}
|
|
9785
10448
|
];
|
|
10449
|
+
if (parentSessionId) {
|
|
10450
|
+
manifest.up.push({
|
|
10451
|
+
label: "User session",
|
|
10452
|
+
links: [linkToSession(parentSessionId)]
|
|
10453
|
+
});
|
|
10454
|
+
}
|
|
9786
10455
|
manifest.across = [
|
|
9787
10456
|
linksFromSpans(otherSpans, `Other spans in this trace`),
|
|
9788
10457
|
linksFromLogs(traceManifest.logs, "Logs in this trace"),
|
|
@@ -9816,11 +10485,7 @@ var connectedRoutesPlugin = {
|
|
|
9816
10485
|
"Metric exemplars in this trace"
|
|
9817
10486
|
)
|
|
9818
10487
|
];
|
|
9819
|
-
if (
|
|
9820
|
-
const interactionManifest = await index.byInteraction(
|
|
9821
|
-
projectId,
|
|
9822
|
-
span.interactionId
|
|
9823
|
-
);
|
|
10488
|
+
if (interactionManifest) {
|
|
9824
10489
|
manifest.related = [
|
|
9825
10490
|
linksFromUsage(
|
|
9826
10491
|
interactionManifest.usageEvents,
|
|
@@ -10435,7 +11100,12 @@ var dashboardRoutesPlugin = {
|
|
|
10435
11100
|
url.pathname = "/dashboard/";
|
|
10436
11101
|
return assets.fetch(new Request(url.toString(), c.req.raw));
|
|
10437
11102
|
}
|
|
10438
|
-
return c.
|
|
11103
|
+
return c.json(
|
|
11104
|
+
{
|
|
11105
|
+
error: "Dashboard assets are not configured on this collector. Serve @obsunified/dashboard separately or bind [assets] in wrangler.toml."
|
|
11106
|
+
},
|
|
11107
|
+
404
|
|
11108
|
+
);
|
|
10439
11109
|
});
|
|
10440
11110
|
app.get("/", (c) => {
|
|
10441
11111
|
return c.redirect("/dashboard/");
|
|
@@ -11182,12 +11852,8 @@ var evalCasesRoutesPlugin = {
|
|
|
11182
11852
|
name: "eval-cases-routes",
|
|
11183
11853
|
register(app) {
|
|
11184
11854
|
app.post("/internal/eval-cases", async (c) => {
|
|
11185
|
-
|
|
11186
|
-
|
|
11187
|
-
body = await c.req.json();
|
|
11188
|
-
} catch {
|
|
11189
|
-
return c.json(badRequest("body must be JSON"), 400);
|
|
11190
|
-
}
|
|
11855
|
+
const body = await readJsonObject(c);
|
|
11856
|
+
if (!body) return c.json(badRequest("body must be a JSON object"), 400);
|
|
11191
11857
|
const sourceType = body.sourceEntityType ?? body.source?.type;
|
|
11192
11858
|
if (!isEvalCaseSourceType(sourceType)) {
|
|
11193
11859
|
return c.json(
|
|
@@ -11280,12 +11946,8 @@ var evalCasesRoutesPlugin = {
|
|
|
11280
11946
|
return c.json({ evalCase });
|
|
11281
11947
|
});
|
|
11282
11948
|
app.post("/internal/eval-runs", async (c) => {
|
|
11283
|
-
|
|
11284
|
-
|
|
11285
|
-
body = await c.req.json();
|
|
11286
|
-
} catch {
|
|
11287
|
-
return c.json(badRequest("body must be JSON"), 400);
|
|
11288
|
-
}
|
|
11949
|
+
const body = await readJsonObject(c);
|
|
11950
|
+
if (!body) return c.json(badRequest("body must be a JSON object"), 400);
|
|
11289
11951
|
const id = asOptionalString(body.id ?? body.runId)?.trim();
|
|
11290
11952
|
if ((body.id ?? body.runId) !== void 0 && !id) {
|
|
11291
11953
|
return c.json(badRequest("id must be a non-empty string"), 400);
|
|
@@ -11399,11 +12061,9 @@ var evalCasesRoutesPlugin = {
|
|
|
11399
12061
|
const evalCaseId = c.req.param("id");
|
|
11400
12062
|
if (!evalCaseId) return c.json(badRequest("id is required"), 400);
|
|
11401
12063
|
let body;
|
|
11402
|
-
|
|
11403
|
-
|
|
11404
|
-
|
|
11405
|
-
return c.json(badRequest("body must be JSON"), 400);
|
|
11406
|
-
}
|
|
12064
|
+
const parsed = await readJsonObject(c);
|
|
12065
|
+
if (!parsed) return c.json(badRequest("body must be a JSON object"), 400);
|
|
12066
|
+
body = parsed;
|
|
11407
12067
|
if (body.passed === void 0 || typeof body.passed !== "boolean") {
|
|
11408
12068
|
return c.json(badRequest("passed must be a boolean"), 400);
|
|
11409
12069
|
}
|
|
@@ -13664,12 +14324,8 @@ var identityReceiverPlugin = {
|
|
|
13664
14324
|
register(app) {
|
|
13665
14325
|
app.post("/v1/identify", async (c) => {
|
|
13666
14326
|
const projectId = getProjectId(c);
|
|
13667
|
-
|
|
13668
|
-
|
|
13669
|
-
payload = await c.req.json();
|
|
13670
|
-
} catch {
|
|
13671
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
13672
|
-
}
|
|
14327
|
+
const payload = await readJsonObject(c);
|
|
14328
|
+
if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
|
|
13673
14329
|
if (!payload.userId || typeof payload.userId !== "string") {
|
|
13674
14330
|
return c.json({ error: "userId is required" }, 400);
|
|
13675
14331
|
}
|
|
@@ -13696,7 +14352,7 @@ var identityReceiverPlugin = {
|
|
|
13696
14352
|
await db.prepare(
|
|
13697
14353
|
`INSERT INTO user_profiles (project_id, user_id, visitor_id, email, name, properties_json, first_seen_at, last_seen_at)
|
|
13698
14354
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
13699
|
-
ON CONFLICT(user_id) DO UPDATE SET
|
|
14355
|
+
ON CONFLICT(project_id, user_id) DO UPDATE SET
|
|
13700
14356
|
visitor_id = excluded.visitor_id,
|
|
13701
14357
|
email = COALESCE(excluded.email, user_profiles.email),
|
|
13702
14358
|
name = COALESCE(excluded.name, user_profiles.name),
|
|
@@ -13948,13 +14604,22 @@ var OtlpDecodeError = class extends Error {
|
|
|
13948
14604
|
this.status = status;
|
|
13949
14605
|
}
|
|
13950
14606
|
};
|
|
13951
|
-
var readOtlpBody = async (c) => {
|
|
14607
|
+
var readOtlpBody = async (c, limits = {}) => {
|
|
13952
14608
|
const contentType = (c.req.header("content-type") ?? "").toLowerCase();
|
|
13953
14609
|
const wireFormat = detectWireFormat(contentType);
|
|
14610
|
+
const env = c.env ?? {};
|
|
14611
|
+
const maxBodyBytes = limits.maxBodyBytes ?? parseByteLimit(env.MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
14612
|
+
const maxDecompressedBytes = limits.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
|
|
13954
14613
|
let body = await c.req.arrayBuffer();
|
|
14614
|
+
if (body.byteLength > maxBodyBytes) {
|
|
14615
|
+
throw new OtlpDecodeError(
|
|
14616
|
+
`Payload too large (max ${maxBodyBytes} bytes)`,
|
|
14617
|
+
413
|
|
14618
|
+
);
|
|
14619
|
+
}
|
|
13955
14620
|
const encoding = (c.req.header("content-encoding") ?? "").toLowerCase();
|
|
13956
14621
|
if (encoding === "gzip") {
|
|
13957
|
-
body = await gunzip(body);
|
|
14622
|
+
body = await gunzip(body, maxDecompressedBytes);
|
|
13958
14623
|
} else if (encoding && encoding !== "identity") {
|
|
13959
14624
|
throw new OtlpDecodeError(`Unsupported content-encoding: ${encoding}`, 415);
|
|
13960
14625
|
}
|
|
@@ -13968,12 +14633,40 @@ var detectWireFormat = (contentType) => {
|
|
|
13968
14633
|
415
|
|
13969
14634
|
);
|
|
13970
14635
|
};
|
|
13971
|
-
var gunzip = async (input) => {
|
|
14636
|
+
var gunzip = async (input, maxBytes) => {
|
|
13972
14637
|
const stream = new Response(input).body?.pipeThrough(
|
|
13973
14638
|
new DecompressionStream("gzip")
|
|
13974
14639
|
);
|
|
13975
14640
|
if (!stream) throw new OtlpDecodeError("Empty gzip body", 400);
|
|
13976
|
-
|
|
14641
|
+
const reader = stream.getReader();
|
|
14642
|
+
const chunks = [];
|
|
14643
|
+
let total = 0;
|
|
14644
|
+
try {
|
|
14645
|
+
for (; ; ) {
|
|
14646
|
+
const { done, value } = await reader.read();
|
|
14647
|
+
if (done) break;
|
|
14648
|
+
total += value.byteLength;
|
|
14649
|
+
if (total > maxBytes) {
|
|
14650
|
+
await reader.cancel().catch(() => {
|
|
14651
|
+
});
|
|
14652
|
+
throw new OtlpDecodeError(
|
|
14653
|
+
`Decompressed payload too large (max ${maxBytes} bytes)`,
|
|
14654
|
+
413
|
|
14655
|
+
);
|
|
14656
|
+
}
|
|
14657
|
+
chunks.push(value);
|
|
14658
|
+
}
|
|
14659
|
+
} catch (err) {
|
|
14660
|
+
if (err instanceof OtlpDecodeError) throw err;
|
|
14661
|
+
throw new OtlpDecodeError("Malformed gzip body", 400);
|
|
14662
|
+
}
|
|
14663
|
+
const out = new Uint8Array(total);
|
|
14664
|
+
let offset = 0;
|
|
14665
|
+
for (const chunk of chunks) {
|
|
14666
|
+
out.set(chunk, offset);
|
|
14667
|
+
offset += chunk.byteLength;
|
|
14668
|
+
}
|
|
14669
|
+
return out.buffer;
|
|
13977
14670
|
};
|
|
13978
14671
|
var decodeJsonBody = (bytes) => {
|
|
13979
14672
|
const text = new TextDecoder().decode(bytes);
|
|
@@ -14827,17 +15520,20 @@ var onboardingRoutesPlugin = {
|
|
|
14827
15520
|
register(app) {
|
|
14828
15521
|
app.get("/internal/onboarding/counts", async (c) => {
|
|
14829
15522
|
const db = sqlDbFor(c.env);
|
|
15523
|
+
const projectId = getProjectId(c);
|
|
15524
|
+
const count = (table, extra = "") => safeCount(
|
|
15525
|
+
db,
|
|
15526
|
+
`SELECT COUNT(*) AS n FROM ${table} WHERE project_id = ?${extra}`,
|
|
15527
|
+
projectId
|
|
15528
|
+
);
|
|
14830
15529
|
const counts = await Promise.all([
|
|
14831
|
-
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
db,
|
|
14839
|
-
"SELECT COUNT(*) AS n FROM telemetry_spans WHERE interaction_id IS NOT NULL"
|
|
14840
|
-
)
|
|
15530
|
+
count("telemetry_spans"),
|
|
15531
|
+
count("logs"),
|
|
15532
|
+
count("usage_events"),
|
|
15533
|
+
count("user_profiles"),
|
|
15534
|
+
count("ai_calls"),
|
|
15535
|
+
count("session_replay_metadata"),
|
|
15536
|
+
count("telemetry_spans", " AND interaction_id IS NOT NULL")
|
|
14841
15537
|
]);
|
|
14842
15538
|
return c.json({
|
|
14843
15539
|
spans: counts[0],
|
|
@@ -14851,9 +15547,9 @@ var onboardingRoutesPlugin = {
|
|
|
14851
15547
|
});
|
|
14852
15548
|
}
|
|
14853
15549
|
};
|
|
14854
|
-
async function safeCount(db, sql) {
|
|
15550
|
+
async function safeCount(db, sql, ...binds) {
|
|
14855
15551
|
try {
|
|
14856
|
-
const row = await db.prepare(sql).first();
|
|
15552
|
+
const row = await db.prepare(sql).bind(...binds).first();
|
|
14857
15553
|
return Number(row?.n ?? 0);
|
|
14858
15554
|
} catch {
|
|
14859
15555
|
return 0;
|
|
@@ -15616,9 +16312,10 @@ var projectsRoutesPlugin = {
|
|
|
15616
16312
|
}
|
|
15617
16313
|
});
|
|
15618
16314
|
app.delete("/internal/projects/:id/keys/:keyId", async (c) => {
|
|
16315
|
+
const projectId = c.req.param("id");
|
|
15619
16316
|
const keyId = c.req.param("keyId");
|
|
15620
16317
|
const store = new ProjectsStore(sqlDbFor(c.env));
|
|
15621
|
-
const revoked = await store.revokeKey(keyId);
|
|
16318
|
+
const revoked = await store.revokeKey(keyId, projectId);
|
|
15622
16319
|
if (!revoked)
|
|
15623
16320
|
return c.json({ error: "Key not found or already revoked" }, 404);
|
|
15624
16321
|
return c.json({ success: true });
|
|
@@ -15925,13 +16622,9 @@ var replayReceiverPlugin = {
|
|
|
15925
16622
|
register(app, runtime) {
|
|
15926
16623
|
app.post("/v1/replays", async (c) => {
|
|
15927
16624
|
const projectId = getProjectId(c);
|
|
15928
|
-
|
|
15929
|
-
|
|
15930
|
-
|
|
15931
|
-
} catch {
|
|
15932
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
15933
|
-
}
|
|
15934
|
-
if (!isSafeId(payload.sessionId) || payload.visitorId !== void 0 && !isSafeId(payload.visitorId) || !Number.isInteger(payload.sequenceNumber) || payload.sequenceNumber < 0 || !Array.isArray(payload.events)) {
|
|
16625
|
+
const payload = await readJsonObject(c);
|
|
16626
|
+
if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
|
|
16627
|
+
if (!isSafeId(payload.sessionId) || !isSafeId(payload.visitorId) || !Number.isInteger(payload.sequenceNumber) || payload.sequenceNumber < 0 || !Array.isArray(payload.events)) {
|
|
15935
16628
|
return c.json({ error: "Invalid replay payload" }, 400);
|
|
15936
16629
|
}
|
|
15937
16630
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -15951,7 +16644,7 @@ var replayReceiverPlugin = {
|
|
|
15951
16644
|
await sqlDbFor(c.env).prepare(
|
|
15952
16645
|
`INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
|
|
15953
16646
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
15954
|
-
ON CONFLICT(session_id) DO UPDATE SET
|
|
16647
|
+
ON CONFLICT(project_id, session_id) DO UPDATE SET
|
|
15955
16648
|
last_chunk_at = excluded.last_chunk_at,
|
|
15956
16649
|
chunk_count = session_replay_metadata.chunk_count + 1,
|
|
15957
16650
|
events_count = session_replay_metadata.events_count + excluded.events_count,
|
|
@@ -16385,12 +17078,8 @@ var usageReceiverPlugin = {
|
|
|
16385
17078
|
register(app, runtime) {
|
|
16386
17079
|
app.post("/v1/usage", async (c) => {
|
|
16387
17080
|
const projectId = getProjectId(c);
|
|
16388
|
-
|
|
16389
|
-
|
|
16390
|
-
payload = await c.req.json();
|
|
16391
|
-
} catch {
|
|
16392
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
16393
|
-
}
|
|
17081
|
+
const payload = await readJsonObject(c);
|
|
17082
|
+
if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
|
|
16394
17083
|
const rawEvents = payload.events || [];
|
|
16395
17084
|
if (rawEvents.length > 200) {
|
|
16396
17085
|
return c.json(
|
|
@@ -16521,6 +17210,71 @@ var utmEnrichmentPlugin = {
|
|
|
16521
17210
|
}
|
|
16522
17211
|
};
|
|
16523
17212
|
|
|
17213
|
+
// src/auth/login-rate-limit.ts
|
|
17214
|
+
var LoginRateLimiter = class {
|
|
17215
|
+
buckets = /* @__PURE__ */ new Map();
|
|
17216
|
+
maxFailures;
|
|
17217
|
+
windowMs;
|
|
17218
|
+
maxClients;
|
|
17219
|
+
now;
|
|
17220
|
+
constructor(options = {}) {
|
|
17221
|
+
this.maxFailures = Math.max(1, options.maxFailures ?? 10);
|
|
17222
|
+
this.windowMs = Math.max(1e3, options.windowMs ?? 15 * 6e4);
|
|
17223
|
+
this.maxClients = Math.max(1, options.maxClients ?? 1e4);
|
|
17224
|
+
this.now = options.now ?? Date.now;
|
|
17225
|
+
}
|
|
17226
|
+
check(key) {
|
|
17227
|
+
const bucket = this.live(key);
|
|
17228
|
+
if (!bucket || bucket.failures < this.maxFailures) {
|
|
17229
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
17230
|
+
}
|
|
17231
|
+
const remaining = bucket.windowStartedAt + this.windowMs - this.now();
|
|
17232
|
+
return {
|
|
17233
|
+
allowed: false,
|
|
17234
|
+
retryAfterSeconds: Math.max(1, Math.ceil(remaining / 1e3))
|
|
17235
|
+
};
|
|
17236
|
+
}
|
|
17237
|
+
recordFailure(key) {
|
|
17238
|
+
const bucket = this.live(key);
|
|
17239
|
+
if (bucket) {
|
|
17240
|
+
bucket.failures += 1;
|
|
17241
|
+
return;
|
|
17242
|
+
}
|
|
17243
|
+
if (this.buckets.size >= this.maxClients) {
|
|
17244
|
+
const oldest = this.buckets.keys().next().value;
|
|
17245
|
+
if (oldest !== void 0) this.buckets.delete(oldest);
|
|
17246
|
+
}
|
|
17247
|
+
this.buckets.set(key, { failures: 1, windowStartedAt: this.now() });
|
|
17248
|
+
}
|
|
17249
|
+
reset(key) {
|
|
17250
|
+
this.buckets.delete(key);
|
|
17251
|
+
}
|
|
17252
|
+
/** Bucket for `key` if its window is still open; expired buckets are dropped. */
|
|
17253
|
+
live(key) {
|
|
17254
|
+
const bucket = this.buckets.get(key);
|
|
17255
|
+
if (!bucket) return void 0;
|
|
17256
|
+
if (bucket.windowStartedAt + this.windowMs <= this.now()) {
|
|
17257
|
+
this.buckets.delete(key);
|
|
17258
|
+
return void 0;
|
|
17259
|
+
}
|
|
17260
|
+
return bucket;
|
|
17261
|
+
}
|
|
17262
|
+
};
|
|
17263
|
+
var isCloudflareWorkersRuntime = () => typeof navigator !== "undefined" && typeof navigator.userAgent === "string" && navigator.userAgent.startsWith("Cloudflare-Workers");
|
|
17264
|
+
var loginClientKey = (headers, options = {}) => {
|
|
17265
|
+
const cf = headers.get("cf-connecting-ip")?.trim();
|
|
17266
|
+
if (options.trustProxyHeaders) {
|
|
17267
|
+
if (cf) return cf;
|
|
17268
|
+
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
17269
|
+
if (forwarded) return forwarded;
|
|
17270
|
+
}
|
|
17271
|
+
const peer = options.clientAddress?.trim();
|
|
17272
|
+
if (peer) return peer;
|
|
17273
|
+
if (cf && isCloudflareWorkersRuntime()) return cf;
|
|
17274
|
+
return null;
|
|
17275
|
+
};
|
|
17276
|
+
var UNKEYED_LOGIN_CLIENT_WARNING = "[dashboard-auth] login rate limiting is inactive: no trustworthy client address. On Node, pass `loginRateLimit.clientAddress` (e.g. the TCP peer from @hono/node-server's getConnInfo); behind a trusted reverse proxy set `loginRateLimit.trustProxyHeaders`. Cloudflare Workers key on CF-Connecting-IP automatically.";
|
|
17277
|
+
|
|
16524
17278
|
// src/auth/dashboard-auth.ts
|
|
16525
17279
|
var SESSION_COOKIE = "obs_session";
|
|
16526
17280
|
var SESSION_MAX_AGE = 7 * 24 * 60 * 60;
|
|
@@ -16588,37 +17342,75 @@ function getCookie(cookieHeader, name) {
|
|
|
16588
17342
|
}
|
|
16589
17343
|
function createDashboardAuth(config) {
|
|
16590
17344
|
const middleware = async (c, next) => {
|
|
16591
|
-
|
|
17345
|
+
const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
|
|
17346
|
+
const configuredToken = config?.token ?? c.env.DASHBOARD_TOKEN ?? c.env.OBS_DASHBOARD_TOKEN;
|
|
17347
|
+
if (!configuredPassword && !configuredToken) {
|
|
16592
17348
|
return c.json(
|
|
16593
17349
|
{
|
|
16594
|
-
error: "DASHBOARD_PASSWORD is
|
|
17350
|
+
error: "Neither DASHBOARD_PASSWORD nor DASHBOARD_TOKEN is configured. Set at least one environment variable."
|
|
16595
17351
|
},
|
|
16596
17352
|
500
|
|
16597
17353
|
);
|
|
16598
17354
|
}
|
|
16599
|
-
|
|
16600
|
-
const
|
|
16601
|
-
if (
|
|
17355
|
+
let authenticated = false;
|
|
17356
|
+
const authHeader = c.req.header("Authorization");
|
|
17357
|
+
if (authHeader) {
|
|
17358
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
17359
|
+
if (!match) {
|
|
17360
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
17361
|
+
}
|
|
17362
|
+
const bearerToken = match[1].trim();
|
|
17363
|
+
if (!bearerToken) {
|
|
17364
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
17365
|
+
}
|
|
17366
|
+
const ingestKey = c.env.INGEST_KEY;
|
|
17367
|
+
if (ingestKey && await timingSafeEqualStr(bearerToken, ingestKey)) {
|
|
17368
|
+
return c.json(
|
|
17369
|
+
{
|
|
17370
|
+
error: "Unauthorized: ingest keys cannot be used for dashboard/internal access"
|
|
17371
|
+
},
|
|
17372
|
+
401
|
|
17373
|
+
);
|
|
17374
|
+
}
|
|
17375
|
+
if (!configuredToken) {
|
|
17376
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
17377
|
+
}
|
|
17378
|
+
const valid = await timingSafeEqualStr(bearerToken, configuredToken);
|
|
17379
|
+
if (!valid) {
|
|
17380
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
17381
|
+
}
|
|
17382
|
+
authenticated = true;
|
|
17383
|
+
} else {
|
|
17384
|
+
const cookieHeader = c.req.header("Cookie");
|
|
17385
|
+
const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
|
|
17386
|
+
if (sessionValue) {
|
|
17387
|
+
if (!configuredPassword) {
|
|
17388
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
17389
|
+
}
|
|
17390
|
+
const dotIndex = sessionValue.lastIndexOf(".");
|
|
17391
|
+
if (dotIndex === -1) {
|
|
17392
|
+
return c.json({ error: "Invalid session" }, 401);
|
|
17393
|
+
}
|
|
17394
|
+
const payload = sessionValue.substring(0, dotIndex);
|
|
17395
|
+
const sig = sessionValue.substring(dotIndex + 1);
|
|
17396
|
+
const valid = await verify(payload, sig, configuredPassword);
|
|
17397
|
+
if (!valid) {
|
|
17398
|
+
return c.json({ error: "Invalid session" }, 401);
|
|
17399
|
+
}
|
|
17400
|
+
const token = parseSessionToken(payload);
|
|
17401
|
+
if (!token || token.exp < Math.floor(Date.now() / 1e3)) {
|
|
17402
|
+
return c.json({ error: "Session expired" }, 401);
|
|
17403
|
+
}
|
|
17404
|
+
authenticated = true;
|
|
17405
|
+
}
|
|
17406
|
+
}
|
|
17407
|
+
if (!authenticated) {
|
|
16602
17408
|
const accept = c.req.header("Accept") || "";
|
|
16603
17409
|
if (accept.includes("text/html")) {
|
|
16604
17410
|
return c.redirect("/dashboard/login");
|
|
16605
17411
|
}
|
|
16606
17412
|
return c.json({ error: "Unauthorized" }, 401);
|
|
16607
17413
|
}
|
|
16608
|
-
const dotIndex = sessionValue.lastIndexOf(".");
|
|
16609
|
-
if (dotIndex === -1) {
|
|
16610
|
-
return c.json({ error: "Invalid session" }, 401);
|
|
16611
|
-
}
|
|
16612
|
-
const payload = sessionValue.substring(0, dotIndex);
|
|
16613
|
-
const sig = sessionValue.substring(dotIndex + 1);
|
|
16614
|
-
const valid = await verify(payload, sig, config.password);
|
|
16615
|
-
if (!valid) {
|
|
16616
|
-
return c.json({ error: "Invalid session" }, 401);
|
|
16617
|
-
}
|
|
16618
|
-
const token = parseSessionToken(payload);
|
|
16619
|
-
if (!token || token.exp < Math.floor(Date.now() / 1e3)) {
|
|
16620
|
-
return c.json({ error: "Session expired" }, 401);
|
|
16621
|
-
}
|
|
16622
17414
|
const path = c.req.path;
|
|
16623
17415
|
const needsProject = path.startsWith("/internal/") && !path.startsWith("/internal/projects");
|
|
16624
17416
|
if (needsProject) {
|
|
@@ -16637,17 +17429,42 @@ function createDashboardAuth(config) {
|
|
|
16637
17429
|
await next();
|
|
16638
17430
|
};
|
|
16639
17431
|
const registerRoutes = (app) => {
|
|
17432
|
+
const limiter = config?.loginRateLimit === false ? null : new LoginRateLimiter(config?.loginRateLimit ?? {});
|
|
17433
|
+
let warnedUnkeyedClient = false;
|
|
16640
17434
|
app.post("/auth/login", async (c) => {
|
|
16641
|
-
|
|
17435
|
+
const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
|
|
17436
|
+
if (!configuredPassword) {
|
|
16642
17437
|
return c.json({ error: "Dashboard password not configured" }, 500);
|
|
16643
17438
|
}
|
|
16644
|
-
const
|
|
16645
|
-
|
|
17439
|
+
const limitConfig = config?.loginRateLimit || void 0;
|
|
17440
|
+
const clientKey = limiter ? loginClientKey(c.req.raw.headers, {
|
|
17441
|
+
clientAddress: limitConfig?.clientAddress?.(c),
|
|
17442
|
+
trustProxyHeaders: limitConfig?.trustProxyHeaders
|
|
17443
|
+
}) : null;
|
|
17444
|
+
if (limiter && clientKey === null && !warnedUnkeyedClient) {
|
|
17445
|
+
warnedUnkeyedClient = true;
|
|
17446
|
+
(limitConfig?.warn ?? console.warn)(UNKEYED_LOGIN_CLIENT_WARNING);
|
|
17447
|
+
}
|
|
17448
|
+
const verdict = clientKey === null ? null : limiter?.check(clientKey);
|
|
17449
|
+
if (verdict && !verdict.allowed) {
|
|
17450
|
+
c.header("Retry-After", String(verdict.retryAfterSeconds));
|
|
17451
|
+
return c.json({ error: "Too many failed login attempts" }, 429);
|
|
17452
|
+
}
|
|
17453
|
+
let body = null;
|
|
17454
|
+
try {
|
|
17455
|
+
body = await c.req.json();
|
|
17456
|
+
} catch (err) {
|
|
17457
|
+
if (err instanceof Error && err.name === "BodyLimitError") throw err;
|
|
17458
|
+
body = null;
|
|
17459
|
+
}
|
|
17460
|
+
if (!body?.password || typeof body.password !== "string" || !await timingSafeEqualStr(body.password, configuredPassword)) {
|
|
17461
|
+
if (clientKey !== null) limiter?.recordFailure(clientKey);
|
|
16646
17462
|
return c.json({ error: "Invalid password" }, 401);
|
|
16647
17463
|
}
|
|
17464
|
+
if (clientKey !== null) limiter?.reset(clientKey);
|
|
16648
17465
|
const expiresAt = Math.floor(Date.now() / 1e3) + SESSION_MAX_AGE;
|
|
16649
17466
|
const payload = createSessionToken(expiresAt);
|
|
16650
|
-
const sig = await sign(payload,
|
|
17467
|
+
const sig = await sign(payload, configuredPassword);
|
|
16651
17468
|
const cookieValue = `${payload}.${sig}`;
|
|
16652
17469
|
const isSecure = c.req.url.startsWith("https:");
|
|
16653
17470
|
c.header(
|
|
@@ -16657,9 +17474,27 @@ function createDashboardAuth(config) {
|
|
|
16657
17474
|
return c.json({ success: true });
|
|
16658
17475
|
});
|
|
16659
17476
|
app.get("/auth/check", async (c) => {
|
|
17477
|
+
const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
|
|
17478
|
+
const configuredToken = config?.token ?? c.env.DASHBOARD_TOKEN ?? c.env.OBS_DASHBOARD_TOKEN;
|
|
17479
|
+
const authHeader = c.req.header("Authorization");
|
|
17480
|
+
if (authHeader) {
|
|
17481
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
17482
|
+
if (match && configuredToken) {
|
|
17483
|
+
const bearerToken = match[1].trim();
|
|
17484
|
+
const ingestKey = c.env.INGEST_KEY;
|
|
17485
|
+
if (ingestKey && await timingSafeEqualStr(bearerToken, ingestKey)) {
|
|
17486
|
+
return c.json({ authenticated: false });
|
|
17487
|
+
}
|
|
17488
|
+
const valid2 = await timingSafeEqualStr(bearerToken, configuredToken);
|
|
17489
|
+
if (valid2) {
|
|
17490
|
+
return c.json({ authenticated: true });
|
|
17491
|
+
}
|
|
17492
|
+
}
|
|
17493
|
+
return c.json({ authenticated: false });
|
|
17494
|
+
}
|
|
16660
17495
|
const cookieHeader = c.req.header("Cookie");
|
|
16661
17496
|
const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
|
|
16662
|
-
if (!sessionValue) {
|
|
17497
|
+
if (!sessionValue || !configuredPassword) {
|
|
16663
17498
|
return c.json({ authenticated: false });
|
|
16664
17499
|
}
|
|
16665
17500
|
const dotIndex = sessionValue.lastIndexOf(".");
|
|
@@ -16668,7 +17503,7 @@ function createDashboardAuth(config) {
|
|
|
16668
17503
|
}
|
|
16669
17504
|
const payload = sessionValue.substring(0, dotIndex);
|
|
16670
17505
|
const sig = sessionValue.substring(dotIndex + 1);
|
|
16671
|
-
const valid = await verify(payload, sig,
|
|
17506
|
+
const valid = await verify(payload, sig, configuredPassword);
|
|
16672
17507
|
if (!valid) {
|
|
16673
17508
|
return c.json({ authenticated: false });
|
|
16674
17509
|
}
|
|
@@ -16812,8 +17647,7 @@ var ActionStore = class {
|
|
|
16812
17647
|
tool_call_id, prompt_version, model_name, provider, total_cost_usd,
|
|
16813
17648
|
attrs_json
|
|
16814
17649
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
16815
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
16816
|
-
project_id = excluded.project_id,
|
|
17650
|
+
ON CONFLICT(project_id, id) DO UPDATE SET
|
|
16817
17651
|
root_action_id = excluded.root_action_id,
|
|
16818
17652
|
caused_by_action_id = excluded.caused_by_action_id,
|
|
16819
17653
|
actor_type = excluded.actor_type,
|
|
@@ -16872,8 +17706,7 @@ var ActionStore = class {
|
|
|
16872
17706
|
autonomy_level, status, error_message, total_cost_usd,
|
|
16873
17707
|
total_duration_ms, metadata_json
|
|
16874
17708
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
16875
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
16876
|
-
project_id = excluded.project_id,
|
|
17709
|
+
ON CONFLICT(project_id, id) DO UPDATE SET
|
|
16877
17710
|
agent_id = excluded.agent_id,
|
|
16878
17711
|
agent_name = excluded.agent_name,
|
|
16879
17712
|
agent_version = excluded.agent_version,
|
|
@@ -16909,9 +17742,8 @@ var ActionStore = class {
|
|
|
16909
17742
|
mcp_audit_json, mutation_before_json, mutation_after_json,
|
|
16910
17743
|
mutation_diff_json, mutation_artifact_id
|
|
16911
17744
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
16912
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
17745
|
+
ON CONFLICT(project_id, id) DO UPDATE SET
|
|
16913
17746
|
action_id = excluded.action_id,
|
|
16914
|
-
project_id = excluded.project_id,
|
|
16915
17747
|
tool_name = excluded.tool_name,
|
|
16916
17748
|
args_hash = excluded.args_hash,
|
|
16917
17749
|
result_hash = excluded.result_hash,
|
|
@@ -16950,9 +17782,8 @@ var ActionStore = class {
|
|
|
16950
17782
|
id, action_id, project_id, retriever_name, query_hash, documents_json,
|
|
16951
17783
|
total_results, max_relevance_score, duration_ms
|
|
16952
17784
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
16953
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
17785
|
+
ON CONFLICT(project_id, id) DO UPDATE SET
|
|
16954
17786
|
action_id = excluded.action_id,
|
|
16955
|
-
project_id = excluded.project_id,
|
|
16956
17787
|
retriever_name = excluded.retriever_name,
|
|
16957
17788
|
query_hash = excluded.query_hash,
|
|
16958
17789
|
documents_json = excluded.documents_json,
|
|
@@ -16977,9 +17808,8 @@ var ActionStore = class {
|
|
|
16977
17808
|
id, action_id, project_id, evaluator_name, evaluator_version, score,
|
|
16978
17809
|
passed, reasoning, rubric_json
|
|
16979
17810
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
16980
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
17811
|
+
ON CONFLICT(project_id, id) DO UPDATE SET
|
|
16981
17812
|
action_id = excluded.action_id,
|
|
16982
|
-
project_id = excluded.project_id,
|
|
16983
17813
|
evaluator_name = excluded.evaluator_name,
|
|
16984
17814
|
evaluator_version = excluded.evaluator_version,
|
|
16985
17815
|
score = excluded.score,
|
|
@@ -17004,9 +17834,8 @@ var ActionStore = class {
|
|
|
17004
17834
|
id, action_id, project_id, artifact_name, artifact_type, storage_ref,
|
|
17005
17835
|
size_bytes, sha256_hash, content_preview
|
|
17006
17836
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
17007
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
17837
|
+
ON CONFLICT(project_id, id) DO UPDATE SET
|
|
17008
17838
|
action_id = excluded.action_id,
|
|
17009
|
-
project_id = excluded.project_id,
|
|
17010
17839
|
artifact_name = excluded.artifact_name,
|
|
17011
17840
|
artifact_type = excluded.artifact_type,
|
|
17012
17841
|
storage_ref = excluded.storage_ref,
|
|
@@ -17452,9 +18281,11 @@ var PostgresAdapter = class {
|
|
|
17452
18281
|
dialect = postgresDialect;
|
|
17453
18282
|
statementTimeoutMs;
|
|
17454
18283
|
prepare(sql) {
|
|
18284
|
+
const translated = prepareSqlForPostgres(sql);
|
|
17455
18285
|
return new PostgresStatement(
|
|
17456
18286
|
this.pool,
|
|
17457
|
-
|
|
18287
|
+
translated,
|
|
18288
|
+
mixedCaseAliases(translated)
|
|
17458
18289
|
);
|
|
17459
18290
|
}
|
|
17460
18291
|
async batch(statements) {
|
|
@@ -17485,23 +18316,49 @@ var PostgresAdapter = class {
|
|
|
17485
18316
|
}
|
|
17486
18317
|
}
|
|
17487
18318
|
};
|
|
17488
|
-
var PostgresStatement = class {
|
|
17489
|
-
constructor(pool, sql) {
|
|
18319
|
+
var PostgresStatement = class _PostgresStatement {
|
|
18320
|
+
constructor(pool, sql, aliases = /* @__PURE__ */ new Map(), bound = []) {
|
|
17490
18321
|
this.pool = pool;
|
|
17491
18322
|
this.sql = sql;
|
|
18323
|
+
this.aliases = aliases;
|
|
18324
|
+
this.bound = bound;
|
|
17492
18325
|
}
|
|
17493
|
-
|
|
18326
|
+
/**
|
|
18327
|
+
* Returns a NEW statement, matching D1 semantics. Stores prepare once and
|
|
18328
|
+
* bind per row (`calls.map((c) => stmt.bind(...))`), so mutating in place
|
|
18329
|
+
* would make every statement in a batch carry the last row's values.
|
|
18330
|
+
*/
|
|
17494
18331
|
bind(...args) {
|
|
17495
|
-
this.
|
|
17496
|
-
return this;
|
|
18332
|
+
return new _PostgresStatement(this.pool, this.sql, this.aliases, args);
|
|
17497
18333
|
}
|
|
17498
18334
|
async first() {
|
|
17499
18335
|
const result = await this.exec();
|
|
17500
|
-
|
|
18336
|
+
const row = result.rows[0];
|
|
18337
|
+
return row === void 0 ? null : this.restoreAliases(row);
|
|
17501
18338
|
}
|
|
17502
18339
|
async all() {
|
|
17503
18340
|
const result = await this.exec();
|
|
17504
|
-
return {
|
|
18341
|
+
return {
|
|
18342
|
+
results: result.rows.map(
|
|
18343
|
+
(row) => this.restoreAliases(row)
|
|
18344
|
+
)
|
|
18345
|
+
};
|
|
18346
|
+
}
|
|
18347
|
+
/**
|
|
18348
|
+
* Postgres folds unquoted identifiers to lower case, so `COUNT(*) AS
|
|
18349
|
+
* totalCalls` comes back as `totalcalls`. D1 preserves the alias. Put the
|
|
18350
|
+
* mixed-case spelling back so stores can read `row.totalCalls` on both.
|
|
18351
|
+
*/
|
|
18352
|
+
restoreAliases(row) {
|
|
18353
|
+
if (this.aliases.size === 0 || !row || typeof row !== "object") return row;
|
|
18354
|
+
const out = row;
|
|
18355
|
+
for (const [lower, original] of this.aliases) {
|
|
18356
|
+
if (lower in out && !(original in out)) {
|
|
18357
|
+
out[original] = out[lower];
|
|
18358
|
+
delete out[lower];
|
|
18359
|
+
}
|
|
18360
|
+
}
|
|
18361
|
+
return row;
|
|
17505
18362
|
}
|
|
17506
18363
|
async run() {
|
|
17507
18364
|
const result = await this.exec();
|
|
@@ -17510,19 +18367,100 @@ var PostgresStatement = class {
|
|
|
17510
18367
|
// Internal — used by batch() to run inside an already-checked-out
|
|
17511
18368
|
// client (so the BEGIN/COMMIT span the whole batch).
|
|
17512
18369
|
async runOnClient(client) {
|
|
17513
|
-
const r = await client.query(this.sql, this.bound);
|
|
18370
|
+
const r = await client.query(this.sql, [...this.bound]);
|
|
17514
18371
|
return { meta: { changes: r.rowCount ?? 0 } };
|
|
17515
18372
|
}
|
|
17516
18373
|
async exec() {
|
|
17517
18374
|
const client = await this.pool.connect();
|
|
17518
18375
|
try {
|
|
17519
|
-
const r = await client.query(this.sql, this.bound);
|
|
18376
|
+
const r = await client.query(this.sql, [...this.bound]);
|
|
17520
18377
|
return { rows: r.rows, rowCount: r.rowCount };
|
|
17521
18378
|
} finally {
|
|
17522
18379
|
client.release();
|
|
17523
18380
|
}
|
|
17524
18381
|
}
|
|
17525
18382
|
};
|
|
18383
|
+
var POSTGRES_TYPE_OIDS = {
|
|
18384
|
+
BOOL: 16,
|
|
18385
|
+
INT8: 20,
|
|
18386
|
+
JSON: 114,
|
|
18387
|
+
NUMERIC: 1700,
|
|
18388
|
+
JSONB: 3802
|
|
18389
|
+
};
|
|
18390
|
+
var parsePostgresNumber = (value, typeName) => {
|
|
18391
|
+
const parsed = Number(value);
|
|
18392
|
+
if (!Number.isFinite(parsed)) {
|
|
18393
|
+
throw new RangeError(
|
|
18394
|
+
`PostgresAdapter: cannot convert ${typeName} value ${JSON.stringify(value)} to a JS number`
|
|
18395
|
+
);
|
|
18396
|
+
}
|
|
18397
|
+
if (/^-?\d+$/.test(value) && !Number.isSafeInteger(parsed)) {
|
|
18398
|
+
throw new RangeError(
|
|
18399
|
+
`PostgresAdapter: ${typeName} value ${value} exceeds Number.MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER}); cast it to TEXT in SQL if the exact value is needed`
|
|
18400
|
+
);
|
|
18401
|
+
}
|
|
18402
|
+
return parsed;
|
|
18403
|
+
};
|
|
18404
|
+
var configurePostgresTypeParsers = (types) => {
|
|
18405
|
+
types.setTypeParser(
|
|
18406
|
+
POSTGRES_TYPE_OIDS.BOOL,
|
|
18407
|
+
(value) => value === "t" || value === "true" ? 1 : 0
|
|
18408
|
+
);
|
|
18409
|
+
types.setTypeParser(
|
|
18410
|
+
POSTGRES_TYPE_OIDS.INT8,
|
|
18411
|
+
(value) => parsePostgresNumber(value, "BIGINT")
|
|
18412
|
+
);
|
|
18413
|
+
types.setTypeParser(
|
|
18414
|
+
POSTGRES_TYPE_OIDS.NUMERIC,
|
|
18415
|
+
(value) => parsePostgresNumber(value, "NUMERIC")
|
|
18416
|
+
);
|
|
18417
|
+
types.setTypeParser(POSTGRES_TYPE_OIDS.JSON, (value) => value);
|
|
18418
|
+
types.setTypeParser(POSTGRES_TYPE_OIDS.JSONB, (value) => value);
|
|
18419
|
+
};
|
|
18420
|
+
var prepareSqlForPostgres = (sql) => translateD1Sql(rewriteQuestionMarks(stripSqlComments(sql)));
|
|
18421
|
+
var stripSqlComments = (sql) => {
|
|
18422
|
+
let out = "";
|
|
18423
|
+
let inString = false;
|
|
18424
|
+
for (let i = 0; i < sql.length; i++) {
|
|
18425
|
+
const ch = sql[i];
|
|
18426
|
+
if (ch === "'") {
|
|
18427
|
+
inString = !inString;
|
|
18428
|
+
out += ch;
|
|
18429
|
+
continue;
|
|
18430
|
+
}
|
|
18431
|
+
if (inString) {
|
|
18432
|
+
out += ch;
|
|
18433
|
+
continue;
|
|
18434
|
+
}
|
|
18435
|
+
if (ch === "-" && sql[i + 1] === "-") {
|
|
18436
|
+
const eol = sql.indexOf("\n", i);
|
|
18437
|
+
if (eol === -1) return out;
|
|
18438
|
+
i = eol - 1;
|
|
18439
|
+
continue;
|
|
18440
|
+
}
|
|
18441
|
+
if (ch === "/" && sql[i + 1] === "*") {
|
|
18442
|
+
const end = sql.indexOf("*/", i + 2);
|
|
18443
|
+
if (end === -1) return out;
|
|
18444
|
+
i = end + 1;
|
|
18445
|
+
continue;
|
|
18446
|
+
}
|
|
18447
|
+
out += ch;
|
|
18448
|
+
}
|
|
18449
|
+
return out;
|
|
18450
|
+
};
|
|
18451
|
+
var mixedCaseAliases = (sql) => {
|
|
18452
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
18453
|
+
const pattern = /\bAS\s+([A-Za-z_][A-Za-z0-9_]*)/gi;
|
|
18454
|
+
stripSqlComments(sql).split("'").filter((_segment, index) => index % 2 === 0).forEach((segment) => {
|
|
18455
|
+
for (const match of segment.matchAll(pattern)) {
|
|
18456
|
+
const alias = match[1];
|
|
18457
|
+
if (alias !== alias.toLowerCase() && alias !== alias.toUpperCase()) {
|
|
18458
|
+
aliases.set(alias.toLowerCase(), alias);
|
|
18459
|
+
}
|
|
18460
|
+
}
|
|
18461
|
+
});
|
|
18462
|
+
return aliases;
|
|
18463
|
+
};
|
|
17526
18464
|
var rewriteQuestionMarks = (sql) => {
|
|
17527
18465
|
let out = "";
|
|
17528
18466
|
let inString = false;
|
|
@@ -17543,45 +18481,167 @@ var rewriteQuestionMarks = (sql) => {
|
|
|
17543
18481
|
}
|
|
17544
18482
|
return out;
|
|
17545
18483
|
};
|
|
18484
|
+
var UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')";
|
|
18485
|
+
var INTERVAL_UNITS = /^(hour|hours|minute|minutes|day|days|second|seconds)$/i;
|
|
18486
|
+
var sqliteModifierToInterval = (modifier) => {
|
|
18487
|
+
const literal = /^'-(\d+)\s+([a-z]+)'$/i.exec(modifier);
|
|
18488
|
+
if (literal && INTERVAL_UNITS.test(literal[2])) {
|
|
18489
|
+
return `INTERVAL '${literal[1]} ${literal[2].toLowerCase()}'`;
|
|
18490
|
+
}
|
|
18491
|
+
const concat = /^'-'\s*\|\|\s*(.+?)\s*\|\|\s*'\s*([a-z]+)'$/i.exec(modifier);
|
|
18492
|
+
if (concat && INTERVAL_UNITS.test(concat[2])) {
|
|
18493
|
+
return `(${concat[1].trim()}::text || ' ${concat[2].toLowerCase()}')::interval`;
|
|
18494
|
+
}
|
|
18495
|
+
return null;
|
|
18496
|
+
};
|
|
18497
|
+
var asUtcTimestamp = (expr) => `(${expr})::timestamptz AT TIME ZONE 'UTC'`;
|
|
18498
|
+
var STRFTIME_FORMATS = {
|
|
18499
|
+
"%Y-%m-%dT%H:%M:00Z": (expr) => `to_char(date_trunc('minute', ${asUtcTimestamp(expr)}), 'YYYY-MM-DD"T"HH24:MI:00"Z"')`,
|
|
18500
|
+
"%Y-%m-%dT%H:00:00Z": (expr) => `to_char(date_trunc('hour', ${asUtcTimestamp(expr)}), 'YYYY-MM-DD"T"HH24:00:00"Z"')`,
|
|
18501
|
+
"%s": (expr) => `EXTRACT(EPOCH FROM (${expr})::timestamptz)`
|
|
18502
|
+
};
|
|
18503
|
+
var unquote = (literal) => {
|
|
18504
|
+
const m = /^'((?:[^']|'')*)'$/.exec(literal.trim());
|
|
18505
|
+
return m ? m[1].replace(/''/g, "'") : null;
|
|
18506
|
+
};
|
|
18507
|
+
var jsonPathToPostgres = (expr, pathLiteral) => {
|
|
18508
|
+
const path = unquote(pathLiteral);
|
|
18509
|
+
if (!path?.startsWith("$.")) return null;
|
|
18510
|
+
const rest = path.slice(2);
|
|
18511
|
+
const quoted = /^"([^"]+)"$/.exec(rest);
|
|
18512
|
+
const escapeKey = (key) => key.replace(/\\u002E/g, ".").replace(/'/g, "''");
|
|
18513
|
+
if (quoted) return `((${expr})::jsonb ->> '${escapeKey(quoted[1])}')`;
|
|
18514
|
+
if (!/^[A-Za-z0-9_.\\]+$/.test(rest)) return null;
|
|
18515
|
+
const segments = rest.split(".").map(escapeKey);
|
|
18516
|
+
if (segments.length === 1) return `((${expr})::jsonb ->> '${segments[0]}')`;
|
|
18517
|
+
return `((${expr})::jsonb #>> '{${segments.join(",")}}')`;
|
|
18518
|
+
};
|
|
18519
|
+
var FUNCTION_REWRITES = {
|
|
18520
|
+
datetime: (args) => {
|
|
18521
|
+
if (args.length === 1 && /^'now'$/i.test(args[0])) {
|
|
18522
|
+
return postgresIsoText(UTC_NOW);
|
|
18523
|
+
}
|
|
18524
|
+
if (args.length === 2 && /^'now'$/i.test(args[0])) {
|
|
18525
|
+
const interval = sqliteModifierToInterval(args[1]);
|
|
18526
|
+
return interval ? postgresIsoText(`${UTC_NOW} - ${interval}`) : null;
|
|
18527
|
+
}
|
|
18528
|
+
if (args.length === 2 && /^'unixepoch'$/i.test(args[1])) {
|
|
18529
|
+
return `to_timestamp(${args[0]})`;
|
|
18530
|
+
}
|
|
18531
|
+
return null;
|
|
18532
|
+
},
|
|
18533
|
+
strftime: (args) => {
|
|
18534
|
+
const format = args.length >= 2 ? unquote(args[0]) : null;
|
|
18535
|
+
if (format === SQLITE_ISO_FORMAT && /^'now'$/i.test(args[1])) {
|
|
18536
|
+
if (args.length === 2) return postgresIsoText(UTC_NOW);
|
|
18537
|
+
if (args.length === 3) {
|
|
18538
|
+
const interval = sqliteModifierToInterval(args[2]);
|
|
18539
|
+
return interval ? postgresIsoText(`${UTC_NOW} - ${interval}`) : null;
|
|
18540
|
+
}
|
|
18541
|
+
return null;
|
|
18542
|
+
}
|
|
18543
|
+
if (args.length !== 2) return null;
|
|
18544
|
+
const render = format ? STRFTIME_FORMATS[format] : void 0;
|
|
18545
|
+
return render ? render(args[1]) : null;
|
|
18546
|
+
},
|
|
18547
|
+
julianday: (args) => args.length === 1 ? `(EXTRACT(EPOCH FROM (${args[0]})::timestamptz) / 86400.0 + 2440587.5)` : null,
|
|
18548
|
+
json_extract: (args) => args.length === 2 ? jsonPathToPostgres(args[0], args[1]) : null,
|
|
18549
|
+
json_object: (args) => `json_build_object(${args.join(", ")})`,
|
|
18550
|
+
json_array: (args) => `json_build_array(${args.join(", ")})`,
|
|
18551
|
+
json_group_array: (args) => `json_agg(${args.join(", ")})`,
|
|
18552
|
+
// SQLite's scalar MAX/MIN take several arguments; Postgres spells those
|
|
18553
|
+
// GREATEST/LEAST. Single-argument MAX/MIN stay aggregates.
|
|
18554
|
+
max: (args) => args.length > 1 ? `GREATEST(${args.join(", ")})` : null,
|
|
18555
|
+
min: (args) => args.length > 1 ? `LEAST(${args.join(", ")})` : null,
|
|
18556
|
+
// Postgres only defines ROUND(numeric, int); SQLite rounds REAL directly.
|
|
18557
|
+
round: (args) => args.length === 2 ? `ROUND((${args[0]})::numeric, ${args[1]})` : null
|
|
18558
|
+
};
|
|
18559
|
+
var FUNCTION_NAME_PATTERN = new RegExp(
|
|
18560
|
+
`\\b(${Object.keys(FUNCTION_REWRITES).join("|")})\\s*\\(`,
|
|
18561
|
+
"i"
|
|
18562
|
+
);
|
|
18563
|
+
var isIdentifierChar = (ch) => ch !== void 0 && /[A-Za-z0-9_.]/.test(ch);
|
|
18564
|
+
var findClosingParen = (sql, open) => {
|
|
18565
|
+
let depth = 0;
|
|
18566
|
+
let inString = false;
|
|
18567
|
+
for (let i = open; i < sql.length; i++) {
|
|
18568
|
+
const ch = sql[i];
|
|
18569
|
+
if (ch === "'") {
|
|
18570
|
+
inString = !inString;
|
|
18571
|
+
continue;
|
|
18572
|
+
}
|
|
18573
|
+
if (inString) continue;
|
|
18574
|
+
if (ch === "(") depth += 1;
|
|
18575
|
+
else if (ch === ")") {
|
|
18576
|
+
depth -= 1;
|
|
18577
|
+
if (depth === 0) return i;
|
|
18578
|
+
}
|
|
18579
|
+
}
|
|
18580
|
+
return -1;
|
|
18581
|
+
};
|
|
18582
|
+
var splitTopLevelArgs = (inner) => {
|
|
18583
|
+
const args = [];
|
|
18584
|
+
let depth = 0;
|
|
18585
|
+
let inString = false;
|
|
18586
|
+
let current = "";
|
|
18587
|
+
for (const ch of inner) {
|
|
18588
|
+
if (ch === "'") inString = !inString;
|
|
18589
|
+
if (!inString) {
|
|
18590
|
+
if (ch === "(") depth += 1;
|
|
18591
|
+
else if (ch === ")") depth -= 1;
|
|
18592
|
+
else if (ch === "," && depth === 0) {
|
|
18593
|
+
args.push(current.trim());
|
|
18594
|
+
current = "";
|
|
18595
|
+
continue;
|
|
18596
|
+
}
|
|
18597
|
+
}
|
|
18598
|
+
current += ch;
|
|
18599
|
+
}
|
|
18600
|
+
if (current.trim() !== "" || args.length > 0) args.push(current.trim());
|
|
18601
|
+
return args;
|
|
18602
|
+
};
|
|
18603
|
+
var insideStringLiteral = (sql, index) => {
|
|
18604
|
+
let inString = false;
|
|
18605
|
+
for (let i = 0; i < index; i++) {
|
|
18606
|
+
if (sql[i] === "'") inString = !inString;
|
|
18607
|
+
}
|
|
18608
|
+
return inString;
|
|
18609
|
+
};
|
|
18610
|
+
var translateFunctionCalls = (sql) => {
|
|
18611
|
+
let out = "";
|
|
18612
|
+
let rest = sql;
|
|
18613
|
+
for (; ; ) {
|
|
18614
|
+
const match = FUNCTION_NAME_PATTERN.exec(rest);
|
|
18615
|
+
if (!match) return out + rest;
|
|
18616
|
+
const start = match.index;
|
|
18617
|
+
const open = start + match[0].length - 1;
|
|
18618
|
+
if (insideStringLiteral(rest, start)) {
|
|
18619
|
+
const closeQuote = rest.indexOf("'", start);
|
|
18620
|
+
const cut = closeQuote === -1 ? rest.length : closeQuote + 1;
|
|
18621
|
+
out += rest.slice(0, cut);
|
|
18622
|
+
rest = rest.slice(cut);
|
|
18623
|
+
continue;
|
|
18624
|
+
}
|
|
18625
|
+
const precededByIdentifier = isIdentifierChar(rest[start - 1]);
|
|
18626
|
+
const close = findClosingParen(rest, open);
|
|
18627
|
+
if (precededByIdentifier || close === -1) {
|
|
18628
|
+
out += rest.slice(0, open + 1);
|
|
18629
|
+
rest = rest.slice(open + 1);
|
|
18630
|
+
continue;
|
|
18631
|
+
}
|
|
18632
|
+
const name = match[1].toLowerCase();
|
|
18633
|
+
const inner = translateFunctionCalls(rest.slice(open + 1, close));
|
|
18634
|
+
const replacement = FUNCTION_REWRITES[name](splitTopLevelArgs(inner));
|
|
18635
|
+
out += rest.slice(0, start);
|
|
18636
|
+
out += replacement ?? `${match[1]}(${inner})`;
|
|
18637
|
+
rest = rest.slice(close + 1);
|
|
18638
|
+
}
|
|
18639
|
+
};
|
|
17546
18640
|
var translateD1Sql = (sql) => {
|
|
17547
18641
|
let out = sql;
|
|
17548
18642
|
const hadInsertOrIgnore = /\bINSERT\s+OR\s+IGNORE\s+INTO\b/i.test(out);
|
|
17549
18643
|
out = out.replace(/\bINSERT\s+OR\s+IGNORE\s+INTO\b/gi, "INSERT INTO");
|
|
17550
|
-
out = out
|
|
17551
|
-
/datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*hours'\)/gi,
|
|
17552
|
-
"(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)"
|
|
17553
|
-
);
|
|
17554
|
-
out = out.replace(
|
|
17555
|
-
/datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*minutes'\)/gi,
|
|
17556
|
-
"(CURRENT_TIMESTAMP - ($1::text || ' minutes')::interval)"
|
|
17557
|
-
);
|
|
17558
|
-
out = out.replace(
|
|
17559
|
-
/datetime\('now',\s*'-(\d+)\s+(hour|hours|minute|minutes|day|days)'\)/gi,
|
|
17560
|
-
"CURRENT_TIMESTAMP - INTERVAL '$1 $2'"
|
|
17561
|
-
);
|
|
17562
|
-
out = out.replace(/datetime\('now'\)/gi, "CURRENT_TIMESTAMP");
|
|
17563
|
-
out = out.replace(
|
|
17564
|
-
/json_extract\(([^,()]+),\s*'\$\.(?:"([^"]+)"|([A-Za-z0-9_\\.\\\\]+))'\)/gi,
|
|
17565
|
-
(_match, expr, quotedKey, bareKey) => {
|
|
17566
|
-
const key = (quotedKey ?? bareKey ?? "").replace(
|
|
17567
|
-
/\\\\u002E|\\u002E/g,
|
|
17568
|
-
"."
|
|
17569
|
-
);
|
|
17570
|
-
return `(${expr.trim()}::jsonb ->> '${key.replace(/'/g, "''")}')`;
|
|
17571
|
-
}
|
|
17572
|
-
);
|
|
17573
|
-
out = out.replace(
|
|
17574
|
-
/strftime\('%Y-%m-%dT%H:%M:00Z',\s*([^)]+)\)/gi,
|
|
17575
|
-
`to_char(date_trunc('minute', $1::timestamp), 'YYYY-MM-DD"T"HH24:MI:00"Z"')`
|
|
17576
|
-
);
|
|
17577
|
-
out = out.replace(
|
|
17578
|
-
/strftime\('%Y-%m-%dT%H:00:00Z',\s*([^)]+)\)/gi,
|
|
17579
|
-
`to_char(date_trunc('hour', $1::timestamp), 'YYYY-MM-DD"T"HH24:00:00"Z"')`
|
|
17580
|
-
);
|
|
17581
|
-
out = out.replace(
|
|
17582
|
-
/strftime\('%s',\s*([^)]+)\)/gi,
|
|
17583
|
-
"EXTRACT(EPOCH FROM $1::timestamp)"
|
|
17584
|
-
);
|
|
18644
|
+
out = translateFunctionCalls(out);
|
|
17585
18645
|
if (hadInsertOrIgnore && /\bON\s+CONFLICT\b/i.test(out) === false) {
|
|
17586
18646
|
out = out.replace(/(;?\s*)$/, " ON CONFLICT DO NOTHING$1");
|
|
17587
18647
|
}
|
|
@@ -17653,6 +18713,7 @@ export {
|
|
|
17653
18713
|
R2BlobStore,
|
|
17654
18714
|
S3BlobStore,
|
|
17655
18715
|
TailHub,
|
|
18716
|
+
actionRetentionCutoff,
|
|
17656
18717
|
actionRoutesPlugin,
|
|
17657
18718
|
aiReceiverPlugin,
|
|
17658
18719
|
aiSpanPayloadsProcessorPlugin,
|
|
@@ -17662,6 +18723,7 @@ export {
|
|
|
17662
18723
|
askRoutesPlugin,
|
|
17663
18724
|
botFilterPlugin,
|
|
17664
18725
|
compareValue,
|
|
18726
|
+
configurePostgresTypeParsers,
|
|
17665
18727
|
connectedRoutesPlugin,
|
|
17666
18728
|
consoleLogger,
|
|
17667
18729
|
createAlertEvaluatorHandler,
|
|
@@ -17690,6 +18752,7 @@ export {
|
|
|
17690
18752
|
platformRoutesPlugin,
|
|
17691
18753
|
profileRoutesPlugin,
|
|
17692
18754
|
projectsRoutesPlugin,
|
|
18755
|
+
purgeExpiredActionGraph,
|
|
17693
18756
|
queryRoutesPlugin,
|
|
17694
18757
|
redactionProcessorPlugin,
|
|
17695
18758
|
replayQueryRoutesPlugin,
|