@obsunified/collector 2.0.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/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +9 -0
- package/dist/auth.js.map +1 -0
- package/dist/chunk-UGWDOGGH.js +319 -0
- package/dist/chunk-UGWDOGGH.js.map +1 -0
- package/dist/chunk-XKQTVIRE.js +671 -0
- package/dist/chunk-XKQTVIRE.js.map +1 -0
- package/dist/index.d.ts +1197 -0
- package/dist/index.js +17707 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
- package/dist/openai-FJYOQ7WO.js +384 -0
- package/dist/openai-FJYOQ7WO.js.map +1 -0
- package/package.json +73 -0
- package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
- package/src/analyses/derive.ts +642 -0
- package/src/analyses/index.ts +50 -0
- package/src/analyses/investigations.ts +277 -0
- package/src/analyses/tier0.ts +568 -0
- package/src/auth/dashboard-auth.ts +269 -0
- package/src/auth/ingest-auth.ts +147 -0
- package/src/durable-objects/tail-hub.test.ts +32 -0
- package/src/durable-objects/tail-hub.ts +158 -0
- package/src/framework/collector.ts +467 -0
- package/src/framework/env.ts +90 -0
- package/src/framework/logger.ts +81 -0
- package/src/index.ts +211 -0
- package/src/lib/action-aggregates.ts +1031 -0
- package/src/lib/action-identity.ts +106 -0
- package/src/lib/actions-store.ts +397 -0
- package/src/lib/ai-pricing.ts +80 -0
- package/src/lib/ai-store/calls.ts +148 -0
- package/src/lib/ai-store/evaluations.test.ts +47 -0
- package/src/lib/ai-store/evaluations.ts +125 -0
- package/src/lib/ai-store/helpers.ts +37 -0
- package/src/lib/ai-store/retention.ts +17 -0
- package/src/lib/ai-store/sessions.ts +277 -0
- package/src/lib/ai-store/spans.ts +109 -0
- package/src/lib/ai-store/types.ts +112 -0
- package/src/lib/ai-store.ts +76 -0
- package/src/lib/alerts-store.ts +458 -0
- package/src/lib/analyses-runner.ts +439 -0
- package/src/lib/analyses-store.test.ts +243 -0
- package/src/lib/analyses-store.ts +454 -0
- package/src/lib/ask.test.ts +302 -0
- package/src/lib/ask.ts +390 -0
- package/src/lib/blob-store-s3.ts +213 -0
- package/src/lib/blob-store.ts +366 -0
- package/src/lib/eval-cases-store.test.ts +171 -0
- package/src/lib/eval-cases-store.ts +1033 -0
- package/src/lib/evidence-references.test.ts +10 -0
- package/src/lib/evidence-references.ts +684 -0
- package/src/lib/hash.ts +26 -0
- package/src/lib/identity-index/action-lookups.ts +805 -0
- package/src/lib/identity-index/constants.ts +1 -0
- package/src/lib/identity-index/key-lookups.ts +199 -0
- package/src/lib/identity-index/mappers.ts +349 -0
- package/src/lib/identity-index/types.ts +196 -0
- package/src/lib/identity-index/user-lookup.ts +137 -0
- package/src/lib/identity-index.test.ts +624 -0
- package/src/lib/identity-index.ts +85 -0
- package/src/lib/json.ts +39 -0
- package/src/lib/llm.ts +263 -0
- package/src/lib/logs-store.ts +188 -0
- package/src/lib/metrics-store.test.ts +189 -0
- package/src/lib/metrics-store.ts +312 -0
- package/src/lib/narrate-gate.test.ts +260 -0
- package/src/lib/narrate-gate.ts +253 -0
- package/src/lib/openai.test.ts +300 -0
- package/src/lib/openai.ts +512 -0
- package/src/lib/otlp.ts +212 -0
- package/src/lib/parse-pprof.test.ts +64 -0
- package/src/lib/parse-pprof.ts +184 -0
- package/src/lib/payload-capture.ts +22 -0
- package/src/lib/projects-store.ts +207 -0
- package/src/lib/propagation-metric.test.ts +196 -0
- package/src/lib/propagation-metric.ts +175 -0
- package/src/lib/sql-db-dialect.test.ts +22 -0
- package/src/lib/sql-db-postgres.ts +229 -0
- package/src/lib/sql-db.ts +166 -0
- package/src/lib/stage6.test.ts +216 -0
- package/src/lib/store/export.ts +65 -0
- package/src/lib/store/helpers.ts +367 -0
- package/src/lib/store/ingest.ts +68 -0
- package/src/lib/store/issues.ts +158 -0
- package/src/lib/store/overview.ts +127 -0
- package/src/lib/store/retention.ts +10 -0
- package/src/lib/store/service-map.ts +384 -0
- package/src/lib/store/trace-detail.bench.ts +76 -0
- package/src/lib/store/trace-detail.test.ts +410 -0
- package/src/lib/store/trace-detail.ts +427 -0
- package/src/lib/store/trace-queries.test.ts +29 -0
- package/src/lib/store/trace-queries.ts +89 -0
- package/src/lib/store-overview.test.ts +77 -0
- package/src/lib/store.ts +96 -0
- package/src/lib/tail-publisher.ts +64 -0
- package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
- package/src/lib/test-utils/mem-sql-db.ts +86 -0
- package/src/lib/ua-parser.ts +61 -0
- package/src/lib/usage-store.ts +567 -0
- package/src/migrations/001_telemetry_spans.sql +28 -0
- package/src/migrations/002_usage_events.sql +24 -0
- package/src/migrations/003_usage_analytics_columns.sql +13 -0
- package/src/migrations/004_logs.sql +19 -0
- package/src/migrations/005_ai_calls.sql +25 -0
- package/src/migrations/006_user_profiles.sql +11 -0
- package/src/migrations/007_session_replay_metadata.sql +10 -0
- package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
- package/src/migrations/009_projects_and_keys.sql +22 -0
- package/src/migrations/010_signal_project_id.sql +15 -0
- package/src/migrations/011_default_project_seed.sql +3 -0
- package/src/migrations/012_alert_rules.sql +17 -0
- package/src/migrations/013_alert_evaluations.sql +13 -0
- package/src/migrations/014_alert_state.sql +9 -0
- package/src/migrations/015_metrics.sql +58 -0
- package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
- package/src/migrations/017_span_dropped_counts.sql +11 -0
- package/src/migrations/018_log_flags.sql +8 -0
- package/src/migrations/019_ai_span_payloads.sql +30 -0
- package/src/migrations/020_ai_span_evaluations.sql +33 -0
- package/src/migrations/021_ai_span_session.sql +13 -0
- package/src/migrations/022_span_log_session_id.sql +13 -0
- package/src/migrations/023_service_map_indexes.sql +32 -0
- package/src/migrations/024_analysis_results.sql +49 -0
- package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
- package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
- package/src/migrations/027_identity_propagation.sql +50 -0
- package/src/migrations/028_profile_blobs.sql +57 -0
- package/src/migrations/029_telemetry_sdk_name.sql +20 -0
- package/src/migrations/030_analysis_claim.sql +18 -0
- package/src/migrations/031_agent_action_graph.sql +154 -0
- package/src/migrations/032_payload_capture_flags.sql +8 -0
- package/src/migrations/033_eval_cases.sql +52 -0
- package/src/migrations/034_eval_case_results.sql +23 -0
- package/src/migrations/035_metric_exemplars.sql +33 -0
- package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
- package/src/migrations/037_eval_runs.sql +37 -0
- package/src/migrations/038_tool_call_evidence.sql +11 -0
- package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
- package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
- package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
- package/src/migrations-postgres/002_usage_events.sql +24 -0
- package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
- package/src/migrations-postgres/004_logs.sql +19 -0
- package/src/migrations-postgres/005_ai_calls.sql +25 -0
- package/src/migrations-postgres/006_user_profiles.sql +11 -0
- package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
- package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
- package/src/migrations-postgres/010_signal_project_id.sql +18 -0
- package/src/migrations-postgres/011_default_project_seed.sql +5 -0
- package/src/migrations-postgres/012_alert_rules.sql +17 -0
- package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
- package/src/migrations-postgres/014_alert_state.sql +9 -0
- package/src/migrations-postgres/015_metrics.sql +47 -0
- package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
- package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
- package/src/migrations-postgres/018_log_flags.sql +2 -0
- package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
- package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
- package/src/migrations-postgres/021_ai_span_session.sql +7 -0
- package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
- package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
- package/src/migrations-postgres/024_analysis_results.sql +41 -0
- package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
- package/src/migrations-postgres/027_identity_propagation.sql +27 -0
- package/src/migrations-postgres/028_profile_blobs.sql +48 -0
- package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
- package/src/migrations-postgres/030_analysis_claim.sql +5 -0
- package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
- package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
- package/src/migrations-postgres/033_eval_cases.sql +55 -0
- package/src/migrations-postgres/034_eval_case_results.sql +26 -0
- package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
- package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
- package/src/migrations-postgres/037_eval_runs.sql +40 -0
- package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
- package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
- package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
- package/src/migrations-postgres/README.md +40 -0
- package/src/otlp/acceptance.test.ts +373 -0
- package/src/otlp/decode/body.ts +127 -0
- package/src/otlp/decode/logs.ts +96 -0
- package/src/otlp/decode/metrics.ts +402 -0
- package/src/otlp/decode/traces.ts +92 -0
- package/src/otlp/decode/values.ts +150 -0
- package/src/otlp/decode.test.ts +800 -0
- package/src/otlp/decode.ts +11 -0
- package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
- package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
- package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
- package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
- package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
- package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
- package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
- package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
- package/src/otlp/response.test.ts +158 -0
- package/src/otlp/response.ts +129 -0
- package/src/plugins/_context.ts +15 -0
- package/src/plugins/action-graph-processor/enrichers.ts +45 -0
- package/src/plugins/action-graph-processor/redaction.ts +84 -0
- package/src/plugins/action-graph-processor.test.ts +1023 -0
- package/src/plugins/action-graph-processor.ts +1172 -0
- package/src/plugins/action-routes.test.ts +716 -0
- package/src/plugins/action-routes.ts +343 -0
- package/src/plugins/ai-receiver.ts +254 -0
- package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
- package/src/plugins/ai-span-payloads-processor.ts +180 -0
- package/src/plugins/alerts-evaluator.test.ts +91 -0
- package/src/plugins/alerts-evaluator.ts +328 -0
- package/src/plugins/alerts-routes.ts +101 -0
- package/src/plugins/analyses-routes.ts +138 -0
- package/src/plugins/ask-routes.ts +182 -0
- package/src/plugins/bot-filter.ts +19 -0
- package/src/plugins/connected-routes/manifest.ts +531 -0
- package/src/plugins/connected-routes.test.ts +1791 -0
- package/src/plugins/connected-routes.ts +1103 -0
- package/src/plugins/dashboard-routes.ts +41 -0
- package/src/plugins/default-span-enrichment.ts +92 -0
- package/src/plugins/eval-cases-routes.test.ts +391 -0
- package/src/plugins/eval-cases-routes.ts +529 -0
- package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
- package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
- package/src/plugins/evidence-retrieval-routes.ts +2179 -0
- package/src/plugins/gen-ai-normalizer.test.ts +680 -0
- package/src/plugins/gen-ai-normalizer.ts +302 -0
- package/src/plugins/identity-receiver.ts +96 -0
- package/src/plugins/issue-enrichment.ts +132 -0
- package/src/plugins/issue-insights.ts +81 -0
- package/src/plugins/logs-receiver.ts +160 -0
- package/src/plugins/metrics-receiver.ts +91 -0
- package/src/plugins/onboarding-routes.ts +54 -0
- package/src/plugins/otlp-receiver.ts +124 -0
- package/src/plugins/platform-routes.ts +165 -0
- package/src/plugins/profile-routes.ts +468 -0
- package/src/plugins/projects-routes.ts +92 -0
- package/src/plugins/query-routes.ts +220 -0
- package/src/plugins/redaction-processor.ts +132 -0
- package/src/plugins/replay-query-routes.test.ts +38 -0
- package/src/plugins/replay-query-routes.ts +199 -0
- package/src/plugins/replay-receiver.ts +81 -0
- package/src/plugins/tail-routes.test.ts +47 -0
- package/src/plugins/tail-routes.ts +42 -0
- package/src/plugins/timeline-routes.ts +244 -0
- package/src/plugins/ua-enrichment.ts +27 -0
- package/src/plugins/usage-normalization.ts +46 -0
- package/src/plugins/usage-privacy.ts +55 -0
- package/src/plugins/usage-query-routes.ts +102 -0
- package/src/plugins/usage-receiver.ts +129 -0
- package/src/plugins/users-query-routes.ts +78 -0
- package/src/plugins/utm-enrichment.ts +36 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span processor that routes large AI payloads to the ai_span_payloads
|
|
3
|
+
* side table.
|
|
4
|
+
*
|
|
5
|
+
* For every span that has an `openinference.span.kind` attribute, we look
|
|
6
|
+
* for `ai.payload.input` / `ai.payload.output`, write them (plus the kind)
|
|
7
|
+
* to `ai_span_payloads`, and strip them from the span's attributes_json
|
|
8
|
+
* before it hits `telemetry_spans`.
|
|
9
|
+
*
|
|
10
|
+
* This keeps the hot spans table lean while preserving full replay data
|
|
11
|
+
* for AI calls.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { JsonValue, StoredSpan } from "@obsunified/types";
|
|
15
|
+
import {
|
|
16
|
+
ACTION_ID_KEY,
|
|
17
|
+
AI_PAYLOAD_INPUT_KEY,
|
|
18
|
+
AI_PAYLOAD_OUTPUT_KEY,
|
|
19
|
+
INTERACTION_ID_KEY,
|
|
20
|
+
isOpenInferenceSpanKind,
|
|
21
|
+
OPENINFERENCE_SPAN_KIND_KEY,
|
|
22
|
+
SESSION_ID_KEY,
|
|
23
|
+
USER_ID_KEY,
|
|
24
|
+
} from "@obsunified/types/constants";
|
|
25
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
26
|
+
import { sha256Hex } from "../lib/hash";
|
|
27
|
+
import { parseJsonRecord } from "../lib/json";
|
|
28
|
+
import { isPayloadCaptureEnabled } from "../lib/payload-capture";
|
|
29
|
+
import { sqlDbFor } from "../lib/sql-db";
|
|
30
|
+
|
|
31
|
+
interface PayloadRow {
|
|
32
|
+
projectId: string;
|
|
33
|
+
traceId: string;
|
|
34
|
+
spanId: string;
|
|
35
|
+
spanKind: string;
|
|
36
|
+
inputJson: string | null;
|
|
37
|
+
outputJson: string | null;
|
|
38
|
+
inputHash: string | null;
|
|
39
|
+
outputHash: string | null;
|
|
40
|
+
sessionId: string | null;
|
|
41
|
+
userId: string | null;
|
|
42
|
+
interactionId: string | null;
|
|
43
|
+
actionId: string | null;
|
|
44
|
+
receivedAt: string;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const asString = (value: JsonValue | undefined): string | null =>
|
|
49
|
+
typeof value === "string" && value.length > 0 ? value : null;
|
|
50
|
+
|
|
51
|
+
const toJsonString = (value: unknown): string | null => {
|
|
52
|
+
if (value === undefined || value === null) return null;
|
|
53
|
+
if (typeof value === "string") return value;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
} catch {
|
|
57
|
+
return String(value);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const aiSpanPayloadsProcessorPlugin: CollectorPlugin = {
|
|
62
|
+
name: "ai-span-payloads-processor",
|
|
63
|
+
register(_app, runtime) {
|
|
64
|
+
runtime.addSpanProcessor({
|
|
65
|
+
name: "ai-span-payloads-processor",
|
|
66
|
+
async process(spans, context) {
|
|
67
|
+
const db = sqlDbFor(context.env);
|
|
68
|
+
const rows: PayloadRow[] = [];
|
|
69
|
+
const captureByProject = new Map<string, boolean>();
|
|
70
|
+
const projectCaptureEnabled = async (projectId: string) => {
|
|
71
|
+
const cached = captureByProject.get(projectId);
|
|
72
|
+
if (cached !== undefined) return cached;
|
|
73
|
+
const enabled = await isPayloadCaptureEnabled(
|
|
74
|
+
db,
|
|
75
|
+
projectId,
|
|
76
|
+
context.env,
|
|
77
|
+
);
|
|
78
|
+
captureByProject.set(projectId, enabled);
|
|
79
|
+
return enabled;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const transformed = await Promise.all(
|
|
83
|
+
spans.map(async (span): Promise<StoredSpan> => {
|
|
84
|
+
const attrs = parseJsonRecord(span.attributesJson);
|
|
85
|
+
const kind = attrs[OPENINFERENCE_SPAN_KIND_KEY];
|
|
86
|
+
if (!isOpenInferenceSpanKind(kind)) return span;
|
|
87
|
+
|
|
88
|
+
const rawInput = attrs[AI_PAYLOAD_INPUT_KEY];
|
|
89
|
+
const rawOutput = attrs[AI_PAYLOAD_OUTPUT_KEY];
|
|
90
|
+
const hasInput = rawInput !== undefined;
|
|
91
|
+
const hasOutput = rawOutput !== undefined;
|
|
92
|
+
const inputJson = hasInput ? toJsonString(rawInput) : null;
|
|
93
|
+
const outputJson = hasOutput ? toJsonString(rawOutput) : null;
|
|
94
|
+
const capturePayloads = await projectCaptureEnabled(span.projectId);
|
|
95
|
+
|
|
96
|
+
// Always record the row for OpenInference spans so callers can
|
|
97
|
+
// join on (trace_id, span_id) even when payloads are absent.
|
|
98
|
+
rows.push({
|
|
99
|
+
projectId: span.projectId,
|
|
100
|
+
traceId: span.traceId,
|
|
101
|
+
spanId: span.spanId,
|
|
102
|
+
spanKind: kind,
|
|
103
|
+
inputJson: capturePayloads ? inputJson : null,
|
|
104
|
+
outputJson: capturePayloads ? outputJson : null,
|
|
105
|
+
inputHash: inputJson ? await sha256Hex(inputJson) : null,
|
|
106
|
+
outputHash: outputJson ? await sha256Hex(outputJson) : null,
|
|
107
|
+
sessionId: asString(attrs[SESSION_ID_KEY]),
|
|
108
|
+
userId: asString(attrs[USER_ID_KEY]),
|
|
109
|
+
interactionId: asString(attrs[INTERACTION_ID_KEY]),
|
|
110
|
+
actionId: asString(attrs[ACTION_ID_KEY]),
|
|
111
|
+
receivedAt: span.receivedAt,
|
|
112
|
+
expiresAt: span.expiresAt,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!hasInput && !hasOutput) return span;
|
|
116
|
+
|
|
117
|
+
// Strip payload attrs — they live in ai_span_payloads now.
|
|
118
|
+
const stripped = { ...attrs };
|
|
119
|
+
delete stripped[AI_PAYLOAD_INPUT_KEY];
|
|
120
|
+
delete stripped[AI_PAYLOAD_OUTPUT_KEY];
|
|
121
|
+
return { ...span, attributesJson: JSON.stringify(stripped) };
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (rows.length > 0) {
|
|
126
|
+
try {
|
|
127
|
+
const stmt = db.prepare(
|
|
128
|
+
`INSERT INTO ai_span_payloads (
|
|
129
|
+
project_id, trace_id, span_id, span_kind,
|
|
130
|
+
input_json, output_json, input_hash, output_hash, session_id, user_id,
|
|
131
|
+
received_at, expires_at, interaction_id, action_id
|
|
132
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
133
|
+
ON CONFLICT(trace_id, span_id) DO UPDATE SET
|
|
134
|
+
project_id = excluded.project_id,
|
|
135
|
+
span_kind = excluded.span_kind,
|
|
136
|
+
input_json = excluded.input_json,
|
|
137
|
+
output_json = excluded.output_json,
|
|
138
|
+
input_hash = excluded.input_hash,
|
|
139
|
+
output_hash = excluded.output_hash,
|
|
140
|
+
session_id = excluded.session_id,
|
|
141
|
+
user_id = excluded.user_id,
|
|
142
|
+
received_at = excluded.received_at,
|
|
143
|
+
expires_at = excluded.expires_at,
|
|
144
|
+
interaction_id = excluded.interaction_id,
|
|
145
|
+
action_id = excluded.action_id`,
|
|
146
|
+
);
|
|
147
|
+
await db.batch(
|
|
148
|
+
rows.map((r) =>
|
|
149
|
+
stmt.bind(
|
|
150
|
+
r.projectId,
|
|
151
|
+
r.traceId,
|
|
152
|
+
r.spanId,
|
|
153
|
+
r.spanKind,
|
|
154
|
+
r.inputJson,
|
|
155
|
+
r.outputJson,
|
|
156
|
+
r.inputHash,
|
|
157
|
+
r.outputHash,
|
|
158
|
+
r.sessionId,
|
|
159
|
+
r.userId,
|
|
160
|
+
r.receivedAt,
|
|
161
|
+
r.expiresAt,
|
|
162
|
+
r.interactionId,
|
|
163
|
+
r.actionId,
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Don't fail the whole ingest on payload side-table issues —
|
|
169
|
+
// the spans themselves are still useful.
|
|
170
|
+
context.logger.error("[ai-span-payloads-processor] write failed", {
|
|
171
|
+
error: err instanceof Error ? err.message : String(err),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return transformed;
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { AlertRule } from "@obsunified/types";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { evaluateRuleBatch } from "./alerts-evaluator";
|
|
4
|
+
|
|
5
|
+
type EvaluatorStore = Parameters<typeof evaluateRuleBatch>[0];
|
|
6
|
+
|
|
7
|
+
const makeRule = (id: string): AlertRule => ({
|
|
8
|
+
id,
|
|
9
|
+
projectId: "default",
|
|
10
|
+
name: `Rule ${id}`,
|
|
11
|
+
signal: "spans",
|
|
12
|
+
query: {},
|
|
13
|
+
threshold: 10,
|
|
14
|
+
windowMins: 5,
|
|
15
|
+
comparison: ">",
|
|
16
|
+
channels: [],
|
|
17
|
+
enabled: true,
|
|
18
|
+
createdAt: "2026-05-31T00:00:00.000Z",
|
|
19
|
+
updatedAt: "2026-05-31T00:00:00.000Z",
|
|
20
|
+
analysisId: null,
|
|
21
|
+
currentState: "ok",
|
|
22
|
+
lastStateChange: null,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const makeStore = (
|
|
26
|
+
overrides: Partial<EvaluatorStore> = {},
|
|
27
|
+
): EvaluatorStore => ({
|
|
28
|
+
listEnabledRules: async () => [],
|
|
29
|
+
evaluateRule: async () => 0,
|
|
30
|
+
getState: async () => null,
|
|
31
|
+
getAnalysisNarrative: async () => null,
|
|
32
|
+
transitionState: async () => {},
|
|
33
|
+
recordEvaluation: async () => {},
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("evaluateRuleBatch", () => {
|
|
38
|
+
it("evaluates rules with a bounded concurrency limit", async () => {
|
|
39
|
+
const rules = Array.from({ length: 6 }, (_, i) => makeRule(`r${i}`));
|
|
40
|
+
let active = 0;
|
|
41
|
+
let maxActive = 0;
|
|
42
|
+
const store = makeStore({
|
|
43
|
+
evaluateRule: async () => {
|
|
44
|
+
active += 1;
|
|
45
|
+
maxActive = Math.max(maxActive, active);
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
47
|
+
active -= 1;
|
|
48
|
+
return 0;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await evaluateRuleBatch(store, rules, { concurrency: 2 });
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual({ evaluated: 6, fired: 0, resolved: 0 });
|
|
55
|
+
expect(maxActive).toBeLessThanOrEqual(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("times out a stuck rule and continues the batch", async () => {
|
|
59
|
+
const errors: unknown[] = [];
|
|
60
|
+
let recorded = 0;
|
|
61
|
+
const store = makeStore({
|
|
62
|
+
evaluateRule: async (rule) => {
|
|
63
|
+
if (rule.id === "stuck") return new Promise<number>(() => {});
|
|
64
|
+
return 0;
|
|
65
|
+
},
|
|
66
|
+
recordEvaluation: async () => {
|
|
67
|
+
recorded += 1;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = await evaluateRuleBatch(
|
|
72
|
+
store,
|
|
73
|
+
[makeRule("stuck"), makeRule("ok")],
|
|
74
|
+
{
|
|
75
|
+
concurrency: 2,
|
|
76
|
+
ruleTimeoutMs: 1,
|
|
77
|
+
logger: {
|
|
78
|
+
debug: () => {},
|
|
79
|
+
info: () => {},
|
|
80
|
+
warn: () => {},
|
|
81
|
+
error: (_message, attrs) => errors.push(attrs),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(result).toEqual({ evaluated: 2, fired: 0, resolved: 0 });
|
|
87
|
+
expect(recorded).toBe(1);
|
|
88
|
+
expect(errors).toHaveLength(1);
|
|
89
|
+
expect(errors[0]).toMatchObject({ rule_id: "stuck" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AlertChannel,
|
|
3
|
+
AlertRule,
|
|
4
|
+
AlertState,
|
|
5
|
+
AlertWebhookChannel,
|
|
6
|
+
} from "@obsunified/types";
|
|
7
|
+
import type { CollectorEnv } from "../framework/env";
|
|
8
|
+
import {
|
|
9
|
+
type ChildSpanRunner,
|
|
10
|
+
consoleLogger,
|
|
11
|
+
type Logger,
|
|
12
|
+
} from "../framework/logger";
|
|
13
|
+
import { AlertsStore, compareValue } from "../lib/alerts-store";
|
|
14
|
+
import { sqlDbFor } from "../lib/sql-db";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_RULE_CONCURRENCY = 5;
|
|
17
|
+
const DEFAULT_RULE_TIMEOUT_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
interface AlertEvaluatorStore {
|
|
20
|
+
listEnabledRules(): Promise<AlertRule[]>;
|
|
21
|
+
evaluateRule(rule: AlertRule): Promise<number>;
|
|
22
|
+
getState(ruleId: string): Promise<{ current_state: AlertState } | null>;
|
|
23
|
+
getAnalysisNarrative(
|
|
24
|
+
projectId: string,
|
|
25
|
+
analysisId: string,
|
|
26
|
+
): Promise<{ narrative: string | null; status: string | null } | null>;
|
|
27
|
+
transitionState(
|
|
28
|
+
ruleId: string,
|
|
29
|
+
projectId: string,
|
|
30
|
+
newState: AlertState,
|
|
31
|
+
now: string,
|
|
32
|
+
): Promise<void>;
|
|
33
|
+
recordEvaluation(
|
|
34
|
+
ruleId: string,
|
|
35
|
+
projectId: string,
|
|
36
|
+
value: number,
|
|
37
|
+
state: AlertState,
|
|
38
|
+
notified: boolean,
|
|
39
|
+
): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface WebhookPayload {
|
|
43
|
+
rule: {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
signal: string;
|
|
47
|
+
};
|
|
48
|
+
value: number;
|
|
49
|
+
threshold: number;
|
|
50
|
+
comparison: string;
|
|
51
|
+
state: AlertState;
|
|
52
|
+
evaluatedAt: string;
|
|
53
|
+
projectId: string;
|
|
54
|
+
/**
|
|
55
|
+
* RFC 0002 Stage 6 — populated when the rule is bound to an Analysis
|
|
56
|
+
* (`rule.analysisId` set). The narrative is the gated, LLM-generated
|
|
57
|
+
* sentence the dashboard panel was last showing — what's actually
|
|
58
|
+
* happening, in plain English. Webhooks consuming this can put the
|
|
59
|
+
* narrative in the Slack/PagerDuty body directly.
|
|
60
|
+
*/
|
|
61
|
+
analysis?: {
|
|
62
|
+
id: string;
|
|
63
|
+
narrative: string | null;
|
|
64
|
+
status: string | null;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fireWebhook(
|
|
69
|
+
channel: AlertWebhookChannel,
|
|
70
|
+
payload: WebhookPayload,
|
|
71
|
+
logger: Logger,
|
|
72
|
+
tracer?: ChildSpanRunner,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
const exec = async (
|
|
75
|
+
span: { setAttribute(k: string, v: unknown): void } | null,
|
|
76
|
+
): Promise<boolean> => {
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(channel.url, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
...(channel.headers ?? {}),
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(payload),
|
|
85
|
+
signal: AbortSignal.timeout(5000),
|
|
86
|
+
});
|
|
87
|
+
if (span) span.setAttribute("http.response.status_code", response.status);
|
|
88
|
+
return response.ok;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logger.error("[alerts-evaluator] webhook delivery failed", {
|
|
91
|
+
url: channel.url,
|
|
92
|
+
error: err instanceof Error ? err.message : String(err),
|
|
93
|
+
});
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
if (!tracer) return exec(null);
|
|
98
|
+
return tracer("webhook.alert", async (span) => exec(span), {
|
|
99
|
+
"http.method": "POST",
|
|
100
|
+
"http.url": channel.url,
|
|
101
|
+
"alert.state": payload.state,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fireChannels(
|
|
106
|
+
channels: AlertChannel[],
|
|
107
|
+
payload: WebhookPayload,
|
|
108
|
+
logger: Logger,
|
|
109
|
+
tracer?: ChildSpanRunner,
|
|
110
|
+
): Promise<boolean> {
|
|
111
|
+
if (channels.length === 0) return true;
|
|
112
|
+
const results = await Promise.all(
|
|
113
|
+
channels
|
|
114
|
+
.filter((ch): ch is AlertWebhookChannel => ch.type === "webhook")
|
|
115
|
+
.map((ch) => fireWebhook(ch, payload, logger, tracer)),
|
|
116
|
+
);
|
|
117
|
+
return results.every(Boolean);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function withTimeout<T>(
|
|
121
|
+
promise: Promise<T>,
|
|
122
|
+
ms: number,
|
|
123
|
+
message: string,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
try {
|
|
127
|
+
return await Promise.race([
|
|
128
|
+
promise,
|
|
129
|
+
new Promise<never>((_, reject) => {
|
|
130
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
131
|
+
}),
|
|
132
|
+
]);
|
|
133
|
+
} finally {
|
|
134
|
+
if (timer) clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function mapWithConcurrency<T, R>(
|
|
139
|
+
items: T[],
|
|
140
|
+
concurrency: number,
|
|
141
|
+
mapper: (item: T) => Promise<R>,
|
|
142
|
+
): Promise<R[]> {
|
|
143
|
+
const results: R[] = new Array(items.length);
|
|
144
|
+
let nextIndex = 0;
|
|
145
|
+
const workerCount = Math.min(concurrency, items.length);
|
|
146
|
+
await Promise.all(
|
|
147
|
+
Array.from({ length: workerCount }, async () => {
|
|
148
|
+
while (nextIndex < items.length) {
|
|
149
|
+
const current = nextIndex++;
|
|
150
|
+
results[current] = await mapper(items[current]);
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function evaluateOneRule(
|
|
158
|
+
store: AlertEvaluatorStore,
|
|
159
|
+
rule: AlertRule,
|
|
160
|
+
logger: Logger,
|
|
161
|
+
tracer: ChildSpanRunner | undefined,
|
|
162
|
+
ruleTimeoutMs: number,
|
|
163
|
+
): Promise<{ fired: number; resolved: number }> {
|
|
164
|
+
try {
|
|
165
|
+
const value = await withTimeout(
|
|
166
|
+
store.evaluateRule(rule),
|
|
167
|
+
ruleTimeoutMs,
|
|
168
|
+
`rule evaluation timed out after ${ruleTimeoutMs}ms`,
|
|
169
|
+
);
|
|
170
|
+
const shouldFire = compareValue(value, rule.threshold, rule.comparison);
|
|
171
|
+
const priorState = await store.getState(rule.id);
|
|
172
|
+
const previous: AlertState = priorState?.current_state ?? "ok";
|
|
173
|
+
const next: AlertState = shouldFire ? "firing" : "ok";
|
|
174
|
+
const now = new Date().toISOString();
|
|
175
|
+
|
|
176
|
+
// Stage 6: when the rule is bound to an analysis, pull the
|
|
177
|
+
// latest narrative + status so the webhook payload describes
|
|
178
|
+
// what's happening, not just "value > threshold".
|
|
179
|
+
const analysisAttachment = rule.analysisId
|
|
180
|
+
? await store
|
|
181
|
+
.getAnalysisNarrative(rule.projectId, rule.analysisId)
|
|
182
|
+
.then((n) =>
|
|
183
|
+
n
|
|
184
|
+
? {
|
|
185
|
+
id: rule.analysisId as string,
|
|
186
|
+
narrative: n.narrative,
|
|
187
|
+
status: n.status,
|
|
188
|
+
}
|
|
189
|
+
: undefined,
|
|
190
|
+
)
|
|
191
|
+
.catch(() => undefined)
|
|
192
|
+
: undefined;
|
|
193
|
+
|
|
194
|
+
if (previous === "ok" && next === "firing") {
|
|
195
|
+
const ok = await fireChannels(
|
|
196
|
+
rule.channels,
|
|
197
|
+
{
|
|
198
|
+
rule: { id: rule.id, name: rule.name, signal: rule.signal },
|
|
199
|
+
value,
|
|
200
|
+
threshold: rule.threshold,
|
|
201
|
+
comparison: rule.comparison,
|
|
202
|
+
state: "firing",
|
|
203
|
+
evaluatedAt: now,
|
|
204
|
+
projectId: rule.projectId,
|
|
205
|
+
analysis: analysisAttachment,
|
|
206
|
+
},
|
|
207
|
+
logger,
|
|
208
|
+
tracer,
|
|
209
|
+
);
|
|
210
|
+
await store.transitionState(rule.id, rule.projectId, "firing", now);
|
|
211
|
+
await store.recordEvaluation(
|
|
212
|
+
rule.id,
|
|
213
|
+
rule.projectId,
|
|
214
|
+
value,
|
|
215
|
+
"firing",
|
|
216
|
+
ok,
|
|
217
|
+
);
|
|
218
|
+
return { fired: 1, resolved: 0 };
|
|
219
|
+
}
|
|
220
|
+
if (previous === "firing" && next === "ok") {
|
|
221
|
+
const ok = await fireChannels(
|
|
222
|
+
rule.channels,
|
|
223
|
+
{
|
|
224
|
+
rule: { id: rule.id, name: rule.name, signal: rule.signal },
|
|
225
|
+
value,
|
|
226
|
+
threshold: rule.threshold,
|
|
227
|
+
comparison: rule.comparison,
|
|
228
|
+
state: "ok",
|
|
229
|
+
evaluatedAt: now,
|
|
230
|
+
projectId: rule.projectId,
|
|
231
|
+
analysis: analysisAttachment,
|
|
232
|
+
},
|
|
233
|
+
logger,
|
|
234
|
+
tracer,
|
|
235
|
+
);
|
|
236
|
+
await store.transitionState(rule.id, rule.projectId, "ok", now);
|
|
237
|
+
await store.recordEvaluation(rule.id, rule.projectId, value, "ok", ok);
|
|
238
|
+
return { fired: 0, resolved: 1 };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Same state — write an evaluation but do not fire channels.
|
|
242
|
+
await store.recordEvaluation(rule.id, rule.projectId, value, next, false);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
logger.error("[alerts-evaluator] rule evaluation failed", {
|
|
245
|
+
rule_id: rule.id,
|
|
246
|
+
project_id: rule.projectId,
|
|
247
|
+
error: err instanceof Error ? err.message : String(err),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { fired: 0, resolved: 0 };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function evaluateRuleBatch(
|
|
255
|
+
store: AlertEvaluatorStore,
|
|
256
|
+
rules: AlertRule[],
|
|
257
|
+
options?: {
|
|
258
|
+
logger?: Logger;
|
|
259
|
+
tracer?: ChildSpanRunner;
|
|
260
|
+
concurrency?: number;
|
|
261
|
+
ruleTimeoutMs?: number;
|
|
262
|
+
},
|
|
263
|
+
): Promise<{
|
|
264
|
+
evaluated: number;
|
|
265
|
+
fired: number;
|
|
266
|
+
resolved: number;
|
|
267
|
+
}> {
|
|
268
|
+
const logger = options?.logger ?? consoleLogger;
|
|
269
|
+
const concurrency = Math.max(
|
|
270
|
+
1,
|
|
271
|
+
Math.floor(options?.concurrency ?? DEFAULT_RULE_CONCURRENCY),
|
|
272
|
+
);
|
|
273
|
+
const ruleTimeoutMs = Math.max(
|
|
274
|
+
1,
|
|
275
|
+
Math.floor(options?.ruleTimeoutMs ?? DEFAULT_RULE_TIMEOUT_MS),
|
|
276
|
+
);
|
|
277
|
+
const results = await mapWithConcurrency(rules, concurrency, (rule) =>
|
|
278
|
+
evaluateOneRule(store, rule, logger, options?.tracer, ruleTimeoutMs),
|
|
279
|
+
);
|
|
280
|
+
return {
|
|
281
|
+
evaluated: rules.length,
|
|
282
|
+
fired: results.reduce((sum, result) => sum + result.fired, 0),
|
|
283
|
+
resolved: results.reduce((sum, result) => sum + result.resolved, 0),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function evaluateAllRules(
|
|
288
|
+
env: CollectorEnv,
|
|
289
|
+
options?: {
|
|
290
|
+
logger?: Logger;
|
|
291
|
+
tracer?: ChildSpanRunner;
|
|
292
|
+
concurrency?: number;
|
|
293
|
+
ruleTimeoutMs?: number;
|
|
294
|
+
},
|
|
295
|
+
): Promise<{
|
|
296
|
+
evaluated: number;
|
|
297
|
+
fired: number;
|
|
298
|
+
resolved: number;
|
|
299
|
+
}> {
|
|
300
|
+
const logger = options?.logger ?? consoleLogger;
|
|
301
|
+
const tracer = options?.tracer;
|
|
302
|
+
const store = new AlertsStore(sqlDbFor(env));
|
|
303
|
+
const rules = await store.listEnabledRules();
|
|
304
|
+
return evaluateRuleBatch(store, rules, {
|
|
305
|
+
logger,
|
|
306
|
+
tracer,
|
|
307
|
+
concurrency: options?.concurrency,
|
|
308
|
+
ruleTimeoutMs: options?.ruleTimeoutMs,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Scheduled handler suitable for wrangler cron triggers. Dispatches on the
|
|
314
|
+
* cron expression so multiple triggers can share this handler.
|
|
315
|
+
*/
|
|
316
|
+
export function createAlertEvaluatorHandler() {
|
|
317
|
+
return {
|
|
318
|
+
async scheduled(
|
|
319
|
+
_event: { cron: string },
|
|
320
|
+
env: CollectorEnv,
|
|
321
|
+
ctx: {
|
|
322
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
323
|
+
},
|
|
324
|
+
) {
|
|
325
|
+
ctx.waitUntil(evaluateAllRules(env));
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { AlertRuleInput, AlertTestResponse } from "@obsunified/types";
|
|
2
|
+
import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
|
|
3
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
4
|
+
import { AlertsStore, compareValue } from "../lib/alerts-store";
|
|
5
|
+
import { alertEvidenceReferences } from "../lib/evidence-references";
|
|
6
|
+
import { sqlDbFor } from "../lib/sql-db";
|
|
7
|
+
import { getProjectId } from "./_context";
|
|
8
|
+
|
|
9
|
+
export const alertsRoutesPlugin: CollectorPlugin = {
|
|
10
|
+
name: "alerts-routes",
|
|
11
|
+
register(app) {
|
|
12
|
+
app.get("/internal/alerts/rules", async (c) => {
|
|
13
|
+
const projectId = getProjectId(c);
|
|
14
|
+
const store = new AlertsStore(sqlDbFor(c.env));
|
|
15
|
+
const rules = await store.listRules(projectId);
|
|
16
|
+
return c.json({ rules });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
app.post("/internal/alerts/rules", async (c) => {
|
|
20
|
+
const projectId = getProjectId(c);
|
|
21
|
+
let input: AlertRuleInput;
|
|
22
|
+
try {
|
|
23
|
+
input = await c.req.json<AlertRuleInput>();
|
|
24
|
+
} catch {
|
|
25
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
26
|
+
}
|
|
27
|
+
const store = new AlertsStore(sqlDbFor(c.env));
|
|
28
|
+
try {
|
|
29
|
+
const rule = await store.createRule(projectId, input);
|
|
30
|
+
return c.json({ rule }, 201);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
33
|
+
return c.json({ error: message }, 400);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.patch("/internal/alerts/rules/:id", async (c) => {
|
|
38
|
+
const projectId = getProjectId(c);
|
|
39
|
+
const id = c.req.param("id");
|
|
40
|
+
let patch: Partial<AlertRuleInput>;
|
|
41
|
+
try {
|
|
42
|
+
patch = await c.req.json<Partial<AlertRuleInput>>();
|
|
43
|
+
} catch {
|
|
44
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
45
|
+
}
|
|
46
|
+
const store = new AlertsStore(sqlDbFor(c.env));
|
|
47
|
+
try {
|
|
48
|
+
const rule = await store.updateRule(id, projectId, patch);
|
|
49
|
+
if (!rule) return c.json({ error: "Rule not found" }, 404);
|
|
50
|
+
return c.json({ rule });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
return c.json({ error: message }, 400);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
app.delete("/internal/alerts/rules/:id", async (c) => {
|
|
58
|
+
const projectId = getProjectId(c);
|
|
59
|
+
const id = c.req.param("id");
|
|
60
|
+
const store = new AlertsStore(sqlDbFor(c.env));
|
|
61
|
+
const deleted = await store.deleteRule(id, projectId);
|
|
62
|
+
if (!deleted) return c.json({ error: "Rule not found" }, 404);
|
|
63
|
+
return c.json({ success: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
app.get("/internal/alerts/evaluations", async (c) => {
|
|
67
|
+
const projectId = getProjectId(c);
|
|
68
|
+
const ruleId = c.req.query("ruleId");
|
|
69
|
+
if (!ruleId) return c.json({ error: "ruleId is required" }, 400);
|
|
70
|
+
const hours = Math.max(
|
|
71
|
+
1,
|
|
72
|
+
Math.min(720, Number.parseInt(c.req.query("hours") || "24", 10) || 24),
|
|
73
|
+
);
|
|
74
|
+
const store = new AlertsStore(sqlDbFor(c.env));
|
|
75
|
+
const response = await store.listEvaluations({
|
|
76
|
+
ruleId,
|
|
77
|
+
projectId,
|
|
78
|
+
hours,
|
|
79
|
+
});
|
|
80
|
+
return c.json(response);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.post("/internal/alerts/rules/:id/test", async (c) => {
|
|
84
|
+
const projectId = getProjectId(c);
|
|
85
|
+
const id = c.req.param("id");
|
|
86
|
+
const store = new AlertsStore(sqlDbFor(c.env));
|
|
87
|
+
const rule = await store.getRule(id, projectId);
|
|
88
|
+
if (!rule) return c.json({ error: "Rule not found" }, 404);
|
|
89
|
+
const value = await store.evaluateRule(rule);
|
|
90
|
+
const response: AlertTestResponse = {
|
|
91
|
+
value,
|
|
92
|
+
wouldFire: compareValue(value, rule.threshold, rule.comparison),
|
|
93
|
+
comparison: rule.comparison,
|
|
94
|
+
threshold: rule.threshold,
|
|
95
|
+
evidenceReferences: alertEvidenceReferences(rule),
|
|
96
|
+
evidenceContract: EVIDENCE_REFERENCE_CONTRACT,
|
|
97
|
+
};
|
|
98
|
+
return c.json(response);
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|