@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,642 @@
|
|
|
1
|
+
// RFC 0002 Stage 1 — Tier 1 derivation engine.
|
|
2
|
+
//
|
|
3
|
+
// `deriveAnalysesForProject` queries the project's recent telemetry to infer
|
|
4
|
+
// its `TelemetryShape` (services, cross-service edges, messaging topics,
|
|
5
|
+
// presence of LLM spans) and emits one or more AnalysisDefinitions per
|
|
6
|
+
// detected dimension. The shape query is cached in module-level memory for
|
|
7
|
+
// 5 minutes per project — the derivation cron runs every ~5min so this
|
|
8
|
+
// avoids re-hitting D1 on the dashboard request path.
|
|
9
|
+
//
|
|
10
|
+
// All emitted SQL is executable as-is by the runner; the project id and
|
|
11
|
+
// the relevant scope (service / edge / topic) are baked in at derivation
|
|
12
|
+
// time. Identifiers in analysis ids are slugged (slashes, colons, spaces
|
|
13
|
+
// → underscores) so they're URL-safe.
|
|
14
|
+
|
|
15
|
+
import type { AnalysisDefinition } from "@obsunified/types";
|
|
16
|
+
|
|
17
|
+
interface TelemetryShape {
|
|
18
|
+
services: string[];
|
|
19
|
+
edges: Array<{ source: string; target: string }>;
|
|
20
|
+
messagingTopics: string[];
|
|
21
|
+
hasLlmSpans: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CachedShape {
|
|
25
|
+
shape: TelemetryShape;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SHAPE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
const SHAPE_CACHE_MAX_ENTRIES = 200;
|
|
31
|
+
const shapeCache: Map<string, CachedShape> = new Map();
|
|
32
|
+
|
|
33
|
+
const slug = (value: string): string =>
|
|
34
|
+
value.replace(/[/:\s]+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
35
|
+
|
|
36
|
+
const sqlEscape = (value: string): string => value.replace(/'/g, "''");
|
|
37
|
+
|
|
38
|
+
interface D1Result<T = Record<string, unknown>> {
|
|
39
|
+
results: T[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface D1PreparedStatement {
|
|
43
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
44
|
+
all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface D1Database {
|
|
48
|
+
prepare(query: string): D1PreparedStatement;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fetchShape = async (
|
|
52
|
+
projectId: string,
|
|
53
|
+
db: D1Database,
|
|
54
|
+
): Promise<TelemetryShape> => {
|
|
55
|
+
// Distinct services in the last hour.
|
|
56
|
+
const servicesRes = await db
|
|
57
|
+
.prepare(
|
|
58
|
+
`SELECT DISTINCT service_name
|
|
59
|
+
FROM telemetry_spans
|
|
60
|
+
WHERE project_id = ?
|
|
61
|
+
AND service_name IS NOT NULL
|
|
62
|
+
AND received_at >= datetime('now', '-1 hour')`,
|
|
63
|
+
)
|
|
64
|
+
.bind(projectId)
|
|
65
|
+
.all<{ service_name: string }>();
|
|
66
|
+
|
|
67
|
+
const services = servicesRes.results
|
|
68
|
+
.map((row) => row.service_name)
|
|
69
|
+
.filter(
|
|
70
|
+
(name): name is string => typeof name === "string" && name.length > 0,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Cross-service edges: parent span's service != child span's service,
|
|
74
|
+
// within the same trace, in the last hour. We only keep distinct
|
|
75
|
+
// (parent_service, child_service) pairs.
|
|
76
|
+
const edgesRes = await db
|
|
77
|
+
.prepare(
|
|
78
|
+
`SELECT DISTINCT parent.service_name AS source_service,
|
|
79
|
+
child.service_name AS target_service
|
|
80
|
+
FROM telemetry_spans child
|
|
81
|
+
JOIN telemetry_spans parent
|
|
82
|
+
ON parent.project_id = child.project_id
|
|
83
|
+
AND parent.trace_id = child.trace_id
|
|
84
|
+
AND parent.span_id = child.parent_span_id
|
|
85
|
+
WHERE child.project_id = ?
|
|
86
|
+
AND child.received_at >= datetime('now', '-1 hour')
|
|
87
|
+
AND parent.service_name IS NOT NULL
|
|
88
|
+
AND child.service_name IS NOT NULL
|
|
89
|
+
AND parent.service_name <> child.service_name`,
|
|
90
|
+
)
|
|
91
|
+
.bind(projectId)
|
|
92
|
+
.all<{ source_service: string; target_service: string }>();
|
|
93
|
+
|
|
94
|
+
const edges = edgesRes.results
|
|
95
|
+
.filter((row) => row.source_service && row.target_service)
|
|
96
|
+
.map((row) => ({ source: row.source_service, target: row.target_service }));
|
|
97
|
+
|
|
98
|
+
// Messaging topics: spans that carry a messaging.destination attribute.
|
|
99
|
+
const topicsRes = await db
|
|
100
|
+
.prepare(
|
|
101
|
+
`SELECT DISTINCT json_extract(attributes_json, '$."messaging.destination"') AS topic
|
|
102
|
+
FROM telemetry_spans
|
|
103
|
+
WHERE project_id = ?
|
|
104
|
+
AND received_at >= datetime('now', '-1 hour')
|
|
105
|
+
AND json_extract(attributes_json, '$."messaging.destination"') IS NOT NULL`,
|
|
106
|
+
)
|
|
107
|
+
.bind(projectId)
|
|
108
|
+
.all<{ topic: string }>();
|
|
109
|
+
|
|
110
|
+
const messagingTopics = topicsRes.results
|
|
111
|
+
.map((row) => row.topic)
|
|
112
|
+
.filter(
|
|
113
|
+
(topic): topic is string => typeof topic === "string" && topic.length > 0,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Are there any LLM-kind spans? OpenInference uses
|
|
117
|
+
// `openinference.span.kind` = 'LLM'. We also accept the more general
|
|
118
|
+
// `gen_ai.system` attribute as a heuristic.
|
|
119
|
+
const llmRes = await db
|
|
120
|
+
.prepare(
|
|
121
|
+
`SELECT 1
|
|
122
|
+
FROM telemetry_spans
|
|
123
|
+
WHERE project_id = ?
|
|
124
|
+
AND received_at >= datetime('now', '-1 hour')
|
|
125
|
+
AND (
|
|
126
|
+
json_extract(attributes_json, '$."openinference.span.kind"') = 'LLM'
|
|
127
|
+
OR json_extract(attributes_json, '$."gen_ai.system"') IS NOT NULL
|
|
128
|
+
)
|
|
129
|
+
LIMIT 1`,
|
|
130
|
+
)
|
|
131
|
+
.bind(projectId)
|
|
132
|
+
.all<{ "1": number }>();
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
services,
|
|
136
|
+
edges,
|
|
137
|
+
messagingTopics,
|
|
138
|
+
hasLlmSpans: llmRes.results.length > 0,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const getShape = async (
|
|
143
|
+
projectId: string,
|
|
144
|
+
db: D1Database,
|
|
145
|
+
): Promise<TelemetryShape> => {
|
|
146
|
+
const cached = shapeCache.get(projectId);
|
|
147
|
+
const nowMs = Date.now();
|
|
148
|
+
if (cached && cached.expiresAt > nowMs) return cached.shape;
|
|
149
|
+
|
|
150
|
+
const shape = await fetchShape(projectId, db);
|
|
151
|
+
pruneShapeCache(nowMs);
|
|
152
|
+
shapeCache.set(projectId, {
|
|
153
|
+
shape,
|
|
154
|
+
expiresAt: nowMs + SHAPE_CACHE_TTL_MS,
|
|
155
|
+
});
|
|
156
|
+
return shape;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
function pruneShapeCache(nowMs: number): void {
|
|
160
|
+
for (const [key, entry] of shapeCache) {
|
|
161
|
+
if (entry.expiresAt <= nowMs) shapeCache.delete(key);
|
|
162
|
+
}
|
|
163
|
+
while (shapeCache.size >= SHAPE_CACHE_MAX_ENTRIES) {
|
|
164
|
+
const oldestKey = shapeCache.keys().next().value;
|
|
165
|
+
if (!oldestKey) break;
|
|
166
|
+
shapeCache.delete(oldestKey);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Builders ────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
const makeServiceErrorRate = (
|
|
173
|
+
projectId: string,
|
|
174
|
+
service: string,
|
|
175
|
+
): AnalysisDefinition => ({
|
|
176
|
+
id: `service_error_rate::${slug(service)}`,
|
|
177
|
+
title: `${service} — error rate`,
|
|
178
|
+
group: "Services",
|
|
179
|
+
source: "tier1",
|
|
180
|
+
view: "tile",
|
|
181
|
+
refreshSeconds: 60,
|
|
182
|
+
scope: { service },
|
|
183
|
+
narrate: {
|
|
184
|
+
prompt: `${service} error rate moved to {{primary}} (was {{baseline}}, {{delta_pct}}). If the payload names the dominant span/route, call it out. Time anchor.`,
|
|
185
|
+
only_when: "status_changed || delta_pct>25",
|
|
186
|
+
},
|
|
187
|
+
// Last 5 minutes vs trailing 1h baseline (excluding the current 5m).
|
|
188
|
+
// Status thresholds match overall_error_rate: critical>5%, warn>1%.
|
|
189
|
+
sql: `
|
|
190
|
+
WITH now_window AS (
|
|
191
|
+
SELECT
|
|
192
|
+
COUNT(*) AS total,
|
|
193
|
+
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
|
|
194
|
+
FROM telemetry_spans
|
|
195
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
196
|
+
AND service_name = '${sqlEscape(service)}'
|
|
197
|
+
AND received_at >= datetime('now', '-5 minutes')
|
|
198
|
+
),
|
|
199
|
+
base_window AS (
|
|
200
|
+
SELECT
|
|
201
|
+
COUNT(*) AS total,
|
|
202
|
+
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
|
|
203
|
+
FROM telemetry_spans
|
|
204
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
205
|
+
AND service_name = '${sqlEscape(service)}'
|
|
206
|
+
AND received_at >= datetime('now', '-1 hour')
|
|
207
|
+
AND received_at < datetime('now', '-5 minutes')
|
|
208
|
+
),
|
|
209
|
+
spark AS (
|
|
210
|
+
SELECT
|
|
211
|
+
strftime('%Y-%m-%dT%H:%M:00Z', received_at) AS minute,
|
|
212
|
+
COUNT(*) AS total,
|
|
213
|
+
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
|
|
214
|
+
FROM telemetry_spans
|
|
215
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
216
|
+
AND service_name = '${sqlEscape(service)}'
|
|
217
|
+
AND received_at >= datetime('now', '-30 minutes')
|
|
218
|
+
GROUP BY minute
|
|
219
|
+
ORDER BY minute ASC
|
|
220
|
+
)
|
|
221
|
+
SELECT
|
|
222
|
+
CASE
|
|
223
|
+
WHEN (SELECT total FROM now_window) = 0 THEN 'unknown'
|
|
224
|
+
WHEN (CAST((SELECT errors FROM now_window) AS REAL)
|
|
225
|
+
/ (SELECT total FROM now_window)) > 0.05 THEN 'critical'
|
|
226
|
+
WHEN (CAST((SELECT errors FROM now_window) AS REAL)
|
|
227
|
+
/ (SELECT total FROM now_window)) > 0.01 THEN 'warn'
|
|
228
|
+
ELSE 'ok'
|
|
229
|
+
END AS status,
|
|
230
|
+
CASE WHEN (SELECT total FROM now_window) = 0 THEN NULL
|
|
231
|
+
ELSE CAST((SELECT errors FROM now_window) AS REAL)
|
|
232
|
+
/ (SELECT total FROM now_window) END AS primary_value,
|
|
233
|
+
CASE WHEN (SELECT total FROM base_window) = 0 THEN NULL
|
|
234
|
+
ELSE CAST((SELECT errors FROM base_window) AS REAL)
|
|
235
|
+
/ (SELECT total FROM base_window) END AS baseline_value,
|
|
236
|
+
(SELECT json_object(
|
|
237
|
+
'service', '${sqlEscape(service)}',
|
|
238
|
+
'window', '5m',
|
|
239
|
+
'baselineWindow', '1h',
|
|
240
|
+
'currentErrors', (SELECT errors FROM now_window),
|
|
241
|
+
'currentTotal', (SELECT total FROM now_window),
|
|
242
|
+
'baselineErrors',(SELECT errors FROM base_window),
|
|
243
|
+
'baselineTotal', (SELECT total FROM base_window),
|
|
244
|
+
'sparkline', (
|
|
245
|
+
SELECT json_group_array(json_object(
|
|
246
|
+
'minute', minute,
|
|
247
|
+
'total', total,
|
|
248
|
+
'errors', errors,
|
|
249
|
+
'rate', CASE WHEN total = 0 THEN 0
|
|
250
|
+
ELSE CAST(errors AS REAL) / total END
|
|
251
|
+
)) FROM spark
|
|
252
|
+
)
|
|
253
|
+
)) AS payload
|
|
254
|
+
`,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const makeServiceLatencyP95 = (
|
|
258
|
+
projectId: string,
|
|
259
|
+
service: string,
|
|
260
|
+
): AnalysisDefinition => ({
|
|
261
|
+
id: `service_latency_p95::${slug(service)}`,
|
|
262
|
+
title: `${service} — p95 latency`,
|
|
263
|
+
group: "Services",
|
|
264
|
+
source: "tier1",
|
|
265
|
+
view: "tile",
|
|
266
|
+
refreshSeconds: 60,
|
|
267
|
+
scope: { service },
|
|
268
|
+
narrate: {
|
|
269
|
+
prompt: `${service} p95 moved to {{primary}}ms from {{baseline}}ms ({{delta_pct}}). Name the span pattern dominating the tail if visible. Time anchor.`,
|
|
270
|
+
only_when: "status_changed || delta_pct>30",
|
|
271
|
+
},
|
|
272
|
+
// p95 of duration_ms over the last hour vs. same hour yesterday.
|
|
273
|
+
// Approximate via ROW_NUMBER (no native percentile in SQLite).
|
|
274
|
+
// Status by delta_pct: critical>50%, warn>20%, ok else.
|
|
275
|
+
sql: `
|
|
276
|
+
WITH cur AS (
|
|
277
|
+
SELECT duration_ms,
|
|
278
|
+
ROW_NUMBER() OVER (ORDER BY duration_ms ASC) AS rn,
|
|
279
|
+
COUNT(*) OVER () AS n
|
|
280
|
+
FROM telemetry_spans
|
|
281
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
282
|
+
AND service_name = '${sqlEscape(service)}'
|
|
283
|
+
AND received_at >= datetime('now', '-1 hour')
|
|
284
|
+
),
|
|
285
|
+
base AS (
|
|
286
|
+
SELECT duration_ms,
|
|
287
|
+
ROW_NUMBER() OVER (ORDER BY duration_ms ASC) AS rn,
|
|
288
|
+
COUNT(*) OVER () AS n
|
|
289
|
+
FROM telemetry_spans
|
|
290
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
291
|
+
AND service_name = '${sqlEscape(service)}'
|
|
292
|
+
AND received_at >= datetime('now', '-25 hours')
|
|
293
|
+
AND received_at < datetime('now', '-24 hours')
|
|
294
|
+
),
|
|
295
|
+
cur_p95 AS (
|
|
296
|
+
SELECT duration_ms FROM cur
|
|
297
|
+
WHERE rn = MAX(1, (95 * n + 99) / 100)
|
|
298
|
+
LIMIT 1
|
|
299
|
+
),
|
|
300
|
+
base_p95 AS (
|
|
301
|
+
SELECT duration_ms FROM base
|
|
302
|
+
WHERE rn = MAX(1, (95 * n + 99) / 100)
|
|
303
|
+
LIMIT 1
|
|
304
|
+
),
|
|
305
|
+
cur_val AS (SELECT (SELECT duration_ms FROM cur_p95) AS v),
|
|
306
|
+
base_val AS (SELECT (SELECT duration_ms FROM base_p95) AS v),
|
|
307
|
+
delta AS (
|
|
308
|
+
SELECT CASE
|
|
309
|
+
WHEN (SELECT v FROM cur_val) IS NULL THEN NULL
|
|
310
|
+
WHEN (SELECT v FROM base_val) IS NULL OR (SELECT v FROM base_val) = 0 THEN NULL
|
|
311
|
+
ELSE ((SELECT v FROM cur_val) - (SELECT v FROM base_val))
|
|
312
|
+
* 100.0 / (SELECT v FROM base_val)
|
|
313
|
+
END AS pct
|
|
314
|
+
)
|
|
315
|
+
SELECT
|
|
316
|
+
CASE
|
|
317
|
+
WHEN (SELECT v FROM cur_val) IS NULL THEN 'unknown'
|
|
318
|
+
WHEN (SELECT pct FROM delta) IS NULL THEN 'ok'
|
|
319
|
+
WHEN ABS((SELECT pct FROM delta)) > 50 THEN 'critical'
|
|
320
|
+
WHEN ABS((SELECT pct FROM delta)) > 20 THEN 'warn'
|
|
321
|
+
ELSE 'ok'
|
|
322
|
+
END AS status,
|
|
323
|
+
(SELECT v FROM cur_val) AS primary_value,
|
|
324
|
+
(SELECT v FROM base_val) AS baseline_value,
|
|
325
|
+
(SELECT json_object(
|
|
326
|
+
'service', '${sqlEscape(service)}',
|
|
327
|
+
'window', '1h',
|
|
328
|
+
'baselineWindow', 'same hour yesterday',
|
|
329
|
+
'unit', 'ms'
|
|
330
|
+
)) AS payload
|
|
331
|
+
`,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const makeDependencyHealth = (
|
|
335
|
+
projectId: string,
|
|
336
|
+
source: string,
|
|
337
|
+
target: string,
|
|
338
|
+
): AnalysisDefinition => ({
|
|
339
|
+
id: `dependency_health::${slug(source)}->${slug(target)}`,
|
|
340
|
+
title: `${source} → ${target}`,
|
|
341
|
+
group: "Dependencies",
|
|
342
|
+
source: "tier1",
|
|
343
|
+
view: "tile",
|
|
344
|
+
// Dependency edges change shape slowly; sampling every 5 min keeps the
|
|
345
|
+
// per-minute analyses tick cheap on installs with many edges.
|
|
346
|
+
refreshSeconds: 300,
|
|
347
|
+
scope: { source, target },
|
|
348
|
+
// Error rate on the child-side spans of the edge over the last 5 minutes
|
|
349
|
+
// vs trailing 1h baseline. We identify edges by joining child↔parent on
|
|
350
|
+
// trace_id+parent_span_id where parent.service_name = source and
|
|
351
|
+
// child.service_name = target.
|
|
352
|
+
sql: `
|
|
353
|
+
WITH edge_now AS (
|
|
354
|
+
SELECT child.status_code AS sc
|
|
355
|
+
FROM telemetry_spans child
|
|
356
|
+
JOIN telemetry_spans parent
|
|
357
|
+
ON parent.project_id = child.project_id
|
|
358
|
+
AND parent.trace_id = child.trace_id
|
|
359
|
+
AND parent.span_id = child.parent_span_id
|
|
360
|
+
WHERE child.project_id = '${sqlEscape(projectId)}'
|
|
361
|
+
AND parent.service_name = '${sqlEscape(source)}'
|
|
362
|
+
AND child.service_name = '${sqlEscape(target)}'
|
|
363
|
+
AND child.received_at >= datetime('now', '-5 minutes')
|
|
364
|
+
),
|
|
365
|
+
edge_base AS (
|
|
366
|
+
SELECT child.status_code AS sc
|
|
367
|
+
FROM telemetry_spans child
|
|
368
|
+
JOIN telemetry_spans parent
|
|
369
|
+
ON parent.project_id = child.project_id
|
|
370
|
+
AND parent.trace_id = child.trace_id
|
|
371
|
+
AND parent.span_id = child.parent_span_id
|
|
372
|
+
WHERE child.project_id = '${sqlEscape(projectId)}'
|
|
373
|
+
AND parent.service_name = '${sqlEscape(source)}'
|
|
374
|
+
AND child.service_name = '${sqlEscape(target)}'
|
|
375
|
+
AND child.received_at >= datetime('now', '-1 hour')
|
|
376
|
+
AND child.received_at < datetime('now', '-5 minutes')
|
|
377
|
+
),
|
|
378
|
+
now_agg AS (
|
|
379
|
+
SELECT COUNT(*) AS total,
|
|
380
|
+
SUM(CASE WHEN sc = 2 THEN 1 ELSE 0 END) AS errors
|
|
381
|
+
FROM edge_now
|
|
382
|
+
),
|
|
383
|
+
base_agg AS (
|
|
384
|
+
SELECT COUNT(*) AS total,
|
|
385
|
+
SUM(CASE WHEN sc = 2 THEN 1 ELSE 0 END) AS errors
|
|
386
|
+
FROM edge_base
|
|
387
|
+
)
|
|
388
|
+
SELECT
|
|
389
|
+
CASE
|
|
390
|
+
WHEN (SELECT total FROM now_agg) = 0 THEN 'unknown'
|
|
391
|
+
WHEN (CAST((SELECT errors FROM now_agg) AS REAL)
|
|
392
|
+
/ (SELECT total FROM now_agg)) > 0.05 THEN 'critical'
|
|
393
|
+
WHEN (CAST((SELECT errors FROM now_agg) AS REAL)
|
|
394
|
+
/ (SELECT total FROM now_agg)) > 0.01 THEN 'warn'
|
|
395
|
+
ELSE 'ok'
|
|
396
|
+
END AS status,
|
|
397
|
+
CASE WHEN (SELECT total FROM now_agg) = 0 THEN NULL
|
|
398
|
+
ELSE CAST((SELECT errors FROM now_agg) AS REAL)
|
|
399
|
+
/ (SELECT total FROM now_agg) END AS primary_value,
|
|
400
|
+
CASE WHEN (SELECT total FROM base_agg) = 0 THEN NULL
|
|
401
|
+
ELSE CAST((SELECT errors FROM base_agg) AS REAL)
|
|
402
|
+
/ (SELECT total FROM base_agg) END AS baseline_value,
|
|
403
|
+
(SELECT json_object(
|
|
404
|
+
'source', '${sqlEscape(source)}',
|
|
405
|
+
'target', '${sqlEscape(target)}',
|
|
406
|
+
'window', '5m',
|
|
407
|
+
'baselineWindow', '1h',
|
|
408
|
+
'currentTotal', (SELECT total FROM now_agg),
|
|
409
|
+
'currentErrors', (SELECT errors FROM now_agg),
|
|
410
|
+
'baselineTotal', (SELECT total FROM base_agg),
|
|
411
|
+
'baselineErrors',(SELECT errors FROM base_agg)
|
|
412
|
+
)) AS payload
|
|
413
|
+
`,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const makeMessagingLag = (
|
|
417
|
+
projectId: string,
|
|
418
|
+
topic: string,
|
|
419
|
+
): AnalysisDefinition => ({
|
|
420
|
+
id: `messaging_lag::${slug(topic)}`,
|
|
421
|
+
title: `${topic} — messaging lag`,
|
|
422
|
+
group: "Async",
|
|
423
|
+
source: "tier1",
|
|
424
|
+
view: "tile",
|
|
425
|
+
refreshSeconds: 60,
|
|
426
|
+
scope: { topic },
|
|
427
|
+
// Producer span_kind = 4, Consumer span_kind = 5 (OTel SpanKind enum).
|
|
428
|
+
// We approximate lag as the time gap between producer end_time and the
|
|
429
|
+
// matching consumer start_time on the same trace_id. Spans without a
|
|
430
|
+
// matching counterpart are excluded. Status thresholds in seconds:
|
|
431
|
+
// critical>30s, warn>5s.
|
|
432
|
+
sql: `
|
|
433
|
+
WITH producers AS (
|
|
434
|
+
SELECT trace_id,
|
|
435
|
+
end_time,
|
|
436
|
+
json_extract(attributes_json, '$."messaging.destination"') AS topic
|
|
437
|
+
FROM telemetry_spans
|
|
438
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
439
|
+
AND span_kind = 4
|
|
440
|
+
AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
|
|
441
|
+
AND received_at >= datetime('now', '-15 minutes')
|
|
442
|
+
),
|
|
443
|
+
consumers AS (
|
|
444
|
+
SELECT trace_id,
|
|
445
|
+
start_time,
|
|
446
|
+
json_extract(attributes_json, '$."messaging.destination"') AS topic
|
|
447
|
+
FROM telemetry_spans
|
|
448
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
449
|
+
AND span_kind = 5
|
|
450
|
+
AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
|
|
451
|
+
AND received_at >= datetime('now', '-15 minutes')
|
|
452
|
+
),
|
|
453
|
+
gaps AS (
|
|
454
|
+
SELECT
|
|
455
|
+
(julianday(c.start_time) - julianday(p.end_time)) * 86400.0 AS lag_seconds
|
|
456
|
+
FROM producers p
|
|
457
|
+
JOIN consumers c USING (trace_id)
|
|
458
|
+
WHERE c.start_time >= p.end_time
|
|
459
|
+
),
|
|
460
|
+
ordered AS (
|
|
461
|
+
SELECT lag_seconds,
|
|
462
|
+
ROW_NUMBER() OVER (ORDER BY lag_seconds ASC) AS rn,
|
|
463
|
+
COUNT(*) OVER () AS n
|
|
464
|
+
FROM gaps
|
|
465
|
+
),
|
|
466
|
+
p95_row AS (
|
|
467
|
+
SELECT lag_seconds FROM ordered
|
|
468
|
+
WHERE rn = MAX(1, (95 * n + 99) / 100)
|
|
469
|
+
LIMIT 1
|
|
470
|
+
),
|
|
471
|
+
stats AS (
|
|
472
|
+
SELECT
|
|
473
|
+
(SELECT COUNT(*) FROM gaps) AS pairs,
|
|
474
|
+
(SELECT AVG(lag_seconds) FROM gaps) AS avg_lag,
|
|
475
|
+
(SELECT lag_seconds FROM p95_row) AS p95_lag,
|
|
476
|
+
(SELECT MAX(lag_seconds) FROM gaps) AS max_lag
|
|
477
|
+
)
|
|
478
|
+
SELECT
|
|
479
|
+
CASE
|
|
480
|
+
WHEN (SELECT pairs FROM stats) = 0 THEN 'unknown'
|
|
481
|
+
WHEN COALESCE((SELECT p95_lag FROM stats), 0) > 30 THEN 'critical'
|
|
482
|
+
WHEN COALESCE((SELECT p95_lag FROM stats), 0) > 5 THEN 'warn'
|
|
483
|
+
ELSE 'ok'
|
|
484
|
+
END AS status,
|
|
485
|
+
(SELECT p95_lag FROM stats) AS primary_value,
|
|
486
|
+
(SELECT avg_lag FROM stats) AS baseline_value,
|
|
487
|
+
(SELECT json_object(
|
|
488
|
+
'topic', '${sqlEscape(topic)}',
|
|
489
|
+
'unit', 's',
|
|
490
|
+
'window', '15m',
|
|
491
|
+
'pairs', (SELECT pairs FROM stats),
|
|
492
|
+
'avgLag', (SELECT avg_lag FROM stats),
|
|
493
|
+
'p95Lag', (SELECT p95_lag FROM stats),
|
|
494
|
+
'maxLag', (SELECT max_lag FROM stats)
|
|
495
|
+
)) AS payload
|
|
496
|
+
`,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const makeAiCostBurn = (projectId: string): AnalysisDefinition => ({
|
|
500
|
+
id: "ai_cost_burn",
|
|
501
|
+
title: "AI cost burn",
|
|
502
|
+
group: "AI",
|
|
503
|
+
source: "tier1",
|
|
504
|
+
view: "tile",
|
|
505
|
+
// Cost moves slowly compared to error rates; 5 min cadence keeps the
|
|
506
|
+
// per-minute tick cheap.
|
|
507
|
+
refreshSeconds: 300,
|
|
508
|
+
// USD spent in the last hour vs the prior hour, summed from ai_calls.
|
|
509
|
+
// Status by relative delta: critical>50%, warn>20%. unknown if no cost
|
|
510
|
+
// signal at all.
|
|
511
|
+
sql: `
|
|
512
|
+
WITH cur AS (
|
|
513
|
+
SELECT COALESCE(SUM(total_cost_usd), 0) AS cost,
|
|
514
|
+
COUNT(*) AS n
|
|
515
|
+
FROM ai_calls
|
|
516
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
517
|
+
AND received_at >= datetime('now', '-1 hour')
|
|
518
|
+
),
|
|
519
|
+
prev AS (
|
|
520
|
+
SELECT COALESCE(SUM(total_cost_usd), 0) AS cost,
|
|
521
|
+
COUNT(*) AS n
|
|
522
|
+
FROM ai_calls
|
|
523
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
524
|
+
AND received_at >= datetime('now', '-2 hours')
|
|
525
|
+
AND received_at < datetime('now', '-1 hour')
|
|
526
|
+
),
|
|
527
|
+
delta AS (
|
|
528
|
+
SELECT CASE
|
|
529
|
+
WHEN (SELECT cost FROM prev) = 0 THEN NULL
|
|
530
|
+
ELSE ((SELECT cost FROM cur) - (SELECT cost FROM prev))
|
|
531
|
+
* 100.0 / (SELECT cost FROM prev)
|
|
532
|
+
END AS pct
|
|
533
|
+
)
|
|
534
|
+
SELECT
|
|
535
|
+
CASE
|
|
536
|
+
WHEN (SELECT n FROM cur) = 0 AND (SELECT n FROM prev) = 0 THEN 'unknown'
|
|
537
|
+
WHEN (SELECT pct FROM delta) IS NULL THEN 'ok'
|
|
538
|
+
WHEN (SELECT pct FROM delta) > 50 THEN 'critical'
|
|
539
|
+
WHEN (SELECT pct FROM delta) > 20 THEN 'warn'
|
|
540
|
+
ELSE 'ok'
|
|
541
|
+
END AS status,
|
|
542
|
+
(SELECT cost FROM cur) AS primary_value,
|
|
543
|
+
(SELECT cost FROM prev) AS baseline_value,
|
|
544
|
+
(SELECT json_object(
|
|
545
|
+
'unit', 'usd',
|
|
546
|
+
'window', '1h',
|
|
547
|
+
'baselineWindow', 'prior 1h',
|
|
548
|
+
'currentCalls', (SELECT n FROM cur),
|
|
549
|
+
'baselineCalls', (SELECT n FROM prev)
|
|
550
|
+
)) AS payload
|
|
551
|
+
`,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const makeAiErrorRate = (projectId: string): AnalysisDefinition => ({
|
|
555
|
+
id: "ai_error_rate",
|
|
556
|
+
title: "AI error rate",
|
|
557
|
+
group: "AI",
|
|
558
|
+
source: "tier1",
|
|
559
|
+
view: "tile",
|
|
560
|
+
// AI error rate is per-call; 5 min cadence balances signal vs cost on
|
|
561
|
+
// installs with hundreds of LLM spans.
|
|
562
|
+
refreshSeconds: 300,
|
|
563
|
+
// is_error = 1 fraction in the last 5 minutes vs trailing 1h baseline.
|
|
564
|
+
// Same thresholds as overall_error_rate.
|
|
565
|
+
sql: `
|
|
566
|
+
WITH now_window AS (
|
|
567
|
+
SELECT
|
|
568
|
+
COUNT(*) AS total,
|
|
569
|
+
SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) AS errors
|
|
570
|
+
FROM ai_calls
|
|
571
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
572
|
+
AND received_at >= datetime('now', '-5 minutes')
|
|
573
|
+
),
|
|
574
|
+
base_window AS (
|
|
575
|
+
SELECT
|
|
576
|
+
COUNT(*) AS total,
|
|
577
|
+
SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) AS errors
|
|
578
|
+
FROM ai_calls
|
|
579
|
+
WHERE project_id = '${sqlEscape(projectId)}'
|
|
580
|
+
AND received_at >= datetime('now', '-1 hour')
|
|
581
|
+
AND received_at < datetime('now', '-5 minutes')
|
|
582
|
+
)
|
|
583
|
+
SELECT
|
|
584
|
+
CASE
|
|
585
|
+
WHEN (SELECT total FROM now_window) = 0 THEN 'unknown'
|
|
586
|
+
WHEN (CAST((SELECT errors FROM now_window) AS REAL)
|
|
587
|
+
/ (SELECT total FROM now_window)) > 0.05 THEN 'critical'
|
|
588
|
+
WHEN (CAST((SELECT errors FROM now_window) AS REAL)
|
|
589
|
+
/ (SELECT total FROM now_window)) > 0.01 THEN 'warn'
|
|
590
|
+
ELSE 'ok'
|
|
591
|
+
END AS status,
|
|
592
|
+
CASE WHEN (SELECT total FROM now_window) = 0 THEN NULL
|
|
593
|
+
ELSE CAST((SELECT errors FROM now_window) AS REAL)
|
|
594
|
+
/ (SELECT total FROM now_window) END AS primary_value,
|
|
595
|
+
CASE WHEN (SELECT total FROM base_window) = 0 THEN NULL
|
|
596
|
+
ELSE CAST((SELECT errors FROM base_window) AS REAL)
|
|
597
|
+
/ (SELECT total FROM base_window) END AS baseline_value,
|
|
598
|
+
(SELECT json_object(
|
|
599
|
+
'window', '5m',
|
|
600
|
+
'baselineWindow', '1h',
|
|
601
|
+
'currentErrors', (SELECT errors FROM now_window),
|
|
602
|
+
'currentTotal', (SELECT total FROM now_window),
|
|
603
|
+
'baselineErrors',(SELECT errors FROM base_window),
|
|
604
|
+
'baselineTotal', (SELECT total FROM base_window)
|
|
605
|
+
)) AS payload
|
|
606
|
+
`,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
/** Test hook — clears the per-project shape cache. */
|
|
612
|
+
export const __resetShapeCache = (): void => {
|
|
613
|
+
shapeCache.clear();
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
export const deriveAnalysesForProject = async (
|
|
617
|
+
projectId: string,
|
|
618
|
+
db: D1Database,
|
|
619
|
+
): Promise<AnalysisDefinition[]> => {
|
|
620
|
+
const shape = await getShape(projectId, db);
|
|
621
|
+
const out: AnalysisDefinition[] = [];
|
|
622
|
+
|
|
623
|
+
for (const service of shape.services) {
|
|
624
|
+
out.push(makeServiceErrorRate(projectId, service));
|
|
625
|
+
out.push(makeServiceLatencyP95(projectId, service));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
for (const edge of shape.edges) {
|
|
629
|
+
out.push(makeDependencyHealth(projectId, edge.source, edge.target));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for (const topic of shape.messagingTopics) {
|
|
633
|
+
out.push(makeMessagingLag(projectId, topic));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (shape.hasLlmSpans) {
|
|
637
|
+
out.push(makeAiCostBurn(projectId));
|
|
638
|
+
out.push(makeAiErrorRate(projectId));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return out;
|
|
642
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// RFC 0002 Stage 1 — Analysis catalog entry point.
|
|
2
|
+
//
|
|
3
|
+
// Combines static Tier 0 (universal) analyses with dynamically derived
|
|
4
|
+
// Tier 1 analyses. Agent 1's runner imports `getAllAnalysesForProject`
|
|
5
|
+
// to pick up the active set for a project on each scheduler tick. Tier 2
|
|
6
|
+
// (user-defined / LLM-suggested) analyses live in `analysis_definitions`
|
|
7
|
+
// directly and are merged in by the runner, not here.
|
|
8
|
+
//
|
|
9
|
+
// Stage 6 augments the merge with auto-pinning: the top analyses cited
|
|
10
|
+
// by the Ask box over the past week get a `pinned: true` flag the
|
|
11
|
+
// dashboard groups separately at the top of the Health tab.
|
|
12
|
+
|
|
13
|
+
import type { AnalysisDefinition } from "@obsunified/types";
|
|
14
|
+
|
|
15
|
+
import { AnalysesStore } from "../lib/analyses-store";
|
|
16
|
+
import type { SqlDb } from "../lib/sql-db";
|
|
17
|
+
import { deriveAnalysesForProject } from "./derive";
|
|
18
|
+
import { INVESTIGATION_ANALYSES } from "./investigations";
|
|
19
|
+
import { TIER0_ANALYSES } from "./tier0";
|
|
20
|
+
|
|
21
|
+
export interface AnalysisContext {
|
|
22
|
+
db: SqlDb;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const getAllAnalysesForProject = async (
|
|
26
|
+
projectId: string,
|
|
27
|
+
ctx: AnalysisContext,
|
|
28
|
+
): Promise<AnalysisDefinition[]> => {
|
|
29
|
+
const tier1 = await deriveAnalysesForProject(projectId, ctx.db);
|
|
30
|
+
const all = [...TIER0_ANALYSES, ...INVESTIGATION_ANALYSES, ...tier1];
|
|
31
|
+
|
|
32
|
+
// Stage 6 — fold in the auto-pinned set. We tolerate failures on the
|
|
33
|
+
// pin lookup (e.g. fresh install before migration 025) so registry
|
|
34
|
+
// loading never breaks because the optional signal is missing.
|
|
35
|
+
let pinnedIds = new Set<string>();
|
|
36
|
+
try {
|
|
37
|
+
const store = new AnalysesStore(ctx.db);
|
|
38
|
+
const top = await store.getTopAskedAnalyses(projectId);
|
|
39
|
+
pinnedIds = new Set(top.map((t) => t.analysisId));
|
|
40
|
+
} catch {
|
|
41
|
+
// no-op
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (pinnedIds.size === 0) return all;
|
|
45
|
+
return all.map((d) => (pinnedIds.has(d.id) ? { ...d, pinned: true } : d));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { deriveAnalysesForProject } from "./derive";
|
|
49
|
+
export { INVESTIGATION_ANALYSES } from "./investigations";
|
|
50
|
+
export { TIER0_ANALYSES } from "./tier0";
|