@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,458 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AlertChannel,
|
|
3
|
+
AlertComparison,
|
|
4
|
+
AlertEvaluation,
|
|
5
|
+
AlertEvaluationRow,
|
|
6
|
+
AlertEvaluationsListResponse,
|
|
7
|
+
AlertQuery,
|
|
8
|
+
AlertQueryAI,
|
|
9
|
+
AlertQueryLogs,
|
|
10
|
+
AlertQuerySpans,
|
|
11
|
+
AlertQueryUsage,
|
|
12
|
+
AlertRule,
|
|
13
|
+
AlertRuleInput,
|
|
14
|
+
AlertRuleRow,
|
|
15
|
+
AlertSignal,
|
|
16
|
+
AlertState,
|
|
17
|
+
AlertStateRow,
|
|
18
|
+
LogSeverity,
|
|
19
|
+
} from "@obsunified/types";
|
|
20
|
+
import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
|
|
21
|
+
import { alertEvidenceReferences } from "./evidence-references";
|
|
22
|
+
import { randomHex } from "./hash";
|
|
23
|
+
import type { SqlDb } from "./sql-db";
|
|
24
|
+
|
|
25
|
+
const parseJsonField = <T>(value: unknown): T => {
|
|
26
|
+
if (typeof value === "string") return JSON.parse(value) as T;
|
|
27
|
+
return value as T;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const rowToRule = (row: AlertRuleRow, state?: AlertStateRow): AlertRule => ({
|
|
31
|
+
id: row.id,
|
|
32
|
+
projectId: row.project_id,
|
|
33
|
+
name: row.name,
|
|
34
|
+
signal: row.signal,
|
|
35
|
+
query: parseJsonField<AlertQuery>(row.query_json),
|
|
36
|
+
threshold: row.threshold,
|
|
37
|
+
windowMins: row.window_mins,
|
|
38
|
+
comparison: row.comparison,
|
|
39
|
+
channels: parseJsonField<AlertChannel[]>(row.channels_json),
|
|
40
|
+
enabled: row.enabled === 1,
|
|
41
|
+
createdAt: row.created_at,
|
|
42
|
+
updatedAt: row.updated_at,
|
|
43
|
+
analysisId: row.analysis_id ?? null,
|
|
44
|
+
currentState: state?.current_state,
|
|
45
|
+
lastStateChange: state?.last_state_change ?? null,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const rowToEvaluation = (row: AlertEvaluationRow): AlertEvaluation => ({
|
|
49
|
+
id: row.id,
|
|
50
|
+
ruleId: row.rule_id,
|
|
51
|
+
projectId: row.project_id,
|
|
52
|
+
evaluatedAt: row.evaluated_at,
|
|
53
|
+
value: row.value,
|
|
54
|
+
state: row.state,
|
|
55
|
+
notified: row.notified === 1,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const windowCutoffIso = (windowMins: number): string =>
|
|
59
|
+
new Date(Date.now() - windowMins * 60 * 1000).toISOString();
|
|
60
|
+
|
|
61
|
+
const SIGNALS: AlertSignal[] = ["spans", "logs", "usage", "ai"];
|
|
62
|
+
const COMPARISONS: AlertComparison[] = [">", ">=", "<", "<="];
|
|
63
|
+
|
|
64
|
+
export function compareValue(
|
|
65
|
+
value: number,
|
|
66
|
+
threshold: number,
|
|
67
|
+
comparison: AlertComparison,
|
|
68
|
+
): boolean {
|
|
69
|
+
switch (comparison) {
|
|
70
|
+
case ">":
|
|
71
|
+
return value > threshold;
|
|
72
|
+
case ">=":
|
|
73
|
+
return value >= threshold;
|
|
74
|
+
case "<":
|
|
75
|
+
return value < threshold;
|
|
76
|
+
case "<=":
|
|
77
|
+
return value <= threshold;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class AlertsStore {
|
|
82
|
+
constructor(private readonly db: SqlDb) {}
|
|
83
|
+
|
|
84
|
+
validateInput(input: AlertRuleInput): void {
|
|
85
|
+
if (!input.name?.trim()) throw new Error("name is required");
|
|
86
|
+
if (!SIGNALS.includes(input.signal))
|
|
87
|
+
throw new Error(`signal must be one of: ${SIGNALS.join(", ")}`);
|
|
88
|
+
if (!Number.isFinite(input.threshold))
|
|
89
|
+
throw new Error("threshold must be a finite number");
|
|
90
|
+
if (!Number.isFinite(input.windowMins) || input.windowMins < 1)
|
|
91
|
+
throw new Error("windowMins must be >= 1");
|
|
92
|
+
if (!COMPARISONS.includes(input.comparison))
|
|
93
|
+
throw new Error(`comparison must be one of: ${COMPARISONS.join(", ")}`);
|
|
94
|
+
if (!Array.isArray(input.channels) || input.channels.length === 0)
|
|
95
|
+
throw new Error("at least one channel is required");
|
|
96
|
+
for (const ch of input.channels) {
|
|
97
|
+
if (ch.type !== "webhook")
|
|
98
|
+
throw new Error(`unknown channel type: ${ch.type}`);
|
|
99
|
+
if (!ch.url || !/^https?:\/\//.test(ch.url))
|
|
100
|
+
throw new Error("channel.url must be http(s)://");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async listRules(projectId: string): Promise<AlertRule[]> {
|
|
105
|
+
const rs = await this.db
|
|
106
|
+
.prepare(
|
|
107
|
+
`SELECT * FROM alert_rules WHERE project_id = ? ORDER BY created_at DESC`,
|
|
108
|
+
)
|
|
109
|
+
.bind(projectId)
|
|
110
|
+
.all<AlertRuleRow>();
|
|
111
|
+
const rows = rs.results ?? [];
|
|
112
|
+
if (rows.length === 0) return [];
|
|
113
|
+
const stateRs = await this.db
|
|
114
|
+
.prepare(`SELECT * FROM alert_state WHERE project_id = ?`)
|
|
115
|
+
.bind(projectId)
|
|
116
|
+
.all<AlertStateRow>();
|
|
117
|
+
const stateByRule = new Map<string, AlertStateRow>(
|
|
118
|
+
(stateRs.results ?? []).map((s) => [s.rule_id, s]),
|
|
119
|
+
);
|
|
120
|
+
return rows.map((r) => rowToRule(r, stateByRule.get(r.id)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async getRule(id: string, projectId: string): Promise<AlertRule | null> {
|
|
124
|
+
const row = await this.db
|
|
125
|
+
.prepare(`SELECT * FROM alert_rules WHERE id = ? AND project_id = ?`)
|
|
126
|
+
.bind(id, projectId)
|
|
127
|
+
.first<AlertRuleRow>();
|
|
128
|
+
if (!row) return null;
|
|
129
|
+
const state = await this.db
|
|
130
|
+
.prepare(`SELECT * FROM alert_state WHERE rule_id = ?`)
|
|
131
|
+
.bind(id)
|
|
132
|
+
.first<AlertStateRow>();
|
|
133
|
+
return rowToRule(row, state ?? undefined);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async createRule(
|
|
137
|
+
projectId: string,
|
|
138
|
+
input: AlertRuleInput,
|
|
139
|
+
): Promise<AlertRule> {
|
|
140
|
+
this.validateInput(input);
|
|
141
|
+
const id = randomHex(16);
|
|
142
|
+
const now = new Date().toISOString();
|
|
143
|
+
await this.db
|
|
144
|
+
.prepare(
|
|
145
|
+
`INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at, analysis_id)
|
|
146
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
147
|
+
)
|
|
148
|
+
.bind(
|
|
149
|
+
id,
|
|
150
|
+
projectId,
|
|
151
|
+
input.name.trim(),
|
|
152
|
+
input.signal,
|
|
153
|
+
JSON.stringify(input.query),
|
|
154
|
+
input.threshold,
|
|
155
|
+
input.windowMins,
|
|
156
|
+
input.comparison,
|
|
157
|
+
JSON.stringify(input.channels),
|
|
158
|
+
input.enabled === false ? 0 : 1,
|
|
159
|
+
now,
|
|
160
|
+
now,
|
|
161
|
+
input.analysisId ?? null,
|
|
162
|
+
)
|
|
163
|
+
.run();
|
|
164
|
+
|
|
165
|
+
const created = await this.getRule(id, projectId);
|
|
166
|
+
if (!created) throw new Error("Failed to create rule");
|
|
167
|
+
return created;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async updateRule(
|
|
171
|
+
id: string,
|
|
172
|
+
projectId: string,
|
|
173
|
+
patch: Partial<AlertRuleInput>,
|
|
174
|
+
): Promise<AlertRule | null> {
|
|
175
|
+
const existing = await this.getRule(id, projectId);
|
|
176
|
+
if (!existing) return null;
|
|
177
|
+
|
|
178
|
+
const merged: AlertRuleInput = {
|
|
179
|
+
name: patch.name ?? existing.name,
|
|
180
|
+
signal: patch.signal ?? existing.signal,
|
|
181
|
+
query: patch.query ?? existing.query,
|
|
182
|
+
threshold: patch.threshold ?? existing.threshold,
|
|
183
|
+
windowMins: patch.windowMins ?? existing.windowMins,
|
|
184
|
+
comparison: patch.comparison ?? existing.comparison,
|
|
185
|
+
channels: patch.channels ?? existing.channels,
|
|
186
|
+
enabled: patch.enabled ?? existing.enabled,
|
|
187
|
+
analysisId:
|
|
188
|
+
"analysisId" in patch ? patch.analysisId : existing.analysisId,
|
|
189
|
+
};
|
|
190
|
+
this.validateInput(merged);
|
|
191
|
+
|
|
192
|
+
const now = new Date().toISOString();
|
|
193
|
+
await this.db
|
|
194
|
+
.prepare(
|
|
195
|
+
`UPDATE alert_rules SET
|
|
196
|
+
name = ?, signal = ?, query_json = ?, threshold = ?, window_mins = ?,
|
|
197
|
+
comparison = ?, channels_json = ?, enabled = ?, updated_at = ?, analysis_id = ?
|
|
198
|
+
WHERE id = ? AND project_id = ?`,
|
|
199
|
+
)
|
|
200
|
+
.bind(
|
|
201
|
+
merged.name.trim(),
|
|
202
|
+
merged.signal,
|
|
203
|
+
JSON.stringify(merged.query),
|
|
204
|
+
merged.threshold,
|
|
205
|
+
merged.windowMins,
|
|
206
|
+
merged.comparison,
|
|
207
|
+
JSON.stringify(merged.channels),
|
|
208
|
+
merged.enabled === false ? 0 : 1,
|
|
209
|
+
now,
|
|
210
|
+
merged.analysisId ?? null,
|
|
211
|
+
id,
|
|
212
|
+
projectId,
|
|
213
|
+
)
|
|
214
|
+
.run();
|
|
215
|
+
|
|
216
|
+
return this.getRule(id, projectId);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async deleteRule(id: string, projectId: string): Promise<boolean> {
|
|
220
|
+
const result = await this.db
|
|
221
|
+
.prepare(`DELETE FROM alert_rules WHERE id = ? AND project_id = ?`)
|
|
222
|
+
.bind(id, projectId)
|
|
223
|
+
.run();
|
|
224
|
+
// Also clean up state and evaluations for this rule.
|
|
225
|
+
await this.db
|
|
226
|
+
.prepare(`DELETE FROM alert_state WHERE rule_id = ?`)
|
|
227
|
+
.bind(id)
|
|
228
|
+
.run();
|
|
229
|
+
await this.db
|
|
230
|
+
.prepare(`DELETE FROM alert_evaluations WHERE rule_id = ?`)
|
|
231
|
+
.bind(id)
|
|
232
|
+
.run();
|
|
233
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async listEnabledRules(): Promise<AlertRule[]> {
|
|
237
|
+
const rs = await this.db
|
|
238
|
+
.prepare(`SELECT * FROM alert_rules WHERE enabled = 1`)
|
|
239
|
+
.all<AlertRuleRow>();
|
|
240
|
+
return (rs.results ?? []).map((r) => rowToRule(r));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── State ──
|
|
244
|
+
|
|
245
|
+
async getState(ruleId: string): Promise<AlertStateRow | null> {
|
|
246
|
+
const row = await this.db
|
|
247
|
+
.prepare(`SELECT * FROM alert_state WHERE rule_id = ?`)
|
|
248
|
+
.bind(ruleId)
|
|
249
|
+
.first<AlertStateRow>();
|
|
250
|
+
return row ?? null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async transitionState(
|
|
254
|
+
ruleId: string,
|
|
255
|
+
projectId: string,
|
|
256
|
+
newState: AlertState,
|
|
257
|
+
now: string,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
await this.db
|
|
260
|
+
.prepare(
|
|
261
|
+
`INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change)
|
|
262
|
+
VALUES (?, ?, ?, ?)
|
|
263
|
+
ON CONFLICT(rule_id) DO UPDATE SET current_state = excluded.current_state, last_state_change = excluded.last_state_change`,
|
|
264
|
+
)
|
|
265
|
+
.bind(ruleId, projectId, newState, now)
|
|
266
|
+
.run();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async recordEvaluation(
|
|
270
|
+
ruleId: string,
|
|
271
|
+
projectId: string,
|
|
272
|
+
value: number,
|
|
273
|
+
state: AlertState,
|
|
274
|
+
notified: boolean,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
await this.db
|
|
277
|
+
.prepare(
|
|
278
|
+
`INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified)
|
|
279
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
280
|
+
)
|
|
281
|
+
.bind(
|
|
282
|
+
randomHex(16),
|
|
283
|
+
ruleId,
|
|
284
|
+
projectId,
|
|
285
|
+
new Date().toISOString(),
|
|
286
|
+
value,
|
|
287
|
+
state,
|
|
288
|
+
notified ? 1 : 0,
|
|
289
|
+
)
|
|
290
|
+
.run();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async listEvaluations(params: {
|
|
294
|
+
ruleId: string;
|
|
295
|
+
projectId?: string;
|
|
296
|
+
hours: number;
|
|
297
|
+
limit?: number;
|
|
298
|
+
}): Promise<AlertEvaluationsListResponse> {
|
|
299
|
+
const cutoff = new Date(
|
|
300
|
+
Date.now() - params.hours * 60 * 60 * 1000,
|
|
301
|
+
).toISOString();
|
|
302
|
+
const rs = await this.db
|
|
303
|
+
.prepare(
|
|
304
|
+
`SELECT * FROM alert_evaluations WHERE rule_id = ? AND evaluated_at >= ? ORDER BY evaluated_at DESC LIMIT ?`,
|
|
305
|
+
)
|
|
306
|
+
.bind(params.ruleId, cutoff, Math.min(params.limit ?? 500, 1000))
|
|
307
|
+
.all<AlertEvaluationRow>();
|
|
308
|
+
const rows = rs.results ?? [];
|
|
309
|
+
const projectId = params.projectId ?? rows[0]?.project_id;
|
|
310
|
+
const rule = projectId
|
|
311
|
+
? await this.getRule(params.ruleId, projectId)
|
|
312
|
+
: null;
|
|
313
|
+
const response: AlertEvaluationsListResponse = {
|
|
314
|
+
evaluations: rows.map(rowToEvaluation),
|
|
315
|
+
};
|
|
316
|
+
if (rule) {
|
|
317
|
+
response.evidenceReferences = alertEvidenceReferences(rule);
|
|
318
|
+
response.evidenceContract = EVIDENCE_REFERENCE_CONTRACT;
|
|
319
|
+
}
|
|
320
|
+
return response;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Evaluators (count-over-window per signal) ──
|
|
324
|
+
|
|
325
|
+
async evaluateRule(rule: AlertRule): Promise<number> {
|
|
326
|
+
// Stage 6 — analysis-bound rule. Read the analysis's latest
|
|
327
|
+
// primary value instead of running the rule's raw query. Falls
|
|
328
|
+
// through to the legacy path if no result has landed yet so the
|
|
329
|
+
// alert behaves as "metric unknown / 0" rather than throwing.
|
|
330
|
+
if (rule.analysisId) {
|
|
331
|
+
const row = await this.db
|
|
332
|
+
.prepare(
|
|
333
|
+
`SELECT primary_value FROM analysis_results
|
|
334
|
+
WHERE project_id = ? AND analysis_id = ?
|
|
335
|
+
ORDER BY generated_at DESC LIMIT 1`,
|
|
336
|
+
)
|
|
337
|
+
.bind(rule.projectId, rule.analysisId)
|
|
338
|
+
.first<{ primary_value: number | null }>();
|
|
339
|
+
return row?.primary_value ?? 0;
|
|
340
|
+
}
|
|
341
|
+
switch (rule.signal) {
|
|
342
|
+
case "spans":
|
|
343
|
+
return this.evaluateSpanRule(rule);
|
|
344
|
+
case "logs":
|
|
345
|
+
return this.evaluateLogRule(rule);
|
|
346
|
+
case "usage":
|
|
347
|
+
return this.evaluateUsageRule(rule);
|
|
348
|
+
case "ai":
|
|
349
|
+
return this.evaluateAIRule(rule);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Stage 6 — fetch the latest narrative associated with this rule's
|
|
355
|
+
* bound analysis. Used by the evaluator when firing an analysis-bound
|
|
356
|
+
* alert so the webhook payload includes the human-readable
|
|
357
|
+
* narrative, not just a threshold crossing.
|
|
358
|
+
*/
|
|
359
|
+
async getAnalysisNarrative(
|
|
360
|
+
projectId: string,
|
|
361
|
+
analysisId: string,
|
|
362
|
+
): Promise<{ narrative: string | null; status: string | null } | null> {
|
|
363
|
+
const row = await this.db
|
|
364
|
+
.prepare(
|
|
365
|
+
`SELECT narrative, status FROM analysis_results
|
|
366
|
+
WHERE project_id = ? AND analysis_id = ?
|
|
367
|
+
ORDER BY generated_at DESC LIMIT 1`,
|
|
368
|
+
)
|
|
369
|
+
.bind(projectId, analysisId)
|
|
370
|
+
.first<{ narrative: string | null; status: string | null }>();
|
|
371
|
+
if (!row) return null;
|
|
372
|
+
return { narrative: row.narrative ?? null, status: row.status ?? null };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async evaluateSpanRule(rule: AlertRule): Promise<number> {
|
|
376
|
+
const q = rule.query as AlertQuerySpans;
|
|
377
|
+
const cutoff = windowCutoffIso(rule.windowMins);
|
|
378
|
+
let sql = `SELECT COUNT(*) AS c FROM telemetry_spans WHERE project_id = ? AND received_at >= ?`;
|
|
379
|
+
const binds: unknown[] = [rule.projectId, cutoff];
|
|
380
|
+
if (q.serviceName) {
|
|
381
|
+
sql += ` AND service_name = ?`;
|
|
382
|
+
binds.push(q.serviceName);
|
|
383
|
+
}
|
|
384
|
+
if (q.spanName) {
|
|
385
|
+
sql += ` AND span_name = ?`;
|
|
386
|
+
binds.push(q.spanName);
|
|
387
|
+
}
|
|
388
|
+
if (q.statusCode === "error") sql += ` AND status_code = 2`;
|
|
389
|
+
else if (q.statusCode === "ok") sql += ` AND status_code != 2`;
|
|
390
|
+
const row = await this.db
|
|
391
|
+
.prepare(sql)
|
|
392
|
+
.bind(...binds)
|
|
393
|
+
.first<{ c: number }>();
|
|
394
|
+
return row?.c ?? 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private async evaluateLogRule(rule: AlertRule): Promise<number> {
|
|
398
|
+
const q = rule.query as AlertQueryLogs;
|
|
399
|
+
const cutoff = windowCutoffIso(rule.windowMins);
|
|
400
|
+
let sql = `SELECT COUNT(*) AS c FROM logs WHERE project_id = ? AND received_at >= ?`;
|
|
401
|
+
const binds: unknown[] = [rule.projectId, cutoff];
|
|
402
|
+
if (q.serviceName) {
|
|
403
|
+
sql += ` AND service_name = ?`;
|
|
404
|
+
binds.push(q.serviceName);
|
|
405
|
+
}
|
|
406
|
+
if (q.severity) {
|
|
407
|
+
sql += ` AND severity = ?`;
|
|
408
|
+
binds.push(q.severity as LogSeverity);
|
|
409
|
+
}
|
|
410
|
+
const row = await this.db
|
|
411
|
+
.prepare(sql)
|
|
412
|
+
.bind(...binds)
|
|
413
|
+
.first<{ c: number }>();
|
|
414
|
+
return row?.c ?? 0;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async evaluateUsageRule(rule: AlertRule): Promise<number> {
|
|
418
|
+
const q = rule.query as AlertQueryUsage;
|
|
419
|
+
const cutoff = windowCutoffIso(rule.windowMins);
|
|
420
|
+
// Usage alerts fire on 'frontend_error' events (the error signal on the frontend).
|
|
421
|
+
let sql = `SELECT COUNT(*) AS c FROM usage_events WHERE project_id = ? AND occurred_at >= ? AND event_type = 'frontend_error'`;
|
|
422
|
+
const binds: unknown[] = [rule.projectId, cutoff];
|
|
423
|
+
if (q.eventName) {
|
|
424
|
+
sql += ` AND event_name = ?`;
|
|
425
|
+
binds.push(q.eventName);
|
|
426
|
+
}
|
|
427
|
+
if (q.pathPattern) {
|
|
428
|
+
sql += ` AND page_path LIKE ?`;
|
|
429
|
+
binds.push(q.pathPattern);
|
|
430
|
+
}
|
|
431
|
+
const row = await this.db
|
|
432
|
+
.prepare(sql)
|
|
433
|
+
.bind(...binds)
|
|
434
|
+
.first<{ c: number }>();
|
|
435
|
+
return row?.c ?? 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async evaluateAIRule(rule: AlertRule): Promise<number> {
|
|
439
|
+
const q = rule.query as AlertQueryAI;
|
|
440
|
+
const cutoff = windowCutoffIso(rule.windowMins);
|
|
441
|
+
let sql = `SELECT COUNT(*) AS c FROM ai_calls WHERE project_id = ? AND received_at >= ?`;
|
|
442
|
+
const binds: unknown[] = [rule.projectId, cutoff];
|
|
443
|
+
if (q.provider) {
|
|
444
|
+
sql += ` AND provider = ?`;
|
|
445
|
+
binds.push(q.provider);
|
|
446
|
+
}
|
|
447
|
+
if (q.model) {
|
|
448
|
+
sql += ` AND model_name = ?`;
|
|
449
|
+
binds.push(q.model);
|
|
450
|
+
}
|
|
451
|
+
if (q.isError === true) sql += ` AND is_error = 1`;
|
|
452
|
+
const row = await this.db
|
|
453
|
+
.prepare(sql)
|
|
454
|
+
.bind(...binds)
|
|
455
|
+
.first<{ c: number }>();
|
|
456
|
+
return row?.c ?? 0;
|
|
457
|
+
}
|
|
458
|
+
}
|