@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,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0002 Stage 1 — runner that executes SQL-only Analyses on the existing
|
|
3
|
+
* scheduled handler.
|
|
4
|
+
*
|
|
5
|
+
* runSqlAnalysis — execute one definition's `sql` against D1 and shape the
|
|
6
|
+
* row(s) into an AnalysisResult.
|
|
7
|
+
* runAllDueAnalyses — orchestrator: refresh the in-process registry into
|
|
8
|
+
* analysis_definitions, find which ones are due, run them,
|
|
9
|
+
* persist the results. Per-analysis errors are isolated
|
|
10
|
+
* via Promise.allSettled so one bad SQL doesn't kill the
|
|
11
|
+
* batch.
|
|
12
|
+
*
|
|
13
|
+
* Result shape convention: an Analysis SQL is expected to return a single
|
|
14
|
+
* row with the columns:
|
|
15
|
+
*
|
|
16
|
+
* status TEXT | NULL -- 'ok' | 'warn' | 'critical' | 'unknown'
|
|
17
|
+
* primary_value REAL | NULL
|
|
18
|
+
* baseline_value REAL | NULL
|
|
19
|
+
* payload TEXT | NULL -- JSON-serialized object, optional
|
|
20
|
+
*
|
|
21
|
+
* If `status` is NULL we derive it from the magnitude of delta_pct
|
|
22
|
+
* (>=25% → critical, >=10% → warn, else ok). delta_pct itself is computed
|
|
23
|
+
* here from primary/baseline so individual SQL queries don't have to.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
AnalysisDefinition,
|
|
28
|
+
AnalysisResult,
|
|
29
|
+
AnalysisStatus,
|
|
30
|
+
} from "@obsunified/types";
|
|
31
|
+
import { getAllAnalysesForProject } from "../analyses/index";
|
|
32
|
+
import type { CollectorEnv } from "../framework/env";
|
|
33
|
+
import {
|
|
34
|
+
type ChildSpanRunner,
|
|
35
|
+
consoleLogger,
|
|
36
|
+
type Logger,
|
|
37
|
+
} from "../framework/logger";
|
|
38
|
+
import { AnalysesStore } from "./analyses-store";
|
|
39
|
+
import { generateNarrative, LlmCallError, type LlmConfig } from "./llm";
|
|
40
|
+
import {
|
|
41
|
+
computeSignature,
|
|
42
|
+
evaluateGate,
|
|
43
|
+
type NarrativeIntent,
|
|
44
|
+
} from "./narrate-gate";
|
|
45
|
+
import { type SqlDb, sqlDbFor } from "./sql-db";
|
|
46
|
+
|
|
47
|
+
export interface AnalysisRunContext {
|
|
48
|
+
db: SqlDb;
|
|
49
|
+
projectId: string;
|
|
50
|
+
retentionHours: number;
|
|
51
|
+
/** Stage 3: narrative LLM config. Undefined = narratives disabled. */
|
|
52
|
+
llm?: LlmConfig;
|
|
53
|
+
/** Stage 3: max narrative-writes per project per hour. Default 50. */
|
|
54
|
+
narrativeBudgetPerHour?: number;
|
|
55
|
+
/** Pluggable logger; defaults to console. */
|
|
56
|
+
logger?: Logger;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface AnalysisSqlRow {
|
|
60
|
+
status?: string | null;
|
|
61
|
+
primary_value?: number | null;
|
|
62
|
+
baseline_value?: number | null;
|
|
63
|
+
payload?: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const VALID_STATUSES: ReadonlySet<AnalysisStatus> = new Set([
|
|
67
|
+
"ok",
|
|
68
|
+
"warn",
|
|
69
|
+
"critical",
|
|
70
|
+
"unknown",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const deriveStatus = (deltaPct: number | null): AnalysisStatus => {
|
|
74
|
+
if (deltaPct === null || !Number.isFinite(deltaPct)) return "unknown";
|
|
75
|
+
const magnitude = Math.abs(deltaPct);
|
|
76
|
+
if (magnitude >= 25) return "critical";
|
|
77
|
+
if (magnitude >= 10) return "warn";
|
|
78
|
+
return "ok";
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const computeDeltaPct = (
|
|
82
|
+
primary: number | null,
|
|
83
|
+
baseline: number | null,
|
|
84
|
+
): number | null => {
|
|
85
|
+
if (
|
|
86
|
+
primary === null ||
|
|
87
|
+
baseline === null ||
|
|
88
|
+
!Number.isFinite(primary) ||
|
|
89
|
+
!Number.isFinite(baseline) ||
|
|
90
|
+
baseline === 0
|
|
91
|
+
) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return ((primary - baseline) / baseline) * 100;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const parsePayload = (
|
|
98
|
+
payload: string | null | undefined,
|
|
99
|
+
): Record<string, unknown> => {
|
|
100
|
+
if (!payload) return {};
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(payload) as unknown;
|
|
103
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
104
|
+
? (parsed as Record<string, unknown>)
|
|
105
|
+
: {};
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export async function runSqlAnalysis(
|
|
112
|
+
def: AnalysisDefinition,
|
|
113
|
+
ctx: AnalysisRunContext,
|
|
114
|
+
): Promise<AnalysisResult> {
|
|
115
|
+
if (!def.sql)
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Analysis "${def.id}" has no sql; cannot run as SQL analysis`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const startedAt = Date.now();
|
|
121
|
+
// Contract with Agent 2's analysis derivation:
|
|
122
|
+
// - Tier 0 SQL is static and references the literal token {{PROJECT_ID}};
|
|
123
|
+
// the runner string-replaces it before execution.
|
|
124
|
+
// - Tier 1 SQL bakes the project_id in at derivation time (escaped via
|
|
125
|
+
// SQL '' quoting) and contains no token.
|
|
126
|
+
// Either way, queries expect zero ? bindings. project_id is collector-
|
|
127
|
+
// internal (never user-supplied), but we still validate the shape to keep
|
|
128
|
+
// future user-defined SQL safe from token-replacement injection.
|
|
129
|
+
if (!/^[A-Za-z0-9_-]+$/.test(ctx.projectId)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`refusing to run analysis: unsafe project_id ${ctx.projectId}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const sqlWithProject = def.sql.replace(/\{\{PROJECT_ID\}\}/g, ctx.projectId);
|
|
135
|
+
const row = await ctx.db.prepare(sqlWithProject).first<AnalysisSqlRow>();
|
|
136
|
+
|
|
137
|
+
const primaryValue =
|
|
138
|
+
row && typeof row.primary_value === "number" ? row.primary_value : null;
|
|
139
|
+
const baselineValue =
|
|
140
|
+
row && typeof row.baseline_value === "number" ? row.baseline_value : null;
|
|
141
|
+
const deltaPct = computeDeltaPct(primaryValue, baselineValue);
|
|
142
|
+
|
|
143
|
+
const rawStatus = row?.status;
|
|
144
|
+
const status: AnalysisStatus =
|
|
145
|
+
rawStatus && VALID_STATUSES.has(rawStatus as AnalysisStatus)
|
|
146
|
+
? (rawStatus as AnalysisStatus)
|
|
147
|
+
: deriveStatus(deltaPct);
|
|
148
|
+
|
|
149
|
+
const generatedAt = new Date().toISOString();
|
|
150
|
+
|
|
151
|
+
const payload = parsePayload(row?.payload);
|
|
152
|
+
const narrativeSignature = computeSignature({
|
|
153
|
+
status,
|
|
154
|
+
primaryValue,
|
|
155
|
+
baselineValue,
|
|
156
|
+
payload,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
analysisId: def.id,
|
|
161
|
+
projectId: ctx.projectId,
|
|
162
|
+
generatedAt,
|
|
163
|
+
paramsHash: null,
|
|
164
|
+
status,
|
|
165
|
+
primaryValue,
|
|
166
|
+
baselineValue,
|
|
167
|
+
deltaPct,
|
|
168
|
+
payload,
|
|
169
|
+
narrative: null,
|
|
170
|
+
narrativeSignature,
|
|
171
|
+
durationMs: Date.now() - startedAt,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* RFC 0002 Stage 3 — narrate pass. Decides whether to call the LLM,
|
|
177
|
+
* reuse a cached narrative, or skip; mutates the result in place.
|
|
178
|
+
*
|
|
179
|
+
* Budget is checked once at call time (not pre-decided per panel) so
|
|
180
|
+
* the runner doesn't waste effort computing gates for analyses that
|
|
181
|
+
* won't get a turn anyway. `[narrate]` log lines are the operator's
|
|
182
|
+
* audit trail for "why didn't this panel narrate?".
|
|
183
|
+
*/
|
|
184
|
+
async function narratePass(
|
|
185
|
+
def: AnalysisDefinition,
|
|
186
|
+
current: AnalysisResult,
|
|
187
|
+
previous: AnalysisResult | null,
|
|
188
|
+
ctx: AnalysisRunContext,
|
|
189
|
+
_store: AnalysesStore,
|
|
190
|
+
budgetState: { remaining: number },
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
if (!def.narrate) return;
|
|
193
|
+
if (!ctx.llm) return;
|
|
194
|
+
|
|
195
|
+
const intent: NarrativeIntent = evaluateGate(def.narrate.only_when, {
|
|
196
|
+
current,
|
|
197
|
+
previous,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (intent === "skip") return;
|
|
201
|
+
|
|
202
|
+
if (intent === "reuse" && previous?.narrative) {
|
|
203
|
+
current.narrative = previous.narrative;
|
|
204
|
+
// Keep the previous signature so subsequent runs continue to compare
|
|
205
|
+
// against the state that produced this narrative.
|
|
206
|
+
current.narrativeSignature = previous.narrativeSignature;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (intent !== "call") return;
|
|
211
|
+
|
|
212
|
+
if (budgetState.remaining <= 0) {
|
|
213
|
+
(ctx.logger ?? consoleLogger).warn("[narrate] budget exhausted", {
|
|
214
|
+
analysis_id: def.id,
|
|
215
|
+
});
|
|
216
|
+
if (previous?.narrative) {
|
|
217
|
+
current.narrative = previous.narrative;
|
|
218
|
+
current.narrativeSignature = previous.narrativeSignature;
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
budgetState.remaining -= 1;
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const timeout = setTimeout(() => controller.abort(), 20_000);
|
|
227
|
+
const text = await generateNarrative(
|
|
228
|
+
{ definition: def, current, previous },
|
|
229
|
+
{ ...ctx.llm, signal: controller.signal },
|
|
230
|
+
).finally(() => clearTimeout(timeout));
|
|
231
|
+
current.narrative = text;
|
|
232
|
+
// Keep the just-computed signature so cache compare works next run.
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const msg =
|
|
235
|
+
error instanceof LlmCallError
|
|
236
|
+
? `${error.message}`
|
|
237
|
+
: error instanceof Error
|
|
238
|
+
? error.message
|
|
239
|
+
: String(error);
|
|
240
|
+
(ctx.logger ?? consoleLogger).error("[narrate] LLM call failed", {
|
|
241
|
+
analysis_id: def.id,
|
|
242
|
+
error: msg,
|
|
243
|
+
});
|
|
244
|
+
if (previous?.narrative) {
|
|
245
|
+
current.narrative = previous.narrative;
|
|
246
|
+
current.narrativeSignature = previous.narrativeSignature;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Orchestrator. Refreshes the analysis_definitions registry from the
|
|
253
|
+
* in-process registry (so newly-derived Tier 1 analyses become visible
|
|
254
|
+
* to the dashboard within one tick), then runs whatever's due.
|
|
255
|
+
*
|
|
256
|
+
* Stage 1 has a single project ("default"). When a projects table is
|
|
257
|
+
* available we'll iterate it here.
|
|
258
|
+
*/
|
|
259
|
+
export async function runAllDueAnalyses(
|
|
260
|
+
ctx: {
|
|
261
|
+
env: CollectorEnv;
|
|
262
|
+
retentionHours: number;
|
|
263
|
+
logger?: Logger;
|
|
264
|
+
tracer?: ChildSpanRunner;
|
|
265
|
+
},
|
|
266
|
+
_runtime?: unknown,
|
|
267
|
+
): Promise<{
|
|
268
|
+
ran: number;
|
|
269
|
+
failed: number;
|
|
270
|
+
refreshed: number;
|
|
271
|
+
narrated: number;
|
|
272
|
+
skipped?: number;
|
|
273
|
+
}> {
|
|
274
|
+
const logger = ctx.logger ?? consoleLogger;
|
|
275
|
+
const db = sqlDbFor(ctx.env);
|
|
276
|
+
const store = new AnalysesStore(db);
|
|
277
|
+
const projectId = "default";
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
const expiresAt = now + ctx.retentionHours * 3600 * 1000;
|
|
280
|
+
|
|
281
|
+
// 1. Refresh the registry — Agent 2's getAllAnalysesForProject builds the
|
|
282
|
+
// union of Tier 0 + derived Tier 1 + user-defined analyses.
|
|
283
|
+
let registered: AnalysisDefinition[] = [];
|
|
284
|
+
try {
|
|
285
|
+
registered = await getAllAnalysesForProject(projectId, {
|
|
286
|
+
db,
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
logger.error("[analyses] failed to load registered analyses", {
|
|
290
|
+
project_id: projectId,
|
|
291
|
+
error: error instanceof Error ? error.message : String(error),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let refreshed = 0;
|
|
296
|
+
for (const def of registered) {
|
|
297
|
+
try {
|
|
298
|
+
await store.upsertDefinition(projectId, def);
|
|
299
|
+
refreshed += 1;
|
|
300
|
+
} catch (error) {
|
|
301
|
+
logger.error("[analyses] upsertDefinition failed", {
|
|
302
|
+
analysis_id: def.id,
|
|
303
|
+
error: error instanceof Error ? error.message : String(error),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. Find what's due and run them.
|
|
309
|
+
const due = await store.getDueAnalyses(projectId, now);
|
|
310
|
+
if (due.length === 0) {
|
|
311
|
+
return { ran: 0, failed: 0, refreshed, narrated: 0 };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Narrative LLM config — present only when an API key is set on the
|
|
315
|
+
// worker. We don't fail the run if it's missing; we just skip the
|
|
316
|
+
// narrate pass entirely (every panel still produces numbers).
|
|
317
|
+
//
|
|
318
|
+
// Provider preference: OpenAI wins when both keys are set — configuring
|
|
319
|
+
// both is presumed deliberate. Empty-string env values from .dev.vars
|
|
320
|
+
// are coerced to undefined so they don't accidentally override defaults.
|
|
321
|
+
const openaiBase = ctx.env.OPENAI_BASE_URL?.trim()
|
|
322
|
+
? ctx.env.OPENAI_BASE_URL
|
|
323
|
+
: undefined;
|
|
324
|
+
const narrativeModel = ctx.env.NARRATIVE_MODEL?.trim()
|
|
325
|
+
? ctx.env.NARRATIVE_MODEL
|
|
326
|
+
: undefined;
|
|
327
|
+
const llm: LlmConfig | undefined = ctx.env.OPENAI_API_KEY?.trim()
|
|
328
|
+
? {
|
|
329
|
+
provider: "openai",
|
|
330
|
+
apiKey: ctx.env.OPENAI_API_KEY,
|
|
331
|
+
model: narrativeModel ?? "gpt-4o-mini",
|
|
332
|
+
apiUrl: openaiBase,
|
|
333
|
+
tracer: ctx.tracer,
|
|
334
|
+
}
|
|
335
|
+
: ctx.env.ANTHROPIC_API_KEY?.trim()
|
|
336
|
+
? {
|
|
337
|
+
provider: "anthropic",
|
|
338
|
+
apiKey: ctx.env.ANTHROPIC_API_KEY,
|
|
339
|
+
model: narrativeModel ?? "claude-3-5-haiku-latest",
|
|
340
|
+
tracer: ctx.tracer,
|
|
341
|
+
}
|
|
342
|
+
: undefined;
|
|
343
|
+
const parsedNarrativeBudget = Number.parseInt(
|
|
344
|
+
ctx.env.NARRATIVE_BUDGET_PER_HOUR ?? "",
|
|
345
|
+
10,
|
|
346
|
+
);
|
|
347
|
+
const narrativeBudgetPerHour = Number.isFinite(parsedNarrativeBudget)
|
|
348
|
+
? parsedNarrativeBudget
|
|
349
|
+
: 50;
|
|
350
|
+
const narrativesUsed = llm
|
|
351
|
+
? await store.countNarrativesInWindow(projectId, 60)
|
|
352
|
+
: 0;
|
|
353
|
+
const budgetState = {
|
|
354
|
+
remaining: Math.max(0, narrativeBudgetPerHour - narrativesUsed),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const runCtx: AnalysisRunContext = {
|
|
358
|
+
db,
|
|
359
|
+
projectId,
|
|
360
|
+
retentionHours: ctx.retentionHours,
|
|
361
|
+
llm,
|
|
362
|
+
narrativeBudgetPerHour,
|
|
363
|
+
logger,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Run with bounded concurrency. D1 is happy with a handful of parallel
|
|
367
|
+
// queries; 45 in flight at once causes the Workers scheduled handler
|
|
368
|
+
// to hit its CPU/wall-time cap before any results land. We also log
|
|
369
|
+
// per-analysis so a stuck SQL is identifiable in the log rather than
|
|
370
|
+
// silently swallowed.
|
|
371
|
+
const CONCURRENCY = 4;
|
|
372
|
+
// Lease window for the per-analysis claim. Long enough that a healthy
|
|
373
|
+
// narrative pass (LLM roundtrip + result insert) won't get rerun by the
|
|
374
|
+
// next tick, short enough that a worker crash unblocks the row within
|
|
375
|
+
// two ticks. 90 s sits between the every-minute cron and a typical LLM
|
|
376
|
+
// p95.
|
|
377
|
+
const CLAIM_LEASE_MS = 90_000;
|
|
378
|
+
let ran = 0;
|
|
379
|
+
let failed = 0;
|
|
380
|
+
let skipped = 0;
|
|
381
|
+
let narrated = 0;
|
|
382
|
+
const queue = [...due];
|
|
383
|
+
const workers: Promise<void>[] = [];
|
|
384
|
+
for (let w = 0; w < CONCURRENCY; w += 1) {
|
|
385
|
+
workers.push(
|
|
386
|
+
(async () => {
|
|
387
|
+
while (queue.length > 0) {
|
|
388
|
+
const def = queue.shift();
|
|
389
|
+
if (!def) return;
|
|
390
|
+
const claimed = await store.claimAnalysis(
|
|
391
|
+
projectId,
|
|
392
|
+
def.id,
|
|
393
|
+
Date.now(),
|
|
394
|
+
CLAIM_LEASE_MS,
|
|
395
|
+
);
|
|
396
|
+
if (!claimed) {
|
|
397
|
+
skipped += 1;
|
|
398
|
+
logger.info("[analyses] skipped (lease held by another tick)", {
|
|
399
|
+
analysis_id: def.id,
|
|
400
|
+
});
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const result = await runSqlAnalysis(def, runCtx);
|
|
405
|
+
// Stage 3: narrate pass. Reads previous result from D1 to
|
|
406
|
+
// run gate logic + signature cache; isolated from query
|
|
407
|
+
// failures so a flaky LLM doesn't drop the data row.
|
|
408
|
+
if (def.narrate && runCtx.llm) {
|
|
409
|
+
const previous = await store.getLatestResult(projectId, def.id);
|
|
410
|
+
await narratePass(
|
|
411
|
+
def,
|
|
412
|
+
result,
|
|
413
|
+
previous,
|
|
414
|
+
runCtx,
|
|
415
|
+
store,
|
|
416
|
+
budgetState,
|
|
417
|
+
);
|
|
418
|
+
if (result.narrative) narrated += 1;
|
|
419
|
+
}
|
|
420
|
+
await store.insertResult(result, expiresAt);
|
|
421
|
+
// markRan releases the claim as a side-effect.
|
|
422
|
+
await store.markRan(projectId, def.id, result.generatedAt);
|
|
423
|
+
ran += 1;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
failed += 1;
|
|
426
|
+
await store.releaseClaim(projectId, def.id);
|
|
427
|
+
logger.error("[analyses] analysis failed", {
|
|
428
|
+
analysis_id: def.id,
|
|
429
|
+
error: error instanceof Error ? error.message : String(error),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
})(),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
await Promise.all(workers);
|
|
437
|
+
|
|
438
|
+
return { ran, failed, refreshed, narrated, skipped };
|
|
439
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalysesStore tests — focused on the claim semantics added to prevent
|
|
3
|
+
* cron overlap. Pre-existing methods (upsertDefinition, getLatestResult)
|
|
4
|
+
* are exercised end-to-end by the runner's integration tests; this file
|
|
5
|
+
* only locks down the new claim primitive's contract.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { AnalysesStore } from "./analyses-store";
|
|
10
|
+
import { MemSqlDb } from "./test-utils/mem-sql-db";
|
|
11
|
+
|
|
12
|
+
describe("AnalysesStore.claimAnalysis", () => {
|
|
13
|
+
it("returns true when the UPDATE moved a row (claim acquired)", async () => {
|
|
14
|
+
const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
|
|
15
|
+
const store = new AnalysesStore(db);
|
|
16
|
+
const ok = await store.claimAnalysis(
|
|
17
|
+
"proj-1",
|
|
18
|
+
"analysis-1",
|
|
19
|
+
Date.now(),
|
|
20
|
+
90_000,
|
|
21
|
+
);
|
|
22
|
+
expect(ok).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns false when the UPDATE matched zero rows (lease held)", async () => {
|
|
26
|
+
// changes=0 simulates D1 declining the UPDATE because the WHERE
|
|
27
|
+
// clause excluded the row (active lease held by another tick).
|
|
28
|
+
const db = new MemSqlDb({ run: () => ({ changes: 0 }) });
|
|
29
|
+
const store = new AnalysesStore(db);
|
|
30
|
+
const ok = await store.claimAnalysis(
|
|
31
|
+
"proj-1",
|
|
32
|
+
"analysis-1",
|
|
33
|
+
Date.now(),
|
|
34
|
+
90_000,
|
|
35
|
+
);
|
|
36
|
+
expect(ok).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("binds an ISO timestamp for the lease floor (now - leaseMs)", async () => {
|
|
40
|
+
const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
|
|
41
|
+
const store = new AnalysesStore(db);
|
|
42
|
+
const now = Date.parse("2026-05-01T12:00:00.000Z");
|
|
43
|
+
await store.claimAnalysis("proj-1", "analysis-1", now, 90_000);
|
|
44
|
+
|
|
45
|
+
const claim = db.callsMatching("last_started_at = ?")[0];
|
|
46
|
+
expect(claim).toBeDefined();
|
|
47
|
+
// binds: [nowIso, projectId, analysisId, leaseFloorIso]
|
|
48
|
+
expect(claim.binds[0]).toBe("2026-05-01T12:00:00.000Z");
|
|
49
|
+
expect(claim.binds[1]).toBe("proj-1");
|
|
50
|
+
expect(claim.binds[2]).toBe("analysis-1");
|
|
51
|
+
expect(claim.binds[3]).toBe("2026-05-01T11:58:30.000Z");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("AnalysesStore.markRan", () => {
|
|
56
|
+
it("clears last_started_at while writing last_run_at (releases the lease)", async () => {
|
|
57
|
+
const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
|
|
58
|
+
const store = new AnalysesStore(db);
|
|
59
|
+
await store.markRan("proj-1", "analysis-1", "2026-05-01T12:00:00Z");
|
|
60
|
+
|
|
61
|
+
const call = db.callsMatching("UPDATE analysis_definitions")[0];
|
|
62
|
+
expect(call).toBeDefined();
|
|
63
|
+
expect(call.sql).toContain("last_started_at = NULL");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("AnalysesStore.releaseClaim", () => {
|
|
68
|
+
it("nullifies last_started_at without touching last_run_at", async () => {
|
|
69
|
+
const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
|
|
70
|
+
const store = new AnalysesStore(db);
|
|
71
|
+
await store.releaseClaim("proj-1", "analysis-1");
|
|
72
|
+
|
|
73
|
+
const call = db.callsMatching("last_started_at = NULL")[0];
|
|
74
|
+
expect(call).toBeDefined();
|
|
75
|
+
expect(call.sql).not.toContain("last_run_at");
|
|
76
|
+
expect(call.binds).toEqual(["proj-1", "analysis-1"]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("AnalysesStore.getLatestResult evidence references", () => {
|
|
81
|
+
it("derives machine-readable pivots from structured evidence tables", async () => {
|
|
82
|
+
const db = new MemSqlDb({
|
|
83
|
+
first: () => ({
|
|
84
|
+
id: 1,
|
|
85
|
+
project_id: "proj-1",
|
|
86
|
+
analysis_id: "investigate.latency",
|
|
87
|
+
generated_at: Date.parse("2026-05-01T12:00:00.000Z"),
|
|
88
|
+
params_hash: null,
|
|
89
|
+
status: "warn",
|
|
90
|
+
primary_value: 1234,
|
|
91
|
+
baseline_value: null,
|
|
92
|
+
delta_pct: null,
|
|
93
|
+
payload_json: JSON.stringify({
|
|
94
|
+
evidence: {
|
|
95
|
+
tail_offenders: {
|
|
96
|
+
title: "Tail offenders",
|
|
97
|
+
headers: ["trace_id", "span_id", "service", "max_ms"],
|
|
98
|
+
rows: [
|
|
99
|
+
["trace-1", "span-1", "checkout", 1234],
|
|
100
|
+
[null, null, "api", 400],
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
narrative: "Latency is elevated.",
|
|
106
|
+
narrative_signature: "sig-1",
|
|
107
|
+
duration_ms: 12,
|
|
108
|
+
expires_at: Date.parse("2026-05-02T12:00:00.000Z"),
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
const store = new AnalysesStore(db);
|
|
112
|
+
|
|
113
|
+
const result = await store.getLatestResult("proj-1", "investigate.latency");
|
|
114
|
+
|
|
115
|
+
expect(result?.evidenceReferences).toEqual([
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
evidenceId:
|
|
118
|
+
"analysis:investigate.latency:tail_offenders:0:span:trace-1:span-1",
|
|
119
|
+
entityKind: "span",
|
|
120
|
+
entityId: "trace-1:span-1",
|
|
121
|
+
route: "#/traces/trace-1#span=span-1",
|
|
122
|
+
source: "analysis.payload.evidence.tail_offenders",
|
|
123
|
+
confidence: 0.95,
|
|
124
|
+
citations: [
|
|
125
|
+
{
|
|
126
|
+
label: "trace trace-1",
|
|
127
|
+
entityKind: "trace",
|
|
128
|
+
entityId: "trace-1",
|
|
129
|
+
route: "#/traces/trace-1",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
expect.objectContaining({
|
|
134
|
+
evidenceId: "analysis:investigate.latency:tail_offenders:1:service:api",
|
|
135
|
+
entityKind: "service",
|
|
136
|
+
entityId: "api",
|
|
137
|
+
route: "#/traces?service=api",
|
|
138
|
+
source: "analysis.payload.evidence.tail_offenders",
|
|
139
|
+
confidence: 0.7,
|
|
140
|
+
}),
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("omits evidenceReferences for legacy payloads without evidence tables", async () => {
|
|
145
|
+
const db = new MemSqlDb({
|
|
146
|
+
first: () => ({
|
|
147
|
+
id: 1,
|
|
148
|
+
project_id: "proj-1",
|
|
149
|
+
analysis_id: "legacy.panel",
|
|
150
|
+
generated_at: Date.parse("2026-05-01T12:00:00.000Z"),
|
|
151
|
+
params_hash: null,
|
|
152
|
+
status: "ok",
|
|
153
|
+
primary_value: null,
|
|
154
|
+
baseline_value: null,
|
|
155
|
+
delta_pct: null,
|
|
156
|
+
payload_json: JSON.stringify({ rows: [{ service: "api" }] }),
|
|
157
|
+
narrative: null,
|
|
158
|
+
narrative_signature: null,
|
|
159
|
+
duration_ms: 4,
|
|
160
|
+
expires_at: Date.parse("2026-05-02T12:00:00.000Z"),
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
const store = new AnalysesStore(db);
|
|
164
|
+
|
|
165
|
+
const result = await store.getLatestResult("proj-1", "legacy.panel");
|
|
166
|
+
|
|
167
|
+
expect(result?.evidenceReferences).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("derives machine-readable pivots from instrumentation gap payloads", async () => {
|
|
171
|
+
const db = new MemSqlDb({
|
|
172
|
+
first: () => ({
|
|
173
|
+
id: 1,
|
|
174
|
+
project_id: "proj-1",
|
|
175
|
+
analysis_id: "trace.instrumentation_gaps",
|
|
176
|
+
generated_at: Date.parse("2026-05-01T12:00:00.000Z"),
|
|
177
|
+
params_hash: null,
|
|
178
|
+
status: "warn",
|
|
179
|
+
primary_value: 500,
|
|
180
|
+
baseline_value: null,
|
|
181
|
+
delta_pct: null,
|
|
182
|
+
payload_json: JSON.stringify({
|
|
183
|
+
instrumentationGaps: {
|
|
184
|
+
traceId: "trace-gap",
|
|
185
|
+
totalDurationMs: 1000,
|
|
186
|
+
uninstrumentedTimeMs: 500,
|
|
187
|
+
ratio: 0.5,
|
|
188
|
+
blindspots: [
|
|
189
|
+
{
|
|
190
|
+
traceId: "trace-gap",
|
|
191
|
+
parentSpanId: "span-root",
|
|
192
|
+
parentServiceName: "checkout",
|
|
193
|
+
parentSpanName: "POST /checkout",
|
|
194
|
+
offsetMs: 100,
|
|
195
|
+
durationMs: 500,
|
|
196
|
+
ratioOfParent: 0.5,
|
|
197
|
+
childSpanCount: 1,
|
|
198
|
+
asyncParent: false,
|
|
199
|
+
recommendation: "Instrument child operations.",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
narrative: "Checkout has missing instrumentation.",
|
|
205
|
+
narrative_signature: "sig-gap",
|
|
206
|
+
duration_ms: 12,
|
|
207
|
+
expires_at: Date.parse("2026-05-02T12:00:00.000Z"),
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
const store = new AnalysesStore(db);
|
|
211
|
+
|
|
212
|
+
const result = await store.getLatestResult(
|
|
213
|
+
"proj-1",
|
|
214
|
+
"trace.instrumentation_gaps",
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(result?.evidenceReferences?.[0]).toMatchObject({
|
|
218
|
+
evidenceId:
|
|
219
|
+
"analysis:trace.instrumentation_gaps:instrumentation-gap:0:span:trace-gap:span-root",
|
|
220
|
+
entityKind: "span",
|
|
221
|
+
entityId: "trace-gap:span-root",
|
|
222
|
+
route: "#/traces/trace-gap#span=span-root",
|
|
223
|
+
source: "analysis.payload.instrumentationGaps",
|
|
224
|
+
confidence: 0.9,
|
|
225
|
+
citations: [
|
|
226
|
+
{
|
|
227
|
+
label: "trace trace-gap",
|
|
228
|
+
entityKind: "trace",
|
|
229
|
+
entityId: "trace-gap",
|
|
230
|
+
route: "#/traces/trace-gap",
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
suggestedNextPivots: expect.arrayContaining([
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
label: "Open profiler setup docs",
|
|
236
|
+
entityKind: "docs",
|
|
237
|
+
entityId: "howto/ebpf",
|
|
238
|
+
route: "#/docs/howto/ebpf",
|
|
239
|
+
}),
|
|
240
|
+
]),
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|