From 83d7f5a1a938447f8c9028fc0a37cf8ca436a363 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Sun, 5 Jul 2026 16:20:11 +0200 Subject: [PATCH 01/20] feat(analytics): unified metrics registry and /v1/metric-results runtime Metrics are defined once in a typed registry (sources, measures, dimensions, definitions, input-role mappings) and served by one generic runtime: request validation with caps, per-view ClickHouse query compilation for sum and ratio computations (period, timeseries, peer, breakdown views), densification, and row-cap enforcement. Definitions convert to Rust discriminated unions before compilation; only executable computations reach the compiler. Builtin definitions live in a code registry converged by a startup reconciler (disable-not-delete); migrations own schema only. A background validator probes observation sources per definition and degrades unavailable definitions without erroring quiet metrics. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- AGENTS.md | 4 + docs/domain/README.md | 1 + .../analytics/src/api/metric_results.rs | 195 ++++ src/backend/services/analytics/src/api/mod.rs | 11 + src/backend/services/analytics/src/config.rs | 20 + .../src/domain/metric_definitions/README.md | 7 + .../src/domain/metric_definitions/builtin.rs | 482 +++++++++ .../domain/metric_definitions/definition.rs | 333 ++++++ .../domain/metric_definitions/error_code.rs | 51 + .../src/domain/metric_definitions/mod.rs | 14 + .../domain/metric_definitions/repository.rs | 947 ++++++++++++++++++ .../src/domain/metric_definitions/seeds.rs | 363 +++++++ .../domain/metric_definitions/validator.rs | 522 ++++++++++ .../src/domain/metric_results/builder.rs | 512 ++++++++++ .../src/domain/metric_results/compiler.rs | 685 +++++++++++++ .../src/domain/metric_results/definition.rs | 18 + .../src/domain/metric_results/dto.rs | 207 ++++ .../src/domain/metric_results/mod.rs | 16 + .../src/domain/metric_results/validation.rs | 683 +++++++++++++ .../services/analytics/src/domain/mod.rs | 2 + src/backend/services/analytics/src/gear.rs | 39 + .../m20260625_000001_metric_definitions.rs | 208 ++++ .../services/analytics/src/migration/mod.rs | 50 + .../dbt/chatgpt_team__ai_assistant_usage.sql | 2 + .../dbt/chatgpt_team__ai_dev_usage.sql | 2 + .../dbt/claude_admin__ai_dev_usage.sql | 6 + .../claude_enterprise__ai_assistant_usage.sql | 10 + .../dbt/claude_enterprise__ai_dev_usage.sql | 2 + .../dbt/claude_team__ai_dev_usage.sql | 2 + .../ai/cursor/dbt/cursor__ai_dev_usage.sql | 2 + .../dbt/copilot__ai_dev_usage.sql | 2 + src/ingestion/dbt/dbt_project.yml | 1 + .../silver/ai/class_ai_assistant_usage.sql | 1 + .../silver/ai/class_ai_dev_usage.sql | 1 + src/ingestion/silver/ai/schema.yml | 49 +- 35 files changed, 5448 insertions(+), 2 deletions(-) create mode 100644 src/backend/services/analytics/src/api/metric_results.rs create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/README.md create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/builtin.rs create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/definition.rs create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/error_code.rs create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/mod.rs create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/repository.rs create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/seeds.rs create mode 100644 src/backend/services/analytics/src/domain/metric_definitions/validator.rs create mode 100644 src/backend/services/analytics/src/domain/metric_results/builder.rs create mode 100644 src/backend/services/analytics/src/domain/metric_results/compiler.rs create mode 100644 src/backend/services/analytics/src/domain/metric_results/definition.rs create mode 100644 src/backend/services/analytics/src/domain/metric_results/dto.rs create mode 100644 src/backend/services/analytics/src/domain/metric_results/mod.rs create mode 100644 src/backend/services/analytics/src/domain/metric_results/validation.rs create mode 100644 src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs diff --git a/AGENTS.md b/AGENTS.md index 78dfe8dce..f8f1dd83d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,7 @@ ALWAYS open and follow `{cypilot_path}/config/AGENTS.md` FIRST ALWAYS invoke `{cypilot_path}/.core/skills/cypilot/SKILL.md` WHEN user asks to do something with Cypilot + +## Project Rules + +ALWAYS open and follow `docs/domain/metrics/specs/DESIGN.md` WHEN adding or changing metrics, metric definitions, metric seeds, observation sources, or gold metric views diff --git a/docs/domain/README.md b/docs/domain/README.md index dde5fe40f..eb273bb0e 100644 --- a/docs/domain/README.md +++ b/docs/domain/README.md @@ -9,3 +9,4 @@ Domain-level specifications for the Insight platform. Each domain represents a b | [`ingestion/`](ingestion/) | Data pipeline from source APIs to Silver step 1 (Airbyte + Argo Workflows + dbt) | Accepted | | [`connector/`](connector/) | Connector development: Insight Connector packages, nocode and CDK patterns, packaging, debugging | Accepted | | [`identity-resolution/`](identity-resolution/) | Person identity matching and resolution across sources | Proposed | +| [`metrics/`](metrics/) | Unified metrics: typed registry + generic result runtime over source measure observations (`/v1/metric-results`) | Accepted | diff --git a/src/backend/services/analytics/src/api/metric_results.rs b/src/backend/services/analytics/src/api/metric_results.rs new file mode 100644 index 000000000..303ba2bc1 --- /dev/null +++ b/src/backend/services/analytics/src/api/metric_results.rs @@ -0,0 +1,195 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::Json; +use axum::extract::Extension; +use futures::stream::{self, StreamExt}; +use serde::de::DeserializeOwned; +use toolkit_canonical_errors::CanonicalError; + +use super::AppState; +use crate::domain::metric_definitions::ExecutableMetric; +use crate::domain::metric_results::{ + BreakdownQueryRow, CompiledQuery, MetricResultViewDto, MetricResultsRequest, + MetricResultsResponse, PeerQueryRow, PeriodQueryRow, TimeseriesQueryRow, + ValidatedMetricResultsRequest, ValidatedMetricView, build_breakdown_view, build_metric_result, + build_peer_view, build_period_view, build_timeseries_view, compile_view_query, + enforce_row_limit, validate_request, +}; +use toolkit_security::SecurityContext; + +const QUERY_CONCURRENCY: usize = 4; +// Client-side bound on one view query, network stalls included. The +// insight-clickhouse client already caps server-side execution at 30s +// (`max_execution_time`); this covers the transport path that setting +// cannot reach (dead peer, half-open connection). +const QUERY_FETCH_TIMEOUT: Duration = Duration::from_mins(1); + +pub async fn query_metric_results( + Extension(state): Extension>, + Extension(ctx): Extension, + Json(req): Json, +) -> Result, CanonicalError> { + let req = validate_request(&state.db, ctx.subject_tenant_id(), req).await?; + let tenant_id = warehouse_tenant_id(&state, &ctx); + let tasks = compile_tasks(&req, &tenant_id); + let results = stream::iter(tasks) + .map(|task| execute_task(&state, &req, task)) + .buffer_unordered(QUERY_CONCURRENCY) + .collect::>() + .await; + + let mut views_by_metric: Vec>> = req + .metrics + .iter() + .map(|metric| (0..metric.views.len()).map(|_| None).collect()) + .collect(); + + for result in results { + let result = result?; + views_by_metric[result.metric_index][result.view_index] = Some(result.view); + } + + let mut metrics = Vec::with_capacity(req.metrics.len()); + for (idx, metric) in req.metrics.iter().enumerate() { + let mut views = Vec::with_capacity(metric.views.len()); + for view in views_by_metric[idx].drain(..) { + let Some(view) = view else { + return Err(CanonicalError::internal("missing metric view result").create()); + }; + views.push(view); + } + metrics.push(build_metric_result(&metric.def, views)); + } + + let response = MetricResultsResponse { metrics }; + enforce_row_limit(&response)?; + Ok(Json(response)) +} + +fn warehouse_tenant_id(state: &AppState, ctx: &SecurityContext) -> String { + state + .config + .metric_results + .single_tenant_warehouse_id + .as_deref() + .map(str::trim) + .filter(|tenant_id| !tenant_id.is_empty()) + .map_or_else(|| ctx.subject_tenant_id().to_string(), ToOwned::to_owned) +} + +struct MetricViewTask { + metric_index: usize, + view_index: usize, + exec: ExecutableMetric, + view: ValidatedMetricView, + query: CompiledQuery, +} + +struct MetricViewTaskResult { + metric_index: usize, + view_index: usize, + view: MetricResultViewDto, +} + +fn compile_tasks(req: &ValidatedMetricResultsRequest, tenant_id: &str) -> Vec { + req.metrics + .iter() + .enumerate() + .flat_map(|(metric_index, metric)| { + metric + .views + .iter() + .enumerate() + .map(move |(view_index, view)| MetricViewTask { + metric_index, + view_index, + exec: metric.exec.clone(), + view: view.clone(), + query: compile_view_query(&metric.exec, req, tenant_id, view), + }) + }) + .collect() +} + +async fn execute_task( + state: &Arc, + req: &ValidatedMetricResultsRequest, + task: MetricViewTask, +) -> Result { + let MetricViewTask { + metric_index, + view_index, + exec, + view, + query, + } = task; + + let view = match view { + ValidatedMetricView::Period => { + let rows = fetch_rows::(state, query).await?; + build_period_view(&exec, req, rows) + } + ValidatedMetricView::Peer { .. } => { + let rows = fetch_rows::(state, query).await?; + build_peer_view(rows) + } + ValidatedMetricView::Timeseries { bucket, dimensions } => { + let rows = fetch_rows::(state, query).await?; + build_timeseries_view(&exec, req, bucket, &dimensions, rows)? + } + ValidatedMetricView::Breakdown { dimensions } => { + let rows = fetch_rows::(state, query).await?; + build_breakdown_view(&dimensions, rows)? + } + }; + + Ok(MetricViewTaskResult { + metric_index, + view_index, + view, + }) +} + +async fn fetch_rows( + state: &Arc, + query: CompiledQuery, +) -> Result, CanonicalError> +where + T: DeserializeOwned, +{ + let mut ch_query = state.ch.query(&query.sql); + for param in &query.params { + ch_query = ch_query.bind(param.as_str()); + } + + let mut cursor = ch_query.fetch_bytes("JSONEachRow").map_err(|e| { + tracing::error!(error = %e, sql = %query.sql, "ClickHouse metric-results query failed"); + CanonicalError::internal("query execution failed").create() + })?; + + let raw_bytes = tokio::time::timeout(QUERY_FETCH_TIMEOUT, cursor.collect()) + .await + .map_err(|_| { + tracing::error!(sql = %query.sql, "ClickHouse metric-results fetch timed out"); + CanonicalError::internal("query execution failed").create() + })? + .map_err(|e| { + tracing::error!(error = %e, sql = %query.sql, "ClickHouse metric-results fetch failed"); + CanonicalError::internal("query execution failed").create() + })?; + + if raw_bytes.is_empty() { + return Ok(Vec::new()); + } + + raw_bytes + .split(|&b| b == b'\n') + .filter(|line| !line.is_empty()) + .map(serde_json::from_slice) + .collect::, _>>() + .map_err(|e| { + tracing::error!(error = %e, "failed to parse metric-results rows"); + CanonicalError::internal("failed to parse query results").create() + }) +} diff --git a/src/backend/services/analytics/src/api/mod.rs b/src/backend/services/analytics/src/api/mod.rs index a32295d53..bfbedf6be 100644 --- a/src/backend/services/analytics/src/api/mod.rs +++ b/src/backend/services/analytics/src/api/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod canonical_json; mod catalog; pub(crate) mod error; mod handlers; +mod metric_results; #[cfg(test)] mod tenant_resolution_tests; @@ -170,6 +171,16 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { .handler(handlers::query_metrics_batch) .register(router, openapi); + router = OperationBuilder::post("/v1/metric-results") + .operation_id("analytics_api.metric_results.create") + .summary("Compute metric results") + .authenticated() + .no_license_required() + .json_response(StatusCode::OK, "Metric results") + .standard_errors(openapi) + .handler(metric_results::query_metric_results) + .register(router, openapi); + // Thresholds (legacy) router = OperationBuilder::get("/v1/metrics/{id}/thresholds") .operation_id("analytics_api.thresholds.list") diff --git a/src/backend/services/analytics/src/config.rs b/src/backend/services/analytics/src/config.rs index 62d29a025..dc6cdcf24 100644 --- a/src/backend/services/analytics/src/config.rs +++ b/src/backend/services/analytics/src/config.rs @@ -51,6 +51,25 @@ pub struct GearConfig { /// Metric Catalog configuration (DESIGN §3.5). pub metric_catalog: MetricCatalogConfig, + + #[serde(default)] + pub metric_results: MetricResultsConfig, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MetricResultsConfig { + /// Single-tenant warehouse mapping. When set, EVERY authenticated tenant's + /// metric-results queries read this warehouse tenant id instead of the + /// caller's own tenant. Correct only for single-tenant installs where the + /// control-plane tenant UUID differs from the warehouse `tenant_id` + /// strings. Setting this on a multi-tenant install would expose one + /// tenant's warehouse data to all tenants, so startup refuses to boot + /// unless the install declares itself single-tenant via + /// `metric_catalog.tenant_default_id`, and logs a warning when active. + /// + /// Env: `ANALYTICS__metric_results__single_tenant_warehouse_id`. + #[serde(default)] + pub single_tenant_warehouse_id: Option, } impl Default for GearConfig { @@ -65,6 +84,7 @@ impl Default for GearConfig { identity_url: String::new(), redis_url: String::new(), metric_catalog: MetricCatalogConfig::default(), + metric_results: MetricResultsConfig::default(), } } } diff --git a/src/backend/services/analytics/src/domain/metric_definitions/README.md b/src/backend/services/analytics/src/domain/metric_definitions/README.md new file mode 100644 index 000000000..61a7234b4 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/README.md @@ -0,0 +1,7 @@ +# Metric Definitions + +Implementation of the metrics domain. The system contract and the authoring +guide live in [`docs/domain/metrics/specs/DESIGN.md`](../../../../../../../docs/domain/metrics/specs/DESIGN.md). + +Read that document before adding or changing metrics, metric seeds, +observation sources, or gold metric views. diff --git a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs new file mode 100644 index 000000000..fc50bc3c9 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs @@ -0,0 +1,482 @@ +pub struct SourceSeed { + pub key: &'static str, + pub kind: &'static str, + pub ref_name: &'static str, +} + +pub struct MeasureSeed { + pub measure_key: &'static str, + pub value_type: &'static str, +} + +pub struct DimensionSeed { + pub dimension_key: &'static str, + pub label: &'static str, +} + +pub struct BuiltinSource { + pub source: SourceSeed, + pub measures: &'static [MeasureSeed], + pub dimensions: &'static [DimensionSeed], +} + +pub struct MetricSeed { + pub metric_key: &'static str, + pub source_key: &'static str, + pub label: &'static str, + pub description: Option<&'static str>, + pub unit: Option<&'static str>, + pub format: &'static str, + pub direction: &'static str, + pub entity_type: &'static str, + pub computation_type: &'static str, + pub scale: Option, + pub distribution_statistic: Option<&'static str>, + pub gauge_method: Option<&'static str>, + pub peer_cohort_key: Option<&'static str>, + pub inputs: &'static [InputSeed], + pub dimensions: &'static [&'static str], +} + +pub struct InputSeed { + pub input_role: &'static str, + pub measure_key: &'static str, +} + +pub const BUILTIN_SOURCES: &[BuiltinSource] = &[BuiltinSource { + source: SourceSeed { + key: "ai_usage", + kind: "managed_observation", + ref_name: "ai_metric_observations", + }, + measures: &[ + MeasureSeed { + measure_key: "accepted_lines", + value_type: "number", + }, + MeasureSeed { + measure_key: "removed_lines", + value_type: "number", + }, + MeasureSeed { + measure_key: "active_day", + value_type: "number", + }, + MeasureSeed { + measure_key: "cost_usd", + value_type: "number", + }, + MeasureSeed { + measure_key: "accepted_edit_actions", + value_type: "number", + }, + MeasureSeed { + measure_key: "tool_use_offered", + value_type: "number", + }, + MeasureSeed { + measure_key: "assistant_messages", + value_type: "number", + }, + MeasureSeed { + measure_key: "assistant_actions", + value_type: "number", + }, + MeasureSeed { + measure_key: "dev_conversations", + value_type: "number", + }, + MeasureSeed { + measure_key: "chat_assistant_conversations", + value_type: "number", + }, + ], + dimensions: &[ + DimensionSeed { + dimension_key: "tool", + label: "Tool", + }, + DimensionSeed { + dimension_key: "surface", + label: "Surface", + }, + ], +}]; + +pub const BUILTIN_METRICS: &[MetricSeed] = &[ + MetricSeed { + metric_key: "ai.accepted_lines", + source_key: "ai_usage", + label: "AI-added lines", + description: Some("Accepted added coding output"), + unit: Some("lines"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "accepted_lines", + }], + dimensions: &["tool"], + }, + MetricSeed { + metric_key: "ai.removed_lines", + source_key: "ai_usage", + label: "AI-removed lines", + description: Some("Accepted deleted coding output"), + unit: Some("lines"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "removed_lines", + }], + dimensions: &["tool"], + }, + MetricSeed { + metric_key: "ai.active_days", + source_key: "ai_usage", + label: "AI active days", + description: Some("Days with any AI activity across dev and assistant tools"), + unit: Some("days"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "active_day", + }], + dimensions: &[], + }, + MetricSeed { + metric_key: "ai.cost", + source_key: "ai_usage", + label: "AI cost", + description: Some("Reported AI spend across dev and assistant tools"), + unit: None, + format: "currency", + direction: "lower_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "cost_usd", + }], + dimensions: &["tool"], + }, + MetricSeed { + metric_key: "ai.accepted_edit_actions", + source_key: "ai_usage", + label: "Accepted AI edits", + description: Some("Accepted tool or edit suggestions"), + unit: Some("actions"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "accepted_edit_actions", + }], + dimensions: &["tool"], + }, + MetricSeed { + metric_key: "ai.tool_acceptance_rate", + source_key: "ai_usage", + label: "AI tool acceptance", + description: Some("Accepted divided by offered AI edits"), + unit: Some("percent"), + format: "percent", + direction: "higher_is_better", + entity_type: "person", + computation_type: "ratio", + scale: Some(100.0), + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[ + InputSeed { + input_role: "numerator", + measure_key: "accepted_edit_actions", + }, + InputSeed { + input_role: "denominator", + measure_key: "tool_use_offered", + }, + ], + dimensions: &["tool"], + }, + MetricSeed { + metric_key: "ai.assistant_messages", + source_key: "ai_usage", + label: "AI assistant messages", + description: Some("Assistant messages"), + unit: Some("messages"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "assistant_messages", + }], + dimensions: &["tool", "surface"], + }, + MetricSeed { + metric_key: "ai.assistant_actions", + source_key: "ai_usage", + label: "AI assistant actions", + description: Some("Assistant actions"), + unit: Some("actions"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "assistant_actions", + }], + dimensions: &["tool", "surface"], + }, + MetricSeed { + metric_key: "ai.dev_conversations", + source_key: "ai_usage", + label: "AI dev conversations", + description: Some("Coding tool conversations where the source reports them"), + unit: Some("conversations"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "dev_conversations", + }], + dimensions: &["tool"], + }, + MetricSeed { + metric_key: "ai.chat_assistant_conversations", + source_key: "ai_usage", + label: "AI chat conversations", + description: Some("Chat assistant conversations"), + unit: Some("conversations"), + format: "integer", + direction: "higher_is_better", + entity_type: "person", + computation_type: "sum", + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit"), + inputs: &[InputSeed { + input_role: "value", + measure_key: "chat_assistant_conversations", + }], + dimensions: &["tool", "surface"], + }, +]; + +#[cfg(test)] +mod tests { + use std::collections::{BTreeSet, HashMap}; + + use super::*; + + fn is_snake_case(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + } + + fn is_metric_key(value: &str) -> bool { + let parts = value.split('.').collect::>(); + parts.len() == 2 && parts.iter().all(|part| is_snake_case(part)) + } + + #[test] + fn source_keys_are_unique_and_shaped() { + let mut seen = BTreeSet::new(); + for builtin_source in BUILTIN_SOURCES { + assert!(is_snake_case(builtin_source.source.key)); + assert!(seen.insert(builtin_source.source.key)); + } + } + + #[test] + fn measure_and_dimension_keys_are_unique_per_source() { + for builtin_source in BUILTIN_SOURCES { + let mut measures = BTreeSet::new(); + for measure in builtin_source.measures { + assert!(is_snake_case(measure.measure_key)); + assert!(measures.insert(measure.measure_key)); + } + let mut dimensions = BTreeSet::new(); + for dimension in builtin_source.dimensions { + assert!(is_snake_case(dimension.dimension_key)); + assert!(dimensions.insert(dimension.dimension_key)); + } + } + } + + #[test] + fn metric_keys_are_unique_and_shaped() { + let mut seen = BTreeSet::new(); + for metric in BUILTIN_METRICS { + assert!(is_metric_key(metric.metric_key), "{}", metric.metric_key); + assert!(seen.insert(metric.metric_key)); + } + } + + #[test] + fn metric_inputs_reference_declared_measures() { + let measures_by_source: HashMap<&str, BTreeSet<&str>> = BUILTIN_SOURCES + .iter() + .map(|builtin_source| { + ( + builtin_source.source.key, + builtin_source + .measures + .iter() + .map(|measure| measure.measure_key) + .collect(), + ) + }) + .collect(); + + for metric in BUILTIN_METRICS { + let measures = measures_by_source + .get(metric.source_key) + .unwrap_or_else(|| panic!("unknown source for {}", metric.metric_key)); + assert!(!metric.inputs.is_empty(), "{}", metric.metric_key); + for input in metric.inputs { + assert!( + measures.contains(input.measure_key), + "{} references undeclared measure {}", + metric.metric_key, + input.measure_key + ); + } + } + } + + #[test] + fn metric_dimensions_reference_declared_source_dimensions() { + let dimensions_by_source: HashMap<&str, BTreeSet<&str>> = BUILTIN_SOURCES + .iter() + .map(|builtin_source| { + ( + builtin_source.source.key, + builtin_source + .dimensions + .iter() + .map(|dimension| dimension.dimension_key) + .collect(), + ) + }) + .collect(); + + for metric in BUILTIN_METRICS { + let Some(dimensions) = dimensions_by_source.get(metric.source_key) else { + panic!("unknown source for {}", metric.metric_key); + }; + for dimension in metric.dimensions { + assert!( + dimensions.contains(dimension), + "{} references undeclared dimension {dimension}", + metric.metric_key + ); + } + } + } + + #[test] + fn computation_fields_satisfy_db_check() { + for metric in BUILTIN_METRICS { + match metric.computation_type { + "sum" | "count" | "count_distinct" | "derived" => { + assert!(metric.scale.is_none(), "{}", metric.metric_key); + assert!( + metric.distribution_statistic.is_none(), + "{}", + metric.metric_key + ); + assert!(metric.gauge_method.is_none(), "{}", metric.metric_key); + } + "ratio" => { + assert!(metric.scale.is_some(), "{}", metric.metric_key); + assert!( + metric.distribution_statistic.is_none(), + "{}", + metric.metric_key + ); + assert!(metric.gauge_method.is_none(), "{}", metric.metric_key); + } + "distribution" => { + assert!( + metric.distribution_statistic.is_some(), + "{}", + metric.metric_key + ); + } + "gauge" => { + assert!(metric.gauge_method.is_some(), "{}", metric.metric_key); + } + other => panic!("unknown computation {other} for {}", metric.metric_key), + } + } + } + + #[test] + fn ratio_metrics_have_numerator_and_denominator_roles() { + for metric in BUILTIN_METRICS { + if metric.computation_type != "ratio" { + continue; + } + let roles = metric + .inputs + .iter() + .map(|input| input.input_role) + .collect::>(); + assert!(roles.contains("numerator"), "{}", metric.metric_key); + assert!(roles.contains("denominator"), "{}", metric.metric_key); + } + } +} diff --git a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs new file mode 100644 index 000000000..4ac0dc018 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs @@ -0,0 +1,333 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MetricDirection { + HigherIsBetter, + LowerIsBetter, + Neutral, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MetricFormat { + Integer, + Decimal, + Currency, + Percent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MetricComputation { + Sum, + Count, + CountDistinct, + Ratio, + Distribution, + Gauge, + Derived, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MetricInputRole { + Value, + Event, + Numerator, + Denominator, + Sample, + Snapshot, + Dependency, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DistributionStatistic { + P50, + P75, + P90, + P95, + P99, + Avg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GaugeMethod { + Latest, + Min, + Max, + Avg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservationSource { + AiMetricObservations, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CohortSource { + MetricEntityCohortsCurrent, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MetricDefinition { + Sum(SumMetricDefinition), + Count(CountMetricDefinition), + CountDistinct(CountDistinctMetricDefinition), + Ratio(RatioMetricDefinition), + Distribution(DistributionMetricDefinition), + Gauge(GaugeMetricDefinition), + Derived(DerivedMetricDefinition), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MetricBase { + pub key: String, + pub label: String, + pub description: Option, + pub entity_type: String, + pub format: MetricFormat, + pub unit: Option, + pub direction: MetricDirection, + pub peer_cohort_key: Option, + pub allowed_dimensions: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MetricInput { + pub role: MetricInputRole, + pub observation_source: ObservationSource, + pub source_key: String, + pub measure_key: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SumMetricDefinition { + pub base: MetricBase, + pub value: MetricInput, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CountMetricDefinition { + pub base: MetricBase, + pub event: MetricInput, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CountDistinctMetricDefinition { + pub base: MetricBase, + pub event: MetricInput, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RatioMetricDefinition { + pub base: MetricBase, + pub numerator: MetricInput, + pub denominator: MetricInput, + pub scale: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DistributionMetricDefinition { + pub base: MetricBase, + pub sample: MetricInput, + pub statistic: DistributionStatistic, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GaugeMetricDefinition { + pub base: MetricBase, + pub snapshot: MetricInput, + pub method: GaugeMethod, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DerivedMetricDefinition { + pub base: MetricBase, + pub dependencies: Vec, +} + +impl MetricDefinition { + pub fn key(&self) -> &str { + self.base().key.as_str() + } + + pub fn base(&self) -> &MetricBase { + match self { + Self::Sum(def) => &def.base, + Self::Count(def) => &def.base, + Self::CountDistinct(def) => &def.base, + Self::Ratio(def) => &def.base, + Self::Distribution(def) => &def.base, + Self::Gauge(def) => &def.base, + Self::Derived(def) => &def.base, + } + } + + pub fn computation(&self) -> MetricComputation { + match self { + Self::Sum(_) => MetricComputation::Sum, + Self::Count(_) => MetricComputation::Count, + Self::CountDistinct(_) => MetricComputation::CountDistinct, + Self::Ratio(_) => MetricComputation::Ratio, + Self::Distribution(_) => MetricComputation::Distribution, + Self::Gauge(_) => MetricComputation::Gauge, + Self::Derived(_) => MetricComputation::Derived, + } + } + + pub fn allowed_dimension(&self, dimension: &str) -> Option<&str> { + self.base() + .allowed_dimensions + .iter() + .map(String::as_str) + .find(|d| *d == dimension) + } + + pub fn executable(&self) -> Option { + match self { + Self::Sum(def) => Some(ExecutableMetric::Sum(def.clone())), + Self::Ratio(def) => Some(ExecutableMetric::Ratio(def.clone())), + Self::Count(_) + | Self::CountDistinct(_) + | Self::Distribution(_) + | Self::Gauge(_) + | Self::Derived(_) => None, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ExecutableMetric { + Sum(SumMetricDefinition), + Ratio(RatioMetricDefinition), +} + +impl ExecutableMetric { + pub fn is_zero_filled(&self) -> bool { + matches!(self, Self::Sum(_)) + } + + pub fn observation_source(&self) -> ObservationSource { + match self { + Self::Sum(def) => def.value.observation_source, + Self::Ratio(def) => def.numerator.observation_source, + } + } +} + +impl ObservationSource { + pub fn from_ref(value: &str) -> Option { + match value { + "ai_metric_observations" => Some(Self::AiMetricObservations), + _ => None, + } + } + + pub fn table_ref(self) -> (&'static str, &'static str) { + match self { + Self::AiMetricObservations => ("insight", "ai_metric_observations"), + } + } +} + +impl CohortSource { + pub fn table_ref(self) -> (&'static str, &'static str) { + match self { + Self::MetricEntityCohortsCurrent => ("insight", "metric_entity_cohorts_current"), + } + } +} + +impl MetricFormat { + pub fn from_db(value: &str) -> Option { + match value { + "integer" => Some(Self::Integer), + "decimal" => Some(Self::Decimal), + "currency" => Some(Self::Currency), + "percent" => Some(Self::Percent), + _ => None, + } + } +} + +impl MetricDirection { + pub fn from_db(value: &str) -> Option { + match value { + "higher_is_better" => Some(Self::HigherIsBetter), + "lower_is_better" => Some(Self::LowerIsBetter), + "neutral" => Some(Self::Neutral), + _ => None, + } + } +} + +impl MetricComputation { + pub fn from_db(value: &str) -> Option { + match value { + "sum" => Some(Self::Sum), + "count" => Some(Self::Count), + "count_distinct" => Some(Self::CountDistinct), + "ratio" => Some(Self::Ratio), + "distribution" => Some(Self::Distribution), + "gauge" => Some(Self::Gauge), + "derived" => Some(Self::Derived), + _ => None, + } + } + + pub fn as_db(self) -> &'static str { + match self { + Self::Sum => "sum", + Self::Count => "count", + Self::CountDistinct => "count_distinct", + Self::Ratio => "ratio", + Self::Distribution => "distribution", + Self::Gauge => "gauge", + Self::Derived => "derived", + } + } +} + +impl MetricInputRole { + pub fn from_db(value: &str) -> Option { + match value { + "value" => Some(Self::Value), + "event" => Some(Self::Event), + "numerator" => Some(Self::Numerator), + "denominator" => Some(Self::Denominator), + "sample" => Some(Self::Sample), + "snapshot" => Some(Self::Snapshot), + "dependency" => Some(Self::Dependency), + _ => None, + } + } +} + +impl DistributionStatistic { + pub fn from_db(value: &str) -> Option { + match value { + "p50" => Some(Self::P50), + "p75" => Some(Self::P75), + "p90" => Some(Self::P90), + "p95" => Some(Self::P95), + "p99" => Some(Self::P99), + "avg" => Some(Self::Avg), + _ => None, + } + } +} + +impl GaugeMethod { + pub fn from_db(value: &str) -> Option { + match value { + "latest" => Some(Self::Latest), + "min" => Some(Self::Min), + "max" => Some(Self::Max), + "avg" => Some(Self::Avg), + _ => None, + } + } +} diff --git a/src/backend/services/analytics/src/domain/metric_definitions/error_code.rs b/src/backend/services/analytics/src/domain/metric_definitions/error_code.rs new file mode 100644 index 000000000..6f882d82c --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/error_code.rs @@ -0,0 +1,51 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MetricSchemaErrorCode { + TableNotFound, + ColumnNotFound, + DimensionNotCovered, + Unknown, +} + +#[cfg(test)] +pub const ALL_METRIC_SCHEMA_ERROR_CODES: &[MetricSchemaErrorCode] = &[ + MetricSchemaErrorCode::TableNotFound, + MetricSchemaErrorCode::ColumnNotFound, + MetricSchemaErrorCode::DimensionNotCovered, + MetricSchemaErrorCode::Unknown, +]; + +impl MetricSchemaErrorCode { + #[must_use] + pub fn as_db_str(self) -> &'static str { + match self { + Self::TableNotFound => "table_not_found", + Self::ColumnNotFound => "column_not_found", + Self::DimensionNotCovered => "dimension_not_covered", + Self::Unknown => "unknown", + } + } +} + +impl fmt::Display for MetricSchemaErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_db_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_codes_listed_once() { + let mut strings = ALL_METRIC_SCHEMA_ERROR_CODES + .iter() + .map(|code| code.as_db_str()) + .collect::>(); + strings.sort_unstable(); + strings.dedup(); + assert_eq!(strings.len(), ALL_METRIC_SCHEMA_ERROR_CODES.len()); + } +} diff --git a/src/backend/services/analytics/src/domain/metric_definitions/mod.rs b/src/backend/services/analytics/src/domain/metric_definitions/mod.rs new file mode 100644 index 000000000..592f61a44 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/mod.rs @@ -0,0 +1,14 @@ +pub mod builtin; +pub mod definition; +pub mod error_code; +mod repository; +mod seeds; +pub mod validator; + +pub use definition::{ + CohortSource, DistributionStatistic, ExecutableMetric, GaugeMethod, MetricDefinition, + MetricDirection, MetricFormat, ObservationSource, +}; +pub use repository::load_definitions; +pub use seeds::reconcile_builtin_definitions; +pub use validator::MetricDefinitionValidator; diff --git a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs new file mode 100644 index 000000000..6428b31b0 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs @@ -0,0 +1,947 @@ +use std::collections::{BTreeMap, HashMap}; + +use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, Value}; +use toolkit_canonical_errors::CanonicalError; +use uuid::Uuid; + +use crate::api::error::MetricError; +use crate::domain::metric_definitions::definition::{ + CountDistinctMetricDefinition, CountMetricDefinition, DerivedMetricDefinition, + DistributionMetricDefinition, DistributionStatistic, GaugeMethod, GaugeMetricDefinition, + MetricBase, MetricComputation, MetricDefinition, MetricDirection, MetricFormat, MetricInput, + MetricInputRole, ObservationSource, RatioMetricDefinition, SumMetricDefinition, +}; + +#[derive(Debug, FromQueryResult)] +struct DefinitionRow { + definition_id: Uuid, + tenant_id: Option, + metric_key: String, + label: String, + description: Option, + unit: Option, + format: String, + direction: String, + entity_type: String, + computation_type: String, + scale: Option, + distribution_statistic: Option, + gauge_method: Option, + peer_cohort_key: Option, + definition_enabled: bool, + definition_schema_status: String, +} + +#[derive(Debug, FromQueryResult)] +struct InputRow { + metric_definition_id: Uuid, + input_role: String, + measure_key: String, + measure_enabled: bool, + measure_schema_status: String, + source_key: String, + source_kind: String, + source_ref: String, + source_enabled: bool, + source_schema_status: String, +} + +#[derive(Debug, FromQueryResult)] +struct DimensionRow { + metric_definition_id: Uuid, + dimension_key: String, +} + +#[derive(Debug)] +enum ClassifiedInputs { + Available(Vec), + Unavailable, + Corrupt, +} + +#[derive(Debug, Clone)] +pub struct MetricDefinitionValidationSpec { + pub definition_id: Uuid, + pub metric_key: String, + pub entity_type: String, + pub inputs: Vec, + pub dimensions: Vec, +} + +#[derive(Debug, FromQueryResult)] +struct ValidationDefinitionRow { + definition_id: Uuid, + metric_key: String, + entity_type: String, +} + +pub async fn load_definitions( + db: &DatabaseConnection, + tenant_id: Uuid, + metric_keys: &[String], +) -> Result, CanonicalError> { + if metric_keys.is_empty() { + return Ok(HashMap::new()); + } + + let rows = fetch_definition_rows(db, tenant_id, metric_keys) + .await + .map_err(|error| db_error(&error))?; + let all_ids = rows.iter().map(|row| row.definition_id).collect::>(); + let input_rows = fetch_input_rows(db, &all_ids) + .await + .map_err(|error| db_error(&error))?; + let inputs = classify_inputs(input_rows); + let dimensions = fetch_dimensions(db, &all_ids) + .await + .map_err(|error| db_error(&error))?; + + let mut definitions = HashMap::new(); + for (metric_key, candidates) in group_by_key(rows) { + let Some(row) = select_available_row(&metric_key, candidates, &inputs)? else { + continue; + }; + let definition_id = row.definition_id; + let row_inputs = match inputs.get(&definition_id) { + Some(ClassifiedInputs::Available(row_inputs)) => row_inputs.clone(), + Some(ClassifiedInputs::Unavailable | ClassifiedInputs::Corrupt) | None => Vec::new(), + }; + let definition = build_definition( + &row, + row_inputs, + dimensions.get(&definition_id).cloned().unwrap_or_default(), + )?; + definitions.insert(metric_key, definition); + } + + for key in metric_keys { + if !definitions.contains_key(key) { + return Err(unavailable(key)); + } + } + + Ok(definitions) +} + +pub async fn managed_definition_validation_specs( + db: &DatabaseConnection, + source_id: Uuid, +) -> Result, sea_orm::DbErr> { + let rows = fetch_validation_definition_rows(db, source_id).await?; + let definition_ids = rows.iter().map(|row| row.definition_id).collect::>(); + let inputs = classify_inputs(fetch_input_rows(db, &definition_ids).await?); + let dimensions = fetch_dimensions(db, &definition_ids).await?; + + Ok(rows + .into_iter() + .filter_map(|row| { + let definition_id = row.definition_id; + match inputs.get(&definition_id) { + Some(ClassifiedInputs::Available(row_inputs)) => { + Some(MetricDefinitionValidationSpec { + definition_id, + metric_key: row.metric_key, + entity_type: row.entity_type, + inputs: row_inputs.clone(), + dimensions: dimensions.get(&definition_id).cloned().unwrap_or_default(), + }) + } + Some(ClassifiedInputs::Unavailable | ClassifiedInputs::Corrupt) | None => None, + } + }) + .collect()) +} + +async fn fetch_validation_definition_rows( + db: &DatabaseConnection, + source_id: Uuid, +) -> Result, sea_orm::DbErr> { + ValidationDefinitionRow::find_by_statement(Statement::from_sql_and_values( + db.get_database_backend(), + "SELECT DISTINCT \ + d.id AS definition_id, \ + d.metric_key AS metric_key, \ + d.entity_type AS entity_type \ + FROM metric_definitions d \ + INNER JOIN metric_definition_inputs i ON i.metric_definition_id = d.id \ + INNER JOIN metric_source_measures m ON m.id = i.source_measure_id \ + WHERE d.is_enabled = TRUE \ + AND m.is_enabled = TRUE \ + AND m.source_id = ? \ + ORDER BY d.metric_key", + [uuid_value(source_id)], + )) + .all(db) + .await +} + +async fn fetch_definition_rows( + db: &DatabaseConnection, + tenant_id: Uuid, + metric_keys: &[String], +) -> Result, sea_orm::DbErr> { + let placeholders = vec!["?"; metric_keys.len()].join(", "); + let sql = format!( + "SELECT \ + d.id AS definition_id, \ + d.tenant_id AS tenant_id, \ + d.metric_key AS metric_key, \ + d.label AS label, \ + d.description AS description, \ + d.unit AS unit, \ + d.format AS format, \ + d.direction AS direction, \ + d.entity_type AS entity_type, \ + d.computation_type AS computation_type, \ + CAST(d.scale AS DOUBLE) AS scale, \ + d.distribution_statistic AS distribution_statistic, \ + d.gauge_method AS gauge_method, \ + d.peer_cohort_key AS peer_cohort_key, \ + d.is_enabled AS definition_enabled, \ + d.schema_status AS definition_schema_status \ + FROM metric_definitions d \ + WHERE d.metric_key IN ({placeholders}) \ + AND (d.tenant_id IS NULL OR d.tenant_id = ?)" + ); + + let mut values = metric_keys.iter().map(Value::from).collect::>(); + values.push(Value::Bytes(Some(Box::new(tenant_id.as_bytes().to_vec())))); + + DefinitionRow::find_by_statement(Statement::from_sql_and_values( + db.get_database_backend(), + sql, + values, + )) + .all(db) + .await +} + +async fn fetch_input_rows( + db: &DatabaseConnection, + definition_ids: &[Uuid], +) -> Result, sea_orm::DbErr> { + if definition_ids.is_empty() { + return Ok(Vec::new()); + } + + let placeholders = vec!["?"; definition_ids.len()].join(", "); + let sql = format!( + "SELECT \ + i.metric_definition_id AS metric_definition_id, \ + i.input_role AS input_role, \ + m.measure_key AS measure_key, \ + m.is_enabled AS measure_enabled, \ + m.schema_status AS measure_schema_status, \ + s.source_key AS source_key, \ + s.source_kind AS source_kind, \ + s.source_ref AS source_ref, \ + s.is_enabled AS source_enabled, \ + s.schema_status AS source_schema_status \ + FROM metric_definition_inputs i \ + INNER JOIN metric_source_measures m ON m.id = i.source_measure_id \ + INNER JOIN metric_sources s ON s.id = m.source_id \ + WHERE i.metric_definition_id IN ({placeholders}) \ + ORDER BY i.metric_definition_id, i.input_role, m.measure_key" + ); + let values = definition_ids + .iter() + .map(|id| Value::Bytes(Some(Box::new(id.as_bytes().to_vec())))) + .collect::>(); + + InputRow::find_by_statement(Statement::from_sql_and_values( + db.get_database_backend(), + sql, + values, + )) + .all(db) + .await +} + +fn classify_inputs(rows: Vec) -> HashMap { + let mut out: HashMap = HashMap::new(); + for row in rows { + let entry = out + .entry(row.metric_definition_id) + .or_insert_with(|| ClassifiedInputs::Available(Vec::new())); + + // Corrupt config must stay loud regardless of row order: it wins + // over Unavailable, which wins over Available. + let role = MetricInputRole::from_db(&row.input_role); + let observation_source = ObservationSource::from_ref(&row.source_ref); + let parsed = match (role, observation_source) { + (Some(role), Some(observation_source)) if row.source_kind == "managed_observation" => { + Some((role, observation_source)) + } + _ => None, + }; + let Some((role, observation_source)) = parsed else { + tracing::error!( + input_role = %row.input_role, + source_ref = %row.source_ref, + source_kind = %row.source_kind, + "corrupt metric definition input" + ); + *entry = ClassifiedInputs::Corrupt; + continue; + }; + if matches!(entry, ClassifiedInputs::Corrupt) { + continue; + } + + if !row.measure_enabled + || !row.source_enabled + || row.measure_schema_status == "error" + || row.source_schema_status == "error" + { + *entry = ClassifiedInputs::Unavailable; + continue; + } + + if let ClassifiedInputs::Available(inputs) = entry { + inputs.push(MetricInput { + role, + observation_source, + source_key: row.source_key, + measure_key: row.measure_key, + }); + } + } + out +} + +fn group_by_key(rows: Vec) -> BTreeMap> { + let mut grouped: BTreeMap> = BTreeMap::new(); + for row in rows { + grouped.entry(row.metric_key.clone()).or_default().push(row); + } + grouped +} + +fn select_available_row( + metric_key: &str, + rows: Vec, + inputs: &HashMap, +) -> Result, CanonicalError> { + let tenant_rows = rows.iter().filter(|row| row.tenant_id.is_some()).count(); + if tenant_rows > 1 { + return Err(config_error(&format!( + "multiple tenant metric definitions for {metric_key}" + ))); + } + let product_rows = rows.iter().filter(|row| row.tenant_id.is_none()).count(); + if product_rows > 1 { + return Err(config_error(&format!( + "multiple product metric definitions for {metric_key}" + ))); + } + + let mut candidates = rows; + candidates.sort_by_key(|row| row.tenant_id.is_none()); + + for row in candidates { + if matches!( + inputs.get(&row.definition_id), + Some(ClassifiedInputs::Corrupt) + ) { + return Err(config_error(&format!( + "corrupt inputs for metric definition {metric_key}" + ))); + } + let inputs_available = !matches!( + inputs.get(&row.definition_id), + Some(ClassifiedInputs::Unavailable) + ); + if row.definition_enabled && row.definition_schema_status != "error" && inputs_available { + return Ok(Some(row)); + } + } + Ok(None) +} + +async fn fetch_dimensions( + db: &DatabaseConnection, + definition_ids: &[Uuid], +) -> Result>, sea_orm::DbErr> { + if definition_ids.is_empty() { + return Ok(HashMap::new()); + } + + let placeholders = vec!["?"; definition_ids.len()].join(", "); + let sql = format!( + "SELECT \ + d.metric_definition_id AS metric_definition_id, \ + s.dimension_key AS dimension_key \ + FROM metric_definition_dimensions d \ + INNER JOIN metric_source_dimensions s ON s.id = d.source_dimension_id \ + WHERE d.metric_definition_id IN ({placeholders}) \ + ORDER BY d.metric_definition_id, d.display_order, s.dimension_key" + ); + let values = definition_ids + .iter() + .map(|id| Value::Bytes(Some(Box::new(id.as_bytes().to_vec())))) + .collect::>(); + + DimensionRow::find_by_statement(Statement::from_sql_and_values( + db.get_database_backend(), + sql, + values, + )) + .all(db) + .await + .map(|rows| { + let mut out: HashMap> = HashMap::new(); + for row in rows { + out.entry(row.metric_definition_id) + .or_default() + .push(row.dimension_key); + } + out + }) +} + +fn build_definition( + row: &DefinitionRow, + inputs: Vec, + allowed_dimensions: Vec, +) -> Result { + let computation = MetricComputation::from_db(&row.computation_type).ok_or_else(|| { + config_error(&format!( + "unknown metric computation for {}", + row.metric_key + )) + })?; + let base = build_base(row, allowed_dimensions)?; + + match computation { + MetricComputation::Sum => Ok(MetricDefinition::Sum(SumMetricDefinition { + base, + value: one_input(&row.metric_key, &inputs, MetricInputRole::Value)?, + })), + MetricComputation::Count => Ok(MetricDefinition::Count(CountMetricDefinition { + base, + event: one_input(&row.metric_key, &inputs, MetricInputRole::Event)?, + })), + MetricComputation::CountDistinct => Ok(MetricDefinition::CountDistinct( + CountDistinctMetricDefinition { + base, + event: one_input(&row.metric_key, &inputs, MetricInputRole::Event)?, + }, + )), + MetricComputation::Ratio => build_ratio_definition(base, row, &inputs), + MetricComputation::Distribution => build_distribution_definition(base, row, &inputs), + MetricComputation::Gauge => build_gauge_definition(base, row, &inputs), + MetricComputation::Derived => build_derived_definition(base, &row.metric_key, inputs), + } +} + +fn build_base( + row: &DefinitionRow, + allowed_dimensions: Vec, +) -> Result { + let format = MetricFormat::from_db(&row.format) + .ok_or_else(|| config_error(&format!("unknown metric format for {}", row.metric_key)))?; + let direction = MetricDirection::from_db(&row.direction) + .ok_or_else(|| config_error(&format!("unknown metric direction for {}", row.metric_key)))?; + + Ok(MetricBase { + key: row.metric_key.clone(), + label: row.label.clone(), + description: row.description.clone(), + entity_type: row.entity_type.clone(), + format, + unit: row.unit.clone(), + direction, + peer_cohort_key: row.peer_cohort_key.clone(), + allowed_dimensions, + }) +} + +fn build_ratio_definition( + base: MetricBase, + row: &DefinitionRow, + inputs: &[MetricInput], +) -> Result { + let numerator = one_input(&row.metric_key, inputs, MetricInputRole::Numerator)?; + let denominator = one_input(&row.metric_key, inputs, MetricInputRole::Denominator)?; + if numerator.observation_source != denominator.observation_source + || numerator.source_key != denominator.source_key + { + return Err(config_error(&format!( + "ratio inputs must share one source for {}", + row.metric_key + ))); + } + let scale = row + .scale + .ok_or_else(|| config_error(&format!("missing ratio scale for {}", row.metric_key)))?; + + Ok(MetricDefinition::Ratio(RatioMetricDefinition { + base, + numerator, + denominator, + scale, + })) +} + +fn build_distribution_definition( + base: MetricBase, + row: &DefinitionRow, + inputs: &[MetricInput], +) -> Result { + let statistic = row + .distribution_statistic + .as_deref() + .and_then(DistributionStatistic::from_db) + .ok_or_else(|| { + config_error(&format!( + "missing distribution statistic for {}", + row.metric_key + )) + })?; + + Ok(MetricDefinition::Distribution( + DistributionMetricDefinition { + base, + sample: one_input(&row.metric_key, inputs, MetricInputRole::Sample)?, + statistic, + }, + )) +} + +fn build_gauge_definition( + base: MetricBase, + row: &DefinitionRow, + inputs: &[MetricInput], +) -> Result { + let method = row + .gauge_method + .as_deref() + .and_then(GaugeMethod::from_db) + .ok_or_else(|| config_error(&format!("missing gauge method for {}", row.metric_key)))?; + + Ok(MetricDefinition::Gauge(GaugeMetricDefinition { + base, + snapshot: one_input(&row.metric_key, inputs, MetricInputRole::Snapshot)?, + method, + })) +} + +fn build_derived_definition( + base: MetricBase, + metric_key: &str, + inputs: Vec, +) -> Result { + let dependencies = inputs + .into_iter() + .filter(|input| input.role == MetricInputRole::Dependency) + .collect::>(); + if dependencies.is_empty() { + return Err(config_error(&format!( + "missing derived dependencies for {metric_key}" + ))); + } + + Ok(MetricDefinition::Derived(DerivedMetricDefinition { + base, + dependencies, + })) +} + +fn one_input( + metric_key: &str, + inputs: &[MetricInput], + role: MetricInputRole, +) -> Result { + let matches = inputs + .iter() + .filter(|input| input.role == role) + .cloned() + .collect::>(); + match matches.as_slice() { + [input] => Ok(input.clone()), + [] => Err(config_error(&format!( + "missing {role:?} input for {metric_key}" + ))), + _ => Err(config_error(&format!( + "duplicate {role:?} inputs for {metric_key}" + ))), + } +} + +pub async fn all_managed_sources( + db: &DatabaseConnection, +) -> Result, sea_orm::DbErr> { + #[derive(FromQueryResult)] + struct Row { + id: Uuid, + source_kind: String, + source_ref: String, + } + + Row::find_by_statement(Statement::from_string( + db.get_database_backend(), + "SELECT id, source_kind, source_ref \ + FROM metric_sources \ + WHERE is_enabled = TRUE", + )) + .all(db) + .await + .map(|rows| { + rows.into_iter() + .map(|row| (row.id, row.source_kind, row.source_ref)) + .collect() + }) +} + +pub async fn update_source_status( + db: &DatabaseConnection, + source_id: Uuid, + status: &str, + error_code: Option<&str>, +) -> Result<(), sea_orm::DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "UPDATE metric_sources \ + SET schema_status = ?, \ + schema_checked_at = CURRENT_TIMESTAMP(3), \ + schema_error_code = ?, \ + updated_at = updated_at \ + WHERE id = ?", + [ + Value::from(status), + match error_code { + Some(code) => Value::from(code), + None => Value::String(None), + }, + Value::Bytes(Some(Box::new(source_id.as_bytes().to_vec()))), + ], + )) + .await?; + Ok(()) +} + +pub async fn update_definitions_for_source_status( + db: &DatabaseConnection, + source_id: Uuid, + status: &str, + error_code: Option<&str>, +) -> Result<(), sea_orm::DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "UPDATE metric_definitions \ + SET schema_status = ?, \ + schema_checked_at = CURRENT_TIMESTAMP(3), \ + schema_error_code = ?, \ + updated_at = updated_at \ + WHERE id IN ( \ + SELECT metric_definition_id \ + FROM metric_definition_inputs i \ + INNER JOIN metric_source_measures m ON m.id = i.source_measure_id \ + WHERE m.source_id = ? \ + )", + [ + Value::from(status), + match error_code { + Some(code) => Value::from(code), + None => Value::String(None), + }, + Value::Bytes(Some(Box::new(source_id.as_bytes().to_vec()))), + ], + )) + .await?; + Ok(()) +} + +pub async fn update_definition_status( + db: &DatabaseConnection, + definition_id: Uuid, + status: &str, + error_code: Option<&str>, +) -> Result<(), sea_orm::DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "UPDATE metric_definitions \ + SET schema_status = ?, \ + schema_checked_at = CURRENT_TIMESTAMP(3), \ + schema_error_code = ?, \ + updated_at = updated_at \ + WHERE id = ?", + [ + Value::from(status), + match error_code { + Some(code) => Value::from(code), + None => Value::String(None), + }, + uuid_value(definition_id), + ], + )) + .await?; + Ok(()) +} + +fn uuid_value(value: Uuid) -> Value { + Value::Bytes(Some(Box::new(value.as_bytes().to_vec()))) +} + +fn unavailable(metric_key: &str) -> CanonicalError { + MetricError::invalid_argument() + .with_field_violation( + "metrics.metric_key", + format!("unknown or unavailable metric key: {metric_key}"), + "UNAVAILABLE", + ) + .create() +} + +fn config_error(message: &str) -> CanonicalError { + tracing::error!(message = %message, "metric definition configuration error"); + CanonicalError::internal("metric definition configuration error").create() +} + +fn db_error(error: &sea_orm::DbErr) -> CanonicalError { + tracing::error!(error = %error, "metric definition database query failed"); + CanonicalError::internal("metric definition lookup failed").create() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn definition_row( + metric_key: &str, + tenant: Option, + enabled: bool, + schema_status: &str, + ) -> DefinitionRow { + DefinitionRow { + definition_id: Uuid::now_v7(), + tenant_id: tenant, + metric_key: metric_key.to_owned(), + label: "Label".to_owned(), + description: None, + unit: None, + format: "integer".to_owned(), + direction: "neutral".to_owned(), + entity_type: "person".to_owned(), + computation_type: "sum".to_owned(), + scale: None, + distribution_statistic: None, + gauge_method: None, + peer_cohort_key: Some("org_unit".to_owned()), + definition_enabled: enabled, + definition_schema_status: schema_status.to_owned(), + } + } + + fn input_row(definition_id: Uuid, role: &str, enabled: bool, status: &str) -> InputRow { + InputRow { + metric_definition_id: definition_id, + input_role: role.to_owned(), + measure_key: "accepted_lines".to_owned(), + measure_enabled: enabled, + measure_schema_status: status.to_owned(), + source_key: "ai_usage".to_owned(), + source_kind: "managed_observation".to_owned(), + source_ref: "ai_metric_observations".to_owned(), + source_enabled: true, + source_schema_status: "ok".to_owned(), + } + } + + fn available_inputs(id: Uuid) -> HashMap { + classify_inputs(vec![input_row(id, "value", true, "ok")]) + } + + #[test] + fn selects_tenant_row_over_product() { + let tenant = Uuid::now_v7(); + let tenant_row = definition_row("ai.x", Some(tenant), true, "ok"); + let product_row = definition_row("ai.x", None, true, "ok"); + let tenant_id = tenant_row.definition_id; + let mut inputs = available_inputs(tenant_id); + inputs.extend(available_inputs(product_row.definition_id)); + + let Ok(Some(selected)) = + select_available_row("ai.x", vec![product_row, tenant_row], &inputs) + else { + panic!("expected selected row"); + }; + assert_eq!(selected.definition_id, tenant_id); + } + + #[test] + fn disabled_tenant_row_falls_back_to_product() { + let tenant = Uuid::now_v7(); + let tenant_row = definition_row("ai.x", Some(tenant), false, "ok"); + let product_row = definition_row("ai.x", None, true, "ok"); + let product_id = product_row.definition_id; + let mut inputs = available_inputs(tenant_row.definition_id); + inputs.extend(available_inputs(product_id)); + + let Ok(Some(selected)) = + select_available_row("ai.x", vec![tenant_row, product_row], &inputs) + else { + panic!("expected selected row"); + }; + assert_eq!(selected.definition_id, product_id); + } + + #[test] + fn schema_error_tenant_row_falls_back_to_product() { + let tenant = Uuid::now_v7(); + let tenant_row = definition_row("ai.x", Some(tenant), true, "error"); + let product_row = definition_row("ai.x", None, true, "ok"); + let product_id = product_row.definition_id; + let mut inputs = available_inputs(tenant_row.definition_id); + inputs.extend(available_inputs(product_id)); + + let Ok(Some(selected)) = + select_available_row("ai.x", vec![tenant_row, product_row], &inputs) + else { + panic!("expected selected row"); + }; + assert_eq!(selected.definition_id, product_id); + } + + #[test] + fn unavailable_inputs_fall_back_to_product() { + let tenant = Uuid::now_v7(); + let tenant_row = definition_row("ai.x", Some(tenant), true, "ok"); + let product_row = definition_row("ai.x", None, true, "ok"); + let product_id = product_row.definition_id; + let mut inputs = classify_inputs(vec![input_row( + tenant_row.definition_id, + "value", + false, + "ok", + )]); + inputs.extend(available_inputs(product_id)); + + let Ok(Some(selected)) = + select_available_row("ai.x", vec![tenant_row, product_row], &inputs) + else { + panic!("expected selected row"); + }; + assert_eq!(selected.definition_id, product_id); + } + + #[test] + fn no_available_row_yields_none() { + let tenant = Uuid::now_v7(); + let tenant_row = definition_row("ai.x", Some(tenant), false, "ok"); + let product_row = definition_row("ai.x", None, true, "error"); + let mut inputs = available_inputs(tenant_row.definition_id); + inputs.extend(available_inputs(product_row.definition_id)); + + let Ok(selected) = select_available_row("ai.x", vec![tenant_row, product_row], &inputs) + else { + panic!("expected ok selection"); + }; + assert!(selected.is_none()); + } + + #[test] + fn product_only_selection_works() { + let product_row = definition_row("ai.x", None, true, "ok"); + let product_id = product_row.definition_id; + let inputs = available_inputs(product_id); + + let Ok(Some(selected)) = select_available_row("ai.x", vec![product_row], &inputs) else { + panic!("expected selected row"); + }; + assert_eq!(selected.definition_id, product_id); + } + + #[test] + fn duplicate_tenant_rows_are_config_errors() { + let tenant = Uuid::now_v7(); + let rows = vec![ + definition_row("ai.x", Some(tenant), true, "ok"), + definition_row("ai.x", Some(tenant), true, "ok"), + ]; + assert!(select_available_row("ai.x", rows, &HashMap::new()).is_err()); + } + + #[test] + fn corrupt_inputs_are_config_errors_not_fallback() { + let tenant = Uuid::now_v7(); + let tenant_row = definition_row("ai.x", Some(tenant), true, "ok"); + let product_row = definition_row("ai.x", None, true, "ok"); + let mut inputs = classify_inputs(vec![InputRow { + input_role: "nonsense".to_owned(), + ..input_row(tenant_row.definition_id, "value", true, "ok") + }]); + inputs.extend(available_inputs(product_row.definition_id)); + + assert!(select_available_row("ai.x", vec![tenant_row, product_row], &inputs).is_err()); + } + + #[test] + fn classify_corrupt_wins_over_earlier_unavailable_row() { + let id = Uuid::now_v7(); + let disabled = input_row(id, "value", false, "ok"); + let corrupt = InputRow { + input_role: "nonsense".to_owned(), + ..input_row(id, "value", true, "ok") + }; + let classified = classify_inputs(vec![disabled, corrupt]); + assert!(matches!( + classified.get(&id), + Some(ClassifiedInputs::Corrupt) + )); + } + + #[test] + fn classify_corrupt_is_not_downgraded_by_later_rows() { + let id = Uuid::now_v7(); + let corrupt = InputRow { + input_role: "nonsense".to_owned(), + ..input_row(id, "value", true, "ok") + }; + let disabled = input_row(id, "value", false, "ok"); + let available = input_row(id, "value", true, "ok"); + let classified = classify_inputs(vec![corrupt, disabled, available]); + assert!(matches!( + classified.get(&id), + Some(ClassifiedInputs::Corrupt) + )); + } + + #[test] + fn classify_marks_disabled_source_unavailable() { + let id = Uuid::now_v7(); + let mut row = input_row(id, "value", true, "ok"); + row.source_enabled = false; + let classified = classify_inputs(vec![row]); + assert!(matches!( + classified.get(&id), + Some(ClassifiedInputs::Unavailable) + )); + } + + #[test] + fn classify_keeps_available_inputs() { + let id = Uuid::now_v7(); + let classified = classify_inputs(vec![ + input_row(id, "numerator", true, "ok"), + input_row(id, "denominator", true, "ok"), + ]); + match classified.get(&id) { + Some(ClassifiedInputs::Available(inputs)) => assert_eq!(inputs.len(), 2), + other => panic!("expected available inputs, got {other:?}"), + } + } + + #[test] + fn one_input_rejects_missing_and_duplicate_roles() { + let input = MetricInput { + role: MetricInputRole::Value, + observation_source: ObservationSource::AiMetricObservations, + source_key: "ai_usage".to_owned(), + measure_key: "accepted_lines".to_owned(), + }; + assert!(one_input("ai.x", &[], MetricInputRole::Value).is_err()); + assert!(one_input("ai.x", std::slice::from_ref(&input), MetricInputRole::Value).is_ok()); + assert!(one_input("ai.x", &[input.clone(), input], MetricInputRole::Value).is_err()); + } +} diff --git a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs new file mode 100644 index 000000000..df631ab50 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs @@ -0,0 +1,363 @@ +use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, Statement, Value}; +use uuid::Uuid; + +use crate::domain::metric_definitions::builtin::{ + BUILTIN_METRICS, BUILTIN_SOURCES, BuiltinSource, InputSeed, MetricSeed, +}; + +pub async fn reconcile_builtin_definitions(db: &DatabaseConnection) -> Result<(), DbErr> { + for builtin_source in BUILTIN_SOURCES { + reconcile_source(db, builtin_source).await?; + } + + for metric in BUILTIN_METRICS { + let source_id = fetch_source_id(db, metric.source_key).await?; + upsert_metric(db, metric).await?; + let metric_id = fetch_metric_id(db, metric.metric_key).await?; + replace_inputs(db, source_id, metric_id, metric.inputs).await?; + replace_dimensions(db, source_id, metric_id, metric.dimensions).await?; + } + + disable_missing_builtin_rows(db).await?; + Ok(()) +} + +async fn reconcile_source( + db: &DatabaseConnection, + builtin_source: &BuiltinSource, +) -> Result<(), DbErr> { + upsert_source(db, builtin_source).await?; + let source_id = fetch_source_id(db, builtin_source.source.key).await?; + + for measure in builtin_source.measures { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO metric_source_measures \ + (id, source_id, measure_key, value_type, is_enabled) \ + VALUES (?, ?, ?, ?, TRUE) \ + ON DUPLICATE KEY UPDATE \ + value_type = VALUES(value_type), \ + is_enabled = VALUES(is_enabled)", + [ + uuid_value(Uuid::now_v7()), + uuid_value(source_id), + Value::from(measure.measure_key), + Value::from(measure.value_type), + ], + )) + .await?; + } + + for (idx, dimension) in builtin_source.dimensions.iter().enumerate() { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO metric_source_dimensions \ + (id, source_id, dimension_key, label, display_order) \ + VALUES (?, ?, ?, ?, ?) \ + ON DUPLICATE KEY UPDATE \ + label = VALUES(label), \ + display_order = VALUES(display_order)", + [ + uuid_value(Uuid::now_v7()), + uuid_value(source_id), + Value::from(dimension.dimension_key), + Value::from(dimension.label), + Value::from(order_value(idx)), + ], + )) + .await?; + } + + Ok(()) +} + +async fn upsert_source( + db: &DatabaseConnection, + builtin_source: &BuiltinSource, +) -> Result<(), DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO metric_sources \ + (id, tenant_id, source_key, source_kind, source_ref, origin, is_enabled) \ + VALUES (?, NULL, ?, ?, ?, 'builtin', TRUE) \ + ON DUPLICATE KEY UPDATE \ + source_kind = VALUES(source_kind), \ + source_ref = VALUES(source_ref), \ + origin = VALUES(origin), \ + is_enabled = VALUES(is_enabled)", + [ + uuid_value(Uuid::now_v7()), + Value::from(builtin_source.source.key), + Value::from(builtin_source.source.kind), + Value::from(builtin_source.source.ref_name), + ], + )) + .await?; + Ok(()) +} + +async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<(), DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO metric_definitions \ + (id, tenant_id, metric_key, label, description, unit, format, direction, entity_type, \ + computation_type, scale, distribution_statistic, gauge_method, peer_cohort_key, \ + origin, definition_version, is_enabled) \ + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'builtin', 1, TRUE) \ + ON DUPLICATE KEY UPDATE \ + label = VALUES(label), \ + description = VALUES(description), \ + unit = VALUES(unit), \ + format = VALUES(format), \ + direction = VALUES(direction), \ + entity_type = VALUES(entity_type), \ + computation_type = VALUES(computation_type), \ + scale = VALUES(scale), \ + distribution_statistic = VALUES(distribution_statistic), \ + gauge_method = VALUES(gauge_method), \ + peer_cohort_key = VALUES(peer_cohort_key), \ + origin = VALUES(origin), \ + definition_version = VALUES(definition_version), \ + is_enabled = VALUES(is_enabled)", + [ + uuid_value(Uuid::now_v7()), + Value::from(metric.metric_key), + Value::from(metric.label), + nullable_str(metric.description), + nullable_str(metric.unit), + Value::from(metric.format), + Value::from(metric.direction), + Value::from(metric.entity_type), + Value::from(metric.computation_type), + match metric.scale { + Some(scale) => Value::from(scale), + None => Value::Double(None), + }, + nullable_str(metric.distribution_statistic), + nullable_str(metric.gauge_method), + nullable_str(metric.peer_cohort_key), + ], + )) + .await?; + Ok(()) +} + +async fn replace_inputs( + db: &DatabaseConnection, + source_id: Uuid, + metric_id: Uuid, + inputs: &[InputSeed], +) -> Result<(), DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "DELETE FROM metric_definition_inputs WHERE metric_definition_id = ?", + [uuid_value(metric_id)], + )) + .await?; + + for (idx, input) in inputs.iter().enumerate() { + let measure_id = fetch_measure_id(db, source_id, input.measure_key).await?; + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO metric_definition_inputs \ + (id, metric_definition_id, input_role, source_measure_id, display_order) \ + VALUES (?, ?, ?, ?, ?)", + [ + uuid_value(Uuid::now_v7()), + uuid_value(metric_id), + Value::from(input.input_role), + uuid_value(measure_id), + Value::from(order_value(idx)), + ], + )) + .await?; + } + Ok(()) +} + +async fn replace_dimensions( + db: &DatabaseConnection, + source_id: Uuid, + metric_id: Uuid, + dimensions: &[&str], +) -> Result<(), DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "DELETE FROM metric_definition_dimensions WHERE metric_definition_id = ?", + [uuid_value(metric_id)], + )) + .await?; + + for (idx, dimension) in dimensions.iter().enumerate() { + let dimension_id = fetch_source_dimension_id(db, source_id, dimension).await?; + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO metric_definition_dimensions \ + (id, metric_definition_id, source_dimension_id, display_order) \ + VALUES (?, ?, ?, ?)", + [ + uuid_value(Uuid::now_v7()), + uuid_value(metric_id), + uuid_value(dimension_id), + Value::from(order_value(idx)), + ], + )) + .await?; + } + Ok(()) +} + +async fn disable_missing_builtin_rows(db: &DatabaseConnection) -> Result<(), DbErr> { + let metric_keys = BUILTIN_METRICS + .iter() + .map(|metric| metric.metric_key) + .collect::>(); + disable_missing( + db, + "UPDATE metric_definitions SET is_enabled = FALSE \ + WHERE tenant_id IS NULL AND origin = 'builtin' AND is_enabled = TRUE", + "metric_key", + &metric_keys, + ) + .await?; + + let source_keys = BUILTIN_SOURCES + .iter() + .map(|builtin_source| builtin_source.source.key) + .collect::>(); + disable_missing( + db, + "UPDATE metric_sources SET is_enabled = FALSE \ + WHERE tenant_id IS NULL AND origin = 'builtin' AND is_enabled = TRUE", + "source_key", + &source_keys, + ) + .await?; + + for builtin_source in BUILTIN_SOURCES { + let source_id = fetch_source_id(db, builtin_source.source.key).await?; + let measure_keys = builtin_source + .measures + .iter() + .map(|measure| measure.measure_key) + .collect::>(); + let placeholders = vec!["?"; measure_keys.len()].join(", "); + let sql = format!( + "UPDATE metric_source_measures SET is_enabled = FALSE \ + WHERE source_id = ? AND is_enabled = TRUE \ + AND measure_key NOT IN ({placeholders})" + ); + let mut values = vec![uuid_value(source_id)]; + values.extend(measure_keys.iter().map(|key| Value::from(*key))); + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + sql, + values, + )) + .await?; + } + + Ok(()) +} + +async fn disable_missing( + db: &DatabaseConnection, + base_sql: &str, + key_column: &str, + keys: &[&str], +) -> Result<(), DbErr> { + let sql = if keys.is_empty() { + base_sql.to_owned() + } else { + let placeholders = vec!["?"; keys.len()].join(", "); + format!("{base_sql} AND {key_column} NOT IN ({placeholders})") + }; + let values = keys.iter().map(|key| Value::from(*key)).collect::>(); + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + sql, + values, + )) + .await?; + Ok(()) +} + +async fn fetch_source_id(db: &DatabaseConnection, source_key: &str) -> Result { + fetch_uuid( + db, + "SELECT id FROM metric_sources WHERE tenant_id IS NULL AND source_key = ?", + &[Value::from(source_key)], + source_key, + ) + .await +} + +async fn fetch_measure_id( + db: &DatabaseConnection, + source_id: Uuid, + measure_key: &str, +) -> Result { + fetch_uuid( + db, + "SELECT id FROM metric_source_measures WHERE source_id = ? AND measure_key = ?", + &[uuid_value(source_id), Value::from(measure_key)], + measure_key, + ) + .await +} + +async fn fetch_source_dimension_id( + db: &DatabaseConnection, + source_id: Uuid, + dimension_key: &str, +) -> Result { + fetch_uuid( + db, + "SELECT id FROM metric_source_dimensions WHERE source_id = ? AND dimension_key = ?", + &[uuid_value(source_id), Value::from(dimension_key)], + dimension_key, + ) + .await +} + +async fn fetch_metric_id(db: &DatabaseConnection, metric_key: &str) -> Result { + fetch_uuid( + db, + "SELECT id FROM metric_definitions WHERE tenant_id IS NULL AND metric_key = ?", + &[Value::from(metric_key)], + metric_key, + ) + .await +} + +async fn fetch_uuid( + db: &DatabaseConnection, + sql: &str, + values: &[Value], + key: &str, +) -> Result { + let row = db + .query_one(Statement::from_sql_and_values( + db.get_database_backend(), + sql, + values.to_vec(), + )) + .await? + .ok_or_else(|| DbErr::Custom(format!("missing seeded row for {key}")))?; + row.try_get("", "id") +} + +fn order_value(idx: usize) -> i32 { + i32::try_from(idx).unwrap_or(i32::MAX) +} + +fn uuid_value(id: Uuid) -> Value { + Value::Bytes(Some(Box::new(id.as_bytes().to_vec()))) +} + +fn nullable_str(value: Option<&str>) -> Value { + match value { + Some(value) => Value::from(value), + None => Value::String(None), + } +} diff --git a/src/backend/services/analytics/src/domain/metric_definitions/validator.rs b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs new file mode 100644 index 000000000..04e9a3214 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs @@ -0,0 +1,522 @@ +use std::collections::{BTreeSet, HashMap}; + +use clickhouse::Row; +use sea_orm::DatabaseConnection; +use serde::Deserialize; + +use crate::domain::metric_definitions::definition::{CohortSource, ObservationSource}; +use crate::domain::metric_definitions::error_code::MetricSchemaErrorCode; +use crate::domain::metric_definitions::repository::{ + MetricDefinitionValidationSpec, all_managed_sources, managed_definition_validation_specs, + update_definition_status, update_definitions_for_source_status, update_source_status, +}; + +const PROBE_WINDOW_DAYS: u32 = 35; + +#[derive(Clone)] +pub struct MetricDefinitionValidator { + db: DatabaseConnection, + ch: insight_clickhouse::Client, +} + +impl MetricDefinitionValidator { + pub fn new(db: DatabaseConnection, ch: insight_clickhouse::Client) -> Self { + Self { db, ch } + } + + pub async fn validate_all(&self) { + let sources = match all_managed_sources(&self.db).await { + Ok(sources) => sources, + Err(error) => { + tracing::warn!(error = %error, "metric definition validation source load failed"); + return; + } + }; + + for (source_id, source_kind, source_ref) in sources { + let outcome = self + .validate_source(source_kind.as_str(), source_ref.as_str()) + .await; + + match outcome { + ProbeOutcome::Definitive(state) => { + let (status, error_code) = state.as_db(); + if let Err(error) = + update_source_status(&self.db, source_id, status, error_code).await + { + tracing::warn!(error = %error, "metric definition source status update failed"); + continue; + } + if state.is_ok() { + self.validate_definitions_for_source(source_id, source_ref.as_str()) + .await; + } else if let Err(error) = update_definitions_for_source_status( + &self.db, source_id, status, error_code, + ) + .await + { + tracing::warn!(error = %error, "metric definition status update failed"); + } + } + ProbeOutcome::Inconclusive => { + tracing::warn!( + source_ref = %source_ref, + "metric source validation inconclusive; keeping previous status" + ); + } + } + } + } + + async fn validate_source(&self, source_kind: &str, source_ref: &str) -> ProbeOutcome { + if source_kind != "managed_observation" { + return ProbeOutcome::Definitive(ValidationState::error( + MetricSchemaErrorCode::Unknown, + )); + } + + let Some(source) = ObservationSource::from_ref(source_ref) else { + return ProbeOutcome::Definitive(ValidationState::error( + MetricSchemaErrorCode::Unknown, + )); + }; + let cohort = CohortSource::MetricEntityCohortsCurrent; + + match self + .has_columns(source.table_ref(), OBSERVATION_COLUMNS) + .await + { + Ok(ColumnCheck::Present) => {} + Ok(missing) => { + return ProbeOutcome::Definitive(ValidationState::error(missing.error_code())); + } + Err(error) => { + tracing::warn!(error = %error, "metric observation source validation failed"); + return ProbeOutcome::Inconclusive; + } + } + + match self.has_columns(cohort.table_ref(), COHORT_COLUMNS).await { + Ok(ColumnCheck::Present) => ProbeOutcome::Definitive(ValidationState::ok()), + Ok(missing) => ProbeOutcome::Definitive(ValidationState::error(missing.error_code())), + Err(error) => { + tracing::warn!(error = %error, "metric cohort source validation failed"); + ProbeOutcome::Inconclusive + } + } + } + + async fn validate_definitions_for_source(&self, source_id: uuid::Uuid, source_ref: &str) { + let Some(source) = ObservationSource::from_ref(source_ref) else { + return; + }; + let specs = match managed_definition_validation_specs(&self.db, source_id).await { + Ok(specs) => specs, + Err(error) => { + tracing::warn!(error = %error, "metric definition validation spec load failed"); + return; + } + }; + + for spec in specs { + match self.validate_definition(source, &spec).await { + ProbeOutcome::Definitive(state) => { + let (status, error_code) = state.as_db(); + if let Err(error) = + update_definition_status(&self.db, spec.definition_id, status, error_code) + .await + { + tracing::warn!( + error = %error, + metric_key = %spec.metric_key, + "metric definition status update failed" + ); + } + } + ProbeOutcome::Inconclusive => { + tracing::warn!( + metric_key = %spec.metric_key, + "metric definition validation inconclusive; keeping previous status" + ); + } + } + } + } + + async fn validate_definition( + &self, + source: ObservationSource, + spec: &MetricDefinitionValidationSpec, + ) -> ProbeOutcome { + let inputs = spec + .inputs + .iter() + .filter(|input| input.observation_source == source) + .collect::>(); + if inputs.is_empty() { + return ProbeOutcome::Definitive(ValidationState::error( + MetricSchemaErrorCode::Unknown, + )); + } + + let source_keys = inputs + .iter() + .map(|input| input.source_key.as_str()) + .collect::>() + .into_iter() + .collect::>(); + let Some(source_key) = source_keys.first().copied() else { + return ProbeOutcome::Definitive(ValidationState::error( + MetricSchemaErrorCode::Unknown, + )); + }; + if source_keys.len() != 1 { + return ProbeOutcome::Definitive(ValidationState::error( + MetricSchemaErrorCode::Unknown, + )); + } + + let measure_keys = inputs + .iter() + .map(|input| input.measure_key.as_str()) + .collect::>(); + + match self + .has_source_rows(source, source_key, spec.entity_type.as_str()) + .await + { + Ok(true) => {} + Ok(false) => return ProbeOutcome::Definitive(ValidationState::unchecked()), + Err(error) => { + tracing::warn!(error = %error, "metric observation row probe failed"); + return ProbeOutcome::Inconclusive; + } + } + + // Absence of recent rows for a declared measure is a data condition, + // not a schema error: filtered measures (e.g. tool-scoped + // conversations) legitimately go quiet. Only measures that ARE + // observed can be checked definitively; unobserved ones downgrade + // the definition to unchecked, which stays runtime-available. + let observed = match self + .observed_measure_keys( + source, + source_key, + spec.entity_type.as_str(), + &measure_keys.iter().copied().collect::>(), + ) + .await + { + Ok(observed) => observed, + Err(error) => { + tracing::warn!(error = %error, "metric measure probe failed"); + return ProbeOutcome::Inconclusive; + } + }; + let observed_keys = measure_keys + .iter() + .copied() + .filter(|key| observed.contains(*key)) + .collect::>(); + + if let Some(outcome) = self + .check_dimension_coverage(source, source_key, spec, &observed_keys) + .await + { + return outcome; + } + + if observed_keys.len() < measure_keys.len() { + let unobserved = measure_keys + .iter() + .copied() + .filter(|key| !observed.contains(*key)) + .collect::>(); + tracing::warn!( + metric_key = %spec.metric_key, + unobserved = ?unobserved, + "declared measures without recent observations; definition stays unchecked" + ); + return ProbeOutcome::Definitive(ValidationState::unchecked()); + } + + ProbeOutcome::Definitive(ValidationState::ok()) + } + + async fn check_dimension_coverage( + &self, + source: ObservationSource, + source_key: &str, + spec: &MetricDefinitionValidationSpec, + observed_keys: &[&str], + ) -> Option { + if observed_keys.is_empty() { + return None; + } + for dimension in &spec.dimensions { + match self + .dimension_present_on_all_rows( + source, + source_key, + spec.entity_type.as_str(), + observed_keys.iter().copied(), + dimension, + ) + .await + { + Ok(true) => {} + Ok(false) => { + return Some(ProbeOutcome::Definitive(ValidationState::error( + MetricSchemaErrorCode::DimensionNotCovered, + ))); + } + Err(error) => { + tracing::warn!(error = %error, "metric dimension probe failed"); + return Some(ProbeOutcome::Inconclusive); + } + } + } + None + } + + async fn has_columns( + &self, + table: (&str, &str), + columns: &[&str], + ) -> Result { + let (database, table) = table; + let column_list = columns + .iter() + .map(|column| format!("'{column}'")) + .collect::>() + .join(", "); + let sql = format!( + "SELECT \ + count() AS total_columns, \ + countIf(name IN ({column_list})) AS matching_columns \ + FROM system.columns \ + WHERE database = ? AND table = ?" + ); + let mut query = self.ch.query(&sql); + query = query.bind(database).bind(table); + let row: ColumnProbeRow = query.fetch_one().await?; + if row.total_columns == 0 { + return Ok(ColumnCheck::TableMissing); + } + if row.matching_columns < columns.len() as u64 { + return Ok(ColumnCheck::ColumnsMissing); + } + Ok(ColumnCheck::Present) + } + + async fn has_source_rows( + &self, + source: ObservationSource, + source_key: &str, + entity_type: &str, + ) -> Result { + let (database, table) = source.table_ref(); + let sql = format!( + "SELECT count() AS rows \ + FROM ( \ + SELECT 1 \ + FROM {database}.{table} \ + WHERE source_key = ? \ + AND entity_type = ? \ + AND metric_date >= today() - {PROBE_WINDOW_DAYS} \ + LIMIT 1 \ + )" + ); + let row: CountProbeRow = self + .ch + .query(&sql) + .bind(source_key) + .bind(entity_type) + .fetch_one() + .await?; + Ok(row.rows > 0) + } + + async fn observed_measure_keys( + &self, + source: ObservationSource, + source_key: &str, + entity_type: &str, + measure_keys: &[&str], + ) -> Result, clickhouse::error::Error> { + let (database, table) = source.table_ref(); + let placeholders = vec!["?"; measure_keys.len()].join(", "); + let sql = format!( + "SELECT measure_key \ + FROM {database}.{table} \ + WHERE source_key = ? \ + AND entity_type = ? \ + AND metric_date >= today() - {PROBE_WINDOW_DAYS} \ + AND measure_key IN ({placeholders}) \ + GROUP BY measure_key" + ); + let mut query = self.ch.query(&sql).bind(source_key).bind(entity_type); + for measure_key in measure_keys { + query = query.bind(*measure_key); + } + let rows = query.fetch_all::().await?; + Ok(rows.into_iter().map(|row| row.measure_key).collect()) + } + + async fn dimension_present_on_all_rows<'a>( + &self, + source: ObservationSource, + source_key: &str, + entity_type: &str, + measure_keys: impl Iterator, + dimension: &str, + ) -> Result { + let measure_keys = measure_keys.collect::>(); + let rows = self + .dimension_coverage(source, source_key, entity_type, &measure_keys, dimension) + .await?; + let by_measure = rows + .into_iter() + .map(|row| (row.measure_key.clone(), row)) + .collect::>(); + + Ok(measure_keys.iter().all(|measure_key| { + by_measure + .get(*measure_key) + .is_some_and(|row| row.total_rows > 0 && row.total_rows == row.matching_rows) + })) + } + + async fn dimension_coverage( + &self, + source: ObservationSource, + source_key: &str, + entity_type: &str, + measure_keys: &[&str], + dimension: &str, + ) -> Result, clickhouse::error::Error> { + let (database, table) = source.table_ref(); + let placeholders = vec!["?"; measure_keys.len()].join(", "); + let sql = format!( + "SELECT \ + measure_key, \ + count() AS total_rows, \ + countIf(has(arrayMap(d -> d.key, dimensions), ?)) AS matching_rows \ + FROM {database}.{table} \ + WHERE source_key = ? \ + AND entity_type = ? \ + AND metric_date >= today() - {PROBE_WINDOW_DAYS} \ + AND measure_key IN ({placeholders}) \ + GROUP BY measure_key" + ); + let mut query = self + .ch + .query(&sql) + .bind(dimension) + .bind(source_key) + .bind(entity_type); + for measure_key in measure_keys { + query = query.bind(*measure_key); + } + query.fetch_all().await + } +} + +const OBSERVATION_COLUMNS: &[&str] = &[ + "tenant_id", + "source_key", + "entity_type", + "entity_id", + "metric_date", + "observed_at", + "measure_key", + "value", + "subject_key", + "dimensions", +]; + +const COHORT_COLUMNS: &[&str] = &[ + "tenant_id", + "entity_type", + "entity_id", + "cohort_key", + "cohort_id", +]; + +#[derive(Row, Deserialize)] +struct ColumnProbeRow { + total_columns: u64, + matching_columns: u64, +} + +#[derive(Row, Deserialize)] +struct CountProbeRow { + rows: u64, +} + +#[derive(Row, Deserialize)] +struct MeasureKeyProbeRow { + measure_key: String, +} + +#[derive(Row, Deserialize)] +struct DimensionCoverageProbeRow { + measure_key: String, + total_rows: u64, + matching_rows: u64, +} + +#[derive(Debug, Clone, Copy)] +enum ColumnCheck { + Present, + ColumnsMissing, + TableMissing, +} + +impl ColumnCheck { + fn error_code(self) -> MetricSchemaErrorCode { + match self { + Self::TableMissing => MetricSchemaErrorCode::TableNotFound, + Self::ColumnsMissing | Self::Present => MetricSchemaErrorCode::ColumnNotFound, + } + } +} + +#[derive(Debug, Clone, Copy)] +enum ProbeOutcome { + Definitive(ValidationState), + Inconclusive, +} + +#[derive(Debug, Clone, Copy)] +enum ValidationState { + Ok, + Error(MetricSchemaErrorCode), + Unchecked, +} + +impl ValidationState { + fn ok() -> Self { + Self::Ok + } + + fn error(code: MetricSchemaErrorCode) -> Self { + Self::Error(code) + } + + fn unchecked() -> Self { + Self::Unchecked + } + + fn is_ok(self) -> bool { + matches!(self, Self::Ok) + } + + fn as_db(self) -> (&'static str, Option<&'static str>) { + match self { + Self::Ok => ("ok", None), + Self::Error(code) => ("error", Some(code.as_db_str())), + Self::Unchecked => ("unchecked", None), + } + } +} diff --git a/src/backend/services/analytics/src/domain/metric_results/builder.rs b/src/backend/services/analytics/src/domain/metric_results/builder.rs new file mode 100644 index 000000000..76edd2375 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_results/builder.rs @@ -0,0 +1,512 @@ +use std::collections::{BTreeMap, HashMap}; + +use toolkit_canonical_errors::CanonicalError; + +use crate::domain::metric_definitions::{ExecutableMetric, MetricDefinition}; + +use super::compiler::{ + BreakdownQueryRow, PeerQueryRow, PeriodQueryRow, TimeseriesQueryRow, UNKNOWN_DIMENSION_LABEL, + UNKNOWN_DIMENSION_VALUE, +}; +use super::definition::Bucket; +use super::dto::{ + BreakdownValueDto, MetricDimensionDto, MetricResultDto, MetricResultViewDto, + MetricResultsResponse, PeerValueDto, PeriodValueDto, TimeseriesDto, TimeseriesPointDto, +}; +use super::validation::{ + ValidatedMetricResultsRequest, enumerate_buckets, metric_result_too_large, row_limit, +}; + +type DimensionKey = Vec<(String, String, Option)>; +type SeriesKey = (String, DimensionKey); +type PointsByBucket = HashMap>; + +pub fn build_period_view( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + rows: Vec, +) -> MetricResultViewDto { + let values_by_entity: HashMap> = rows + .into_iter() + .map(|row| (row.entity_id, row.value)) + .collect(); + let values = req + .entity_ids + .iter() + .map(|entity_id| PeriodValueDto { + entity_id: entity_id.clone(), + value: values_by_entity + .get(entity_id) + .copied() + .flatten() + .or_else(|| { + if def.is_zero_filled() { + Some(0.0) + } else { + None + } + }), + }) + .collect(); + MetricResultViewDto::Period { values } +} + +pub fn build_timeseries_view( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + bucket: Bucket, + dimensions: &[String], + rows: Vec, +) -> Result { + let buckets = enumerate_buckets(req.from, req.to, bucket); + let mut by_series: BTreeMap = BTreeMap::new(); + + if dimensions.is_empty() { + for entity_id in &req.entity_ids { + by_series + .entry((entity_id.clone(), Vec::new())) + .or_default(); + } + } + + for row in rows { + let dims = row_dimensions(&row.extra, dimensions)?; + by_series + .entry((row.entity_id, dimension_key(&dims))) + .or_default() + .insert(row.bucket_start, row.value); + } + + let series = by_series + .into_iter() + .map(|((entity_id, dims), points_by_bucket)| { + let points = buckets + .iter() + .map(|bucket| TimeseriesPointDto { + bucket_start: bucket.clone(), + value: points_by_bucket.get(bucket).copied().flatten().or_else(|| { + if def.is_zero_filled() { + Some(0.0) + } else { + None + } + }), + }) + .collect(); + TimeseriesDto { + entity_id, + dimensions: dims + .into_iter() + .map(|(key, value, label)| MetricDimensionDto { key, value, label }) + .collect(), + points, + } + }) + .collect(); + + Ok(MetricResultViewDto::Timeseries { bucket, series }) +} + +pub fn build_peer_view(rows: Vec) -> MetricResultViewDto { + MetricResultViewDto::Peer { + values: rows + .into_iter() + .map(|row| PeerValueDto { + entity_id: row.entity_id, + target_value: row.target_value, + p25: row.p25, + median: row.median, + p75: row.p75, + min: row.min, + max: row.max, + n: row.n.unwrap_or(0), + }) + .collect(), + } +} + +pub fn build_breakdown_view( + dimensions: &[String], + rows: Vec, +) -> Result { + let values = rows + .into_iter() + .map(|row| { + Ok(BreakdownValueDto { + entity_id: row.entity_id, + dimensions: row_dimensions(&row.extra, dimensions)? + .into_iter() + .map(|(key, value, label)| MetricDimensionDto { key, value, label }) + .collect(), + value: row.value, + }) + }) + .collect::, CanonicalError>>()?; + Ok(MetricResultViewDto::Breakdown { + dimensions: dimensions.iter().map(|d| (*d).clone()).collect(), + values, + }) +} + +pub fn build_metric_result( + def: &MetricDefinition, + views: Vec, +) -> MetricResultDto { + match def { + MetricDefinition::Sum(sum) => MetricResultDto::Sum { + metric_key: sum.base.key.clone(), + label: sum.base.label.clone(), + description: sum.base.description.clone(), + unit: sum.base.unit.clone(), + format: sum.base.format, + direction: sum.base.direction, + views, + }, + MetricDefinition::Count(count) => MetricResultDto::Count { + metric_key: count.base.key.clone(), + label: count.base.label.clone(), + description: count.base.description.clone(), + unit: count.base.unit.clone(), + format: count.base.format, + direction: count.base.direction, + views, + }, + MetricDefinition::CountDistinct(count) => MetricResultDto::CountDistinct { + metric_key: count.base.key.clone(), + label: count.base.label.clone(), + description: count.base.description.clone(), + unit: count.base.unit.clone(), + format: count.base.format, + direction: count.base.direction, + views, + }, + MetricDefinition::Ratio(ratio) => MetricResultDto::Ratio { + metric_key: ratio.base.key.clone(), + label: ratio.base.label.clone(), + description: ratio.base.description.clone(), + unit: ratio.base.unit.clone(), + format: ratio.base.format, + direction: ratio.base.direction, + scale: ratio.scale, + views, + }, + MetricDefinition::Distribution(distribution) => MetricResultDto::Distribution { + metric_key: distribution.base.key.clone(), + label: distribution.base.label.clone(), + description: distribution.base.description.clone(), + unit: distribution.base.unit.clone(), + format: distribution.base.format, + direction: distribution.base.direction, + statistic: distribution.statistic, + views, + }, + MetricDefinition::Gauge(gauge) => MetricResultDto::Gauge { + metric_key: gauge.base.key.clone(), + label: gauge.base.label.clone(), + description: gauge.base.description.clone(), + unit: gauge.base.unit.clone(), + format: gauge.base.format, + direction: gauge.base.direction, + method: gauge.method, + views, + }, + MetricDefinition::Derived(derived) => MetricResultDto::Derived { + metric_key: derived.base.key.clone(), + label: derived.base.label.clone(), + description: derived.base.description.clone(), + unit: derived.base.unit.clone(), + format: derived.base.format, + direction: derived.base.direction, + views, + }, + } +} + +pub fn enforce_row_limit(response: &MetricResultsResponse) -> Result<(), CanonicalError> { + if response_size(response) > row_limit() { + return Err(metric_result_too_large()); + } + Ok(()) +} + +fn response_size(response: &MetricResultsResponse) -> usize { + response + .metrics + .iter() + .flat_map(|metric| match metric { + MetricResultDto::Sum { views, .. } + | MetricResultDto::Count { views, .. } + | MetricResultDto::CountDistinct { views, .. } + | MetricResultDto::Ratio { views, .. } + | MetricResultDto::Distribution { views, .. } + | MetricResultDto::Gauge { views, .. } + | MetricResultDto::Derived { views, .. } => views, + }) + .map(|view| match view { + MetricResultViewDto::Period { values } => values.len(), + MetricResultViewDto::Timeseries { series, .. } => { + series.iter().map(|s| s.points.len()).sum() + } + MetricResultViewDto::Peer { values } => values.len(), + MetricResultViewDto::Breakdown { values, .. } => values.len(), + }) + .sum() +} + +fn row_dimensions( + extra: &HashMap, + dimensions: &[String], +) -> Result)>, CanonicalError> { + dimensions + .iter() + .enumerate() + .map(|(idx, key)| { + let value_alias = format!("dim_{idx}_value"); + let label_alias = format!("dim_{idx}_label"); + let value_field = extra.get(&value_alias).ok_or_else(|| { + tracing::error!(alias = %value_alias, "metric result row missing dimension alias"); + CanonicalError::internal("metric result shape mismatch").create() + })?; + let label_field = extra.get(&label_alias).ok_or_else(|| { + tracing::error!(alias = %label_alias, "metric result row missing dimension alias"); + CanonicalError::internal("metric result shape mismatch").create() + })?; + let value = json_string(Some(value_field)) + .unwrap_or_else(|| UNKNOWN_DIMENSION_VALUE.to_owned()); + let label = + json_string(Some(label_field)).or_else(|| Some(UNKNOWN_DIMENSION_LABEL.to_owned())); + Ok((key.clone(), value, label)) + }) + .collect() +} + +fn json_string(value: Option<&serde_json::Value>) -> Option { + match value { + Some(serde_json::Value::String(s)) => Some(s.clone()), + Some(serde_json::Value::Null) | None => None, + Some(v) => Some(v.to_string()), + } +} + +fn dimension_key( + dims: &[(String, String, Option)], +) -> Vec<(String, String, Option)> { + dims.iter() + .map(|d| (d.0.clone(), d.1.clone(), d.2.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + use serde_json::json; + + use crate::domain::metric_definitions::definition::{ + MetricBase, MetricDirection, MetricFormat, MetricInput, MetricInputRole, ObservationSource, + RatioMetricDefinition, SumMetricDefinition, + }; + use crate::domain::metric_results::definition::Bucket; + + fn base() -> MetricBase { + MetricBase { + key: "ai.accepted_lines".to_owned(), + label: "AI-added lines".to_owned(), + description: None, + entity_type: "person".to_owned(), + format: MetricFormat::Integer, + unit: None, + direction: MetricDirection::HigherIsBetter, + peer_cohort_key: None, + allowed_dimensions: vec!["tool".to_owned()], + } + } + + fn input(role: MetricInputRole, measure_key: &str) -> MetricInput { + MetricInput { + role, + observation_source: ObservationSource::AiMetricObservations, + source_key: "ai_usage".to_owned(), + measure_key: measure_key.to_owned(), + } + } + + fn sum_metric() -> ExecutableMetric { + ExecutableMetric::Sum(SumMetricDefinition { + base: base(), + value: input(MetricInputRole::Value, "accepted_lines"), + }) + } + + fn ratio_metric() -> ExecutableMetric { + ExecutableMetric::Ratio(RatioMetricDefinition { + base: base(), + numerator: input(MetricInputRole::Numerator, "accepted_edit_actions"), + denominator: input(MetricInputRole::Denominator, "tool_use_offered"), + scale: 100.0, + }) + } + + fn request(entity_ids: Vec<&str>, from: &str, to: &str) -> ValidatedMetricResultsRequest { + ValidatedMetricResultsRequest { + entity_type: "person".to_owned(), + entity_ids: entity_ids.into_iter().map(str::to_owned).collect(), + from: match NaiveDate::parse_from_str(from, "%Y-%m-%d") { + Ok(date) => date, + Err(error) => panic!("bad test date {from}: {error}"), + }, + to: match NaiveDate::parse_from_str(to, "%Y-%m-%d") { + Ok(date) => date, + Err(error) => panic!("bad test date {to}: {error}"), + }, + metrics: Vec::new(), + } + } + + #[test] + fn period_view_zero_fills_sum_and_keeps_request_order() { + let req = request(vec!["b@x.io", "a@x.io"], "2026-01-01", "2026-01-31"); + let rows = vec![PeriodQueryRow { + entity_id: "a@x.io".to_owned(), + value: Some(5.0), + }]; + let MetricResultViewDto::Period { values } = build_period_view(&sum_metric(), &req, rows) + else { + panic!("expected period view"); + }; + assert_eq!(values[0].entity_id, "b@x.io"); + assert_eq!(values[0].value, Some(0.0)); + assert_eq!(values[1].entity_id, "a@x.io"); + assert_eq!(values[1].value, Some(5.0)); + } + + #[test] + fn period_view_keeps_ratio_nulls() { + let req = request(vec!["a@x.io"], "2026-01-01", "2026-01-31"); + let MetricResultViewDto::Period { values } = + build_period_view(&ratio_metric(), &req, Vec::new()) + else { + panic!("expected period view"); + }; + assert_eq!(values[0].value, None); + } + + #[test] + fn timeseries_densifies_all_buckets_per_entity() { + let req = request(vec!["a@x.io"], "2026-01-01", "2026-01-03"); + let rows = vec![TimeseriesQueryRow { + entity_id: "a@x.io".to_owned(), + bucket_start: "2026-01-02".to_owned(), + value: Some(3.0), + extra: HashMap::new(), + }]; + let Ok(MetricResultViewDto::Timeseries { series, .. }) = + build_timeseries_view(&sum_metric(), &req, Bucket::Day, &[], rows) + else { + panic!("expected timeseries view"); + }; + assert_eq!(series.len(), 1); + let points = &series[0].points; + assert_eq!(points.len(), 3); + assert_eq!(points[0].value, Some(0.0)); + assert_eq!(points[1].value, Some(3.0)); + assert_eq!(points[2].value, Some(0.0)); + } + + #[test] + fn ungrouped_timeseries_emits_series_for_entities_without_rows() { + let req = request(vec!["a@x.io", "b@x.io"], "2026-01-01", "2026-01-02"); + let Ok(MetricResultViewDto::Timeseries { series, .. }) = + build_timeseries_view(&ratio_metric(), &req, Bucket::Day, &[], Vec::new()) + else { + panic!("expected timeseries view"); + }; + assert_eq!(series.len(), 2); + assert!(series.iter().all(|s| s.points.len() == 2)); + assert!( + series + .iter() + .all(|s| s.points.iter().all(|p| p.value.is_none())) + ); + } + + #[test] + fn dimensioned_timeseries_groups_by_observed_dimensions() { + let req = request(vec!["a@x.io"], "2026-01-01", "2026-01-02"); + let mut extra = HashMap::new(); + extra.insert("dim_0_value".to_owned(), json!("cursor")); + extra.insert("dim_0_label".to_owned(), json!("Cursor")); + let rows = vec![TimeseriesQueryRow { + entity_id: "a@x.io".to_owned(), + bucket_start: "2026-01-01".to_owned(), + value: Some(2.0), + extra, + }]; + let dimensions = vec!["tool".to_owned()]; + let Ok(MetricResultViewDto::Timeseries { series, .. }) = + build_timeseries_view(&sum_metric(), &req, Bucket::Day, &dimensions, rows) + else { + panic!("expected timeseries view"); + }; + assert_eq!(series.len(), 1); + assert_eq!(series[0].dimensions[0].key, "tool"); + assert_eq!(series[0].dimensions[0].value, "cursor"); + assert_eq!(series[0].dimensions[0].label.as_deref(), Some("Cursor")); + assert_eq!(series[0].points.len(), 2); + } + + #[test] + fn missing_dimension_alias_is_internal_error() { + let req = request(vec!["a@x.io"], "2026-01-01", "2026-01-02"); + let rows = vec![TimeseriesQueryRow { + entity_id: "a@x.io".to_owned(), + bucket_start: "2026-01-01".to_owned(), + value: Some(2.0), + extra: HashMap::new(), + }]; + let dimensions = vec!["tool".to_owned()]; + assert!( + build_timeseries_view(&sum_metric(), &req, Bucket::Day, &dimensions, rows).is_err() + ); + } + + #[test] + fn breakdown_null_dimension_value_maps_to_unknown() { + let mut extra = HashMap::new(); + extra.insert("dim_0_value".to_owned(), serde_json::Value::Null); + extra.insert("dim_0_label".to_owned(), serde_json::Value::Null); + let rows = vec![BreakdownQueryRow { + entity_id: "a@x.io".to_owned(), + value: Some(1.0), + extra, + }]; + let dimensions = vec!["tool".to_owned()]; + let Ok(MetricResultViewDto::Breakdown { values, .. }) = + build_breakdown_view(&dimensions, rows) + else { + panic!("expected breakdown view"); + }; + assert_eq!(values[0].dimensions[0].value, UNKNOWN_DIMENSION_VALUE); + assert_eq!( + values[0].dimensions[0].label.as_deref(), + Some(UNKNOWN_DIMENSION_LABEL) + ); + } + + #[test] + fn response_size_counts_densified_points() { + let req = request(vec!["a@x.io"], "2026-01-01", "2026-01-10"); + let Ok(view) = build_timeseries_view(&sum_metric(), &req, Bucket::Day, &[], Vec::new()) + else { + panic!("expected timeseries view"); + }; + let def = MetricDefinition::Sum(SumMetricDefinition { + base: base(), + value: input(MetricInputRole::Value, "accepted_lines"), + }); + let response = MetricResultsResponse { + metrics: vec![build_metric_result(&def, vec![view])], + }; + assert_eq!(response_size(&response), 10); + } +} diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs new file mode 100644 index 000000000..32475fc2a --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -0,0 +1,685 @@ +use std::collections::HashMap; +use std::fmt::Write; + +use serde::Deserialize; + +use super::definition::Bucket; +use super::validation::{ValidatedMetricResultsRequest, ValidatedMetricView, query_row_limit}; +use crate::domain::metric_definitions::{CohortSource, ExecutableMetric, ObservationSource}; + +pub(crate) const UNKNOWN_DIMENSION_VALUE: &str = "__unknown__"; +pub(crate) const UNKNOWN_DIMENSION_LABEL: &str = "Unknown"; + +#[derive(Debug)] +pub struct CompiledQuery { + pub sql: String, + pub params: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PeriodQueryRow { + pub entity_id: String, + pub value: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TimeseriesQueryRow { + pub entity_id: String, + pub bucket_start: String, + pub value: Option, + #[serde(flatten)] + pub extra: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct PeerQueryRow { + pub entity_id: String, + pub target_value: Option, + pub p25: Option, + pub median: Option, + pub p75: Option, + pub min: Option, + pub max: Option, + #[serde(default, deserialize_with = "optional_u64")] + pub n: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BreakdownQueryRow { + pub entity_id: String, + pub value: Option, + #[serde(flatten)] + pub extra: HashMap, +} + +pub fn compile_view_query( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + tenant_id: &str, + view: &ValidatedMetricView, +) -> CompiledQuery { + match view { + ValidatedMetricView::Period => compile_period_query(def, req, tenant_id), + ValidatedMetricView::Peer { cohort_key } => { + compile_peer_query(def, req, tenant_id, cohort_key) + } + ValidatedMetricView::Timeseries { bucket, dimensions } => { + compile_timeseries_query(def, req, tenant_id, *bucket, dimensions) + } + ValidatedMetricView::Breakdown { dimensions } => { + compile_breakdown_query(def, req, tenant_id, dimensions) + } + } +} + +fn compile_period_query( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + tenant_id: &str, +) -> CompiledQuery { + let mut params = metric_params(def, req, tenant_id); + params.extend(req.entity_ids.iter().cloned()); + let entities = placeholders(req.entity_ids.len()); + let observation_table = observation_table(def.observation_source()); + let limit = query_row_limit(); + let sql = match def { + ExecutableMetric::Sum(_) => format!( + r" + SELECT + entity_id, + sumIf(value, value IS NOT NULL) AS value + FROM {observation_table} + WHERE {metric_where} + AND entity_id IN ({entities}) + GROUP BY entity_id + LIMIT {limit} + ", + metric_where = metric_where(def), + ), + ExecutableMetric::Ratio(ratio) => format!( + r" + SELECT + entity_id, + {scale} * sumIf(value, measure_key = ? AND value IS NOT NULL) + / nullIf(sumIf(value, measure_key = ? AND value IS NOT NULL), 0) AS value + FROM {observation_table} + WHERE {metric_where} + AND entity_id IN ({entities}) + GROUP BY entity_id + LIMIT {limit} + ", + scale = ratio.scale, + metric_where = metric_where(def), + ), + }; + CompiledQuery { sql, params } +} + +fn compile_timeseries_query( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + tenant_id: &str, + bucket: Bucket, + dimensions: &[String], +) -> CompiledQuery { + let mut params = metric_params(def, req, tenant_id); + params.extend(req.entity_ids.iter().cloned()); + let entities = placeholders(req.entity_ids.len()); + let bucket = bucket_expr(bucket); + let (dim_select, dim_group) = dimension_select_group(dimensions); + let group = if dim_group.is_empty() { + "entity_id, bucket_start".to_owned() + } else { + format!("entity_id, bucket_start, {dim_group}") + }; + let observation_table = observation_table(def.observation_source()); + let limit = query_row_limit(); + let sql = match def { + ExecutableMetric::Sum(_) => format!( + r" + SELECT + entity_id, + toString({bucket}) AS bucket_start{dim_select}, + sumIf(value, value IS NOT NULL) AS value + FROM {observation_table} + WHERE {metric_where} + AND entity_id IN ({entities}) + GROUP BY {group} + ORDER BY entity_id, bucket_start + LIMIT {limit} + ", + metric_where = metric_where(def), + ), + ExecutableMetric::Ratio(ratio) => format!( + r" + SELECT + entity_id, + toString({bucket}) AS bucket_start{dim_select}, + {scale} * sumIf(value, measure_key = ? AND value IS NOT NULL) + / nullIf(sumIf(value, measure_key = ? AND value IS NOT NULL), 0) AS value + FROM {observation_table} + WHERE {metric_where} + AND entity_id IN ({entities}) + GROUP BY {group} + ORDER BY entity_id, bucket_start + LIMIT {limit} + ", + metric_where = metric_where(def), + scale = ratio.scale, + ), + }; + CompiledQuery { sql, params } +} + +fn compile_breakdown_query( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + tenant_id: &str, + dimensions: &[String], +) -> CompiledQuery { + let mut params = metric_params(def, req, tenant_id); + params.extend(req.entity_ids.iter().cloned()); + let entities = placeholders(req.entity_ids.len()); + let (dim_select, dim_group) = dimension_select_group(dimensions); + let group = if dim_group.is_empty() { + "entity_id".to_owned() + } else { + format!("entity_id, {dim_group}") + }; + let observation_table = observation_table(def.observation_source()); + let limit = query_row_limit(); + let sql = match def { + ExecutableMetric::Sum(_) => format!( + r" + SELECT + entity_id{dim_select}, + sumIf(value, value IS NOT NULL) AS value + FROM {observation_table} + WHERE {metric_where} + AND entity_id IN ({entities}) + GROUP BY {group} + ORDER BY entity_id + LIMIT {limit} + ", + metric_where = metric_where(def), + ), + ExecutableMetric::Ratio(ratio) => format!( + r" + SELECT + entity_id{dim_select}, + {scale} * sumIf(value, measure_key = ? AND value IS NOT NULL) + / nullIf(sumIf(value, measure_key = ? AND value IS NOT NULL), 0) AS value + FROM {observation_table} + WHERE {metric_where} + AND entity_id IN ({entities}) + GROUP BY {group} + ORDER BY entity_id + LIMIT {limit} + ", + metric_where = metric_where(def), + scale = ratio.scale, + ), + }; + CompiledQuery { sql, params } +} + +fn compile_peer_query( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + tenant_id: &str, + cohort_key: &str, +) -> CompiledQuery { + let mut params = Vec::new(); + params.push(tenant_id.to_owned()); + params.push(req.entity_type.clone()); + params.push(cohort_key.to_owned()); + params.extend(req.entity_ids.iter().cloned()); + params.push(tenant_id.to_owned()); + params.push(req.entity_type.clone()); + params.push(cohort_key.to_owned()); + params.extend(metric_params(def, req, tenant_id)); + + let entities = placeholders(req.entity_ids.len()); + let observation_table = observation_table(def.observation_source()); + let cohort_table = cohort_table(CohortSource::MetricEntityCohortsCurrent); + let metric_value = match def { + ExecutableMetric::Sum(_) => "sumIf(value, value IS NOT NULL)".to_owned(), + ExecutableMetric::Ratio(ratio) => format!( + "{} * sumIf(value, measure_key = ? AND value IS NOT NULL) / nullIf(sumIf(value, measure_key = ? AND value IS NOT NULL), 0)", + ratio.scale + ), + }; + let peer_value = if def.is_zero_filled() { + "coalesce(metric_values.value, 0)" + } else { + "metric_values.value" + }; + let limit = query_row_limit(); + let sql = format!( + r" + WITH + targets AS ( + SELECT + entity_id, + cohort_id + FROM {cohort_table} + WHERE tenant_id = ? + AND entity_type = ? + AND cohort_key = ? + AND entity_id IN ({entities}) + AND cohort_id IS NOT NULL + ), + cohort AS ( + SELECT + entity_id, + cohort_id + FROM {cohort_table} + WHERE tenant_id = ? + AND entity_type = ? + AND cohort_key = ? + AND cohort_id IN (SELECT cohort_id FROM targets) + ), + metric_values AS ( + SELECT + entity_id, + {metric_value} AS value + FROM {observation_table} + WHERE {metric_where} + GROUP BY entity_id + ), + entity_values AS ( + SELECT + cohort.entity_id AS entity_id, + cohort.cohort_id AS cohort_id, + {peer_value} AS value + FROM cohort + LEFT JOIN metric_values + ON metric_values.entity_id = cohort.entity_id + ), + peers AS ( + SELECT + cohort_id, + value + FROM entity_values + WHERE value IS NOT NULL + ) + SELECT + targets.entity_id AS entity_id, + target_values.value AS target_value, + quantileExact(0.25)(peers.value) AS p25, + quantileExact(0.5)(peers.value) AS median, + quantileExact(0.75)(peers.value) AS p75, + min(peers.value) AS min, + max(peers.value) AS max, + toUInt64(count(peers.value)) AS n + FROM targets + LEFT JOIN entity_values AS target_values + ON target_values.entity_id = targets.entity_id + LEFT JOIN peers + ON peers.cohort_id = targets.cohort_id + GROUP BY targets.entity_id, target_values.value + LIMIT {limit} + ", + metric_where = metric_where(def), + ); + CompiledQuery { sql, params } +} + +fn metric_where(def: &ExecutableMetric) -> &'static str { + match def { + ExecutableMetric::Sum(_) => { + "tenant_id = ? AND source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key = ?" + } + ExecutableMetric::Ratio(_) => { + "tenant_id = ? AND source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key IN (?, ?)" + } + } +} + +fn metric_params( + def: &ExecutableMetric, + req: &ValidatedMetricResultsRequest, + tenant_id: &str, +) -> Vec { + match def { + ExecutableMetric::Sum(sum) => vec![ + tenant_id.to_owned(), + sum.value.source_key.clone(), + req.entity_type.clone(), + req.from.to_string(), + req.to.to_string(), + sum.value.measure_key.clone(), + ], + ExecutableMetric::Ratio(ratio) => { + let mut params = vec![ + ratio.numerator.measure_key.clone(), + ratio.denominator.measure_key.clone(), + ]; + params.extend([ + tenant_id.to_owned(), + ratio.numerator.source_key.clone(), + req.entity_type.clone(), + req.from.to_string(), + req.to.to_string(), + ratio.numerator.measure_key.clone(), + ratio.denominator.measure_key.clone(), + ]); + params + } + } +} + +fn placeholders(count: usize) -> String { + vec!["?"; count].join(", ") +} + +fn bucket_expr(bucket: Bucket) -> &'static str { + match bucket { + Bucket::Day => "metric_date", + Bucket::Week => "toStartOfWeek(metric_date, 1)", + Bucket::Month => "toStartOfMonth(metric_date)", + } +} + +fn observation_table(source: ObservationSource) -> &'static str { + match source { + ObservationSource::AiMetricObservations => "insight.ai_metric_observations", + } +} + +fn cohort_table(source: CohortSource) -> &'static str { + match source { + CohortSource::MetricEntityCohortsCurrent => "insight.metric_entity_cohorts_current", + } +} + +fn dimension_select_group(dimensions: &[String]) -> (String, String) { + let mut select = String::new(); + let mut groups = Vec::with_capacity(dimensions.len() * 2); + for (idx, dimension) in dimensions.iter().enumerate() { + let value_alias = format!("dim_{idx}_value"); + let label_alias = format!("dim_{idx}_label"); + let _ = write!( + select, + ", {value} AS {value_alias}, {label} AS {label_alias}", + value = dimension_value_expr(dimension), + label = dimension_label_expr(dimension) + ); + groups.push(value_alias); + groups.push(label_alias); + } + (select, groups.join(", ")) +} + +fn dimension_value_expr(dimension: &str) -> String { + format!( + r" + if( + length(arrayFilter(d -> tupleElement(d, 1) = '{dimension}', dimensions)) = 0, + '{UNKNOWN_DIMENSION_VALUE}', + coalesce( + tupleElement(arrayFilter(d -> tupleElement(d, 1) = '{dimension}', dimensions)[1], 2), + '{UNKNOWN_DIMENSION_VALUE}' + ) + ) + " + ) +} + +fn dimension_label_expr(dimension: &str) -> String { + format!( + r" + if( + length(arrayFilter(d -> tupleElement(d, 1) = '{dimension}', dimensions)) = 0, + '{UNKNOWN_DIMENSION_LABEL}', + coalesce( + tupleElement(arrayFilter(d -> tupleElement(d, 1) = '{dimension}', dimensions)[1], 3), + '{UNKNOWN_DIMENSION_LABEL}' + ) + ) + " + ) +} + +fn optional_u64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Number(number)) => number + .as_u64() + .ok_or_else(|| serde::de::Error::custom("expected unsigned integer")) + .map(Some), + Some(serde_json::Value::String(value)) => value + .parse::() + .map(Some) + .map_err(serde::de::Error::custom), + Some(_) => Err(serde::de::Error::custom("expected unsigned integer")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + use crate::domain::metric_definitions::definition::{ + MetricBase, MetricDirection, MetricFormat, MetricInput, MetricInputRole, + RatioMetricDefinition, SumMetricDefinition, + }; + + fn base(dimensions: Vec<&str>) -> MetricBase { + MetricBase { + key: "ai.accepted_lines".to_owned(), + label: "AI-added lines".to_owned(), + description: None, + entity_type: "person".to_owned(), + format: MetricFormat::Integer, + unit: None, + direction: MetricDirection::HigherIsBetter, + peer_cohort_key: Some("org_unit".to_owned()), + allowed_dimensions: dimensions.into_iter().map(str::to_owned).collect(), + } + } + + fn input(role: MetricInputRole, measure_key: &str) -> MetricInput { + MetricInput { + role, + observation_source: ObservationSource::AiMetricObservations, + source_key: "ai_usage".to_owned(), + measure_key: measure_key.to_owned(), + } + } + + fn sum_metric() -> ExecutableMetric { + ExecutableMetric::Sum(SumMetricDefinition { + base: base(vec!["tool"]), + value: input(MetricInputRole::Value, "accepted_lines"), + }) + } + + fn ratio_metric() -> ExecutableMetric { + ExecutableMetric::Ratio(RatioMetricDefinition { + base: base(vec!["tool"]), + numerator: input(MetricInputRole::Numerator, "accepted_edit_actions"), + denominator: input(MetricInputRole::Denominator, "tool_use_offered"), + scale: 100.0, + }) + } + + fn request() -> ValidatedMetricResultsRequest { + ValidatedMetricResultsRequest { + entity_type: "person".to_owned(), + entity_ids: vec!["a@x.io".to_owned(), "b@x.io".to_owned()], + from: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap_or_default(), + to: NaiveDate::from_ymd_opt(2026, 1, 31).unwrap_or_default(), + metrics: Vec::new(), + } + } + + #[test] + fn sum_period_query_binds_scope_then_entities() { + let query = compile_view_query( + &sum_metric(), + &request(), + "tenant-1", + &ValidatedMetricView::Period, + ); + assert!(query.sql.contains("FROM insight.ai_metric_observations")); + assert!(query.sql.contains("measure_key = ?")); + assert!(query.sql.contains("GROUP BY entity_id")); + assert_eq!( + query.params, + vec![ + "tenant-1", + "ai_usage", + "person", + "2026-01-01", + "2026-01-31", + "accepted_lines", + "a@x.io", + "b@x.io", + ] + ); + } + + #[test] + fn ratio_period_query_binds_select_measures_first() { + let query = compile_view_query( + &ratio_metric(), + &request(), + "tenant-1", + &ValidatedMetricView::Period, + ); + assert!(query.sql.contains("nullIf")); + assert!(query.sql.contains("100 *")); + assert!(query.sql.contains("measure_key IN (?, ?)")); + assert_eq!( + query.params, + vec![ + "accepted_edit_actions", + "tool_use_offered", + "tenant-1", + "ai_usage", + "person", + "2026-01-01", + "2026-01-31", + "accepted_edit_actions", + "tool_use_offered", + "a@x.io", + "b@x.io", + ] + ); + } + + #[test] + fn timeseries_query_uses_bucket_expression() { + for (bucket, expr) in [ + (Bucket::Day, "metric_date"), + (Bucket::Week, "toStartOfWeek(metric_date, 1)"), + (Bucket::Month, "toStartOfMonth(metric_date)"), + ] { + let query = compile_view_query( + &sum_metric(), + &request(), + "tenant-1", + &ValidatedMetricView::Timeseries { + bucket, + dimensions: vec![], + }, + ); + assert!( + query + .sql + .contains(&format!("toString({expr}) AS bucket_start")) + ); + assert!(query.sql.contains("GROUP BY entity_id, bucket_start")); + } + } + + #[test] + fn dimensioned_query_emits_value_and_label_aliases() { + let query = compile_view_query( + &sum_metric(), + &request(), + "tenant-1", + &ValidatedMetricView::Breakdown { + dimensions: vec!["tool".to_owned()], + }, + ); + assert!(query.sql.contains("AS dim_0_value")); + assert!(query.sql.contains("AS dim_0_label")); + assert!(query.sql.contains("tupleElement(d, 1) = 'tool'")); + assert!( + query + .sql + .contains("GROUP BY entity_id, dim_0_value, dim_0_label") + ); + } + + #[test] + fn peer_query_binds_cohort_scopes_then_metric_scope() { + let query = compile_view_query( + &sum_metric(), + &request(), + "tenant-1", + &ValidatedMetricView::Peer { + cohort_key: "org_unit".to_owned(), + }, + ); + assert!( + query + .sql + .contains("FROM insight.metric_entity_cohorts_current") + ); + assert!(query.sql.contains("WHERE value IS NOT NULL")); + assert!(!query.sql.contains("AND peer.value IS NOT NULL")); + assert!(query.sql.contains("coalesce(metric_values.value, 0)")); + assert_eq!( + query.params, + vec![ + "tenant-1", + "person", + "org_unit", + "a@x.io", + "b@x.io", + "tenant-1", + "person", + "org_unit", + "tenant-1", + "ai_usage", + "person", + "2026-01-01", + "2026-01-31", + "accepted_lines", + ] + ); + } + + #[test] + fn ratio_peer_query_keeps_null_peer_values() { + let query = compile_view_query( + &ratio_metric(), + &request(), + "tenant-1", + &ValidatedMetricView::Peer { + cohort_key: "org_unit".to_owned(), + }, + ); + assert!(query.sql.contains("metric_values.value AS value")); + assert!(!query.sql.contains("coalesce(metric_values.value, 0)")); + } + + #[test] + fn queries_carry_row_limit() { + let query = compile_view_query( + &sum_metric(), + &request(), + "tenant-1", + &ValidatedMetricView::Period, + ); + assert!(query.sql.contains(&format!("LIMIT {}", query_row_limit()))); + } +} diff --git a/src/backend/services/analytics/src/domain/metric_results/definition.rs b/src/backend/services/analytics/src/domain/metric_results/definition.rs new file mode 100644 index 000000000..dca2543be --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_results/definition.rs @@ -0,0 +1,18 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Bucket { + Day, + Week, + Month, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MetricResultViewKind { + Period, + Timeseries, + Peer, + Breakdown, +} diff --git a/src/backend/services/analytics/src/domain/metric_results/dto.rs b/src/backend/services/analytics/src/domain/metric_results/dto.rs new file mode 100644 index 000000000..1bc0dec29 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_results/dto.rs @@ -0,0 +1,207 @@ +use serde::{Deserialize, Serialize}; + +use super::definition::{Bucket, MetricResultViewKind}; +use crate::domain::metric_definitions::{ + DistributionStatistic, GaugeMethod, MetricDirection, MetricFormat, +}; + +#[derive(Debug, Deserialize)] +pub struct MetricResultsRequest { + pub entity: MetricResultsEntity, + pub period: MetricResultsPeriod, + pub metrics: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct MetricResultsEntity { + pub r#type: String, + pub ids: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct MetricResultsPeriod { + pub from: String, + pub to: String, +} + +#[derive(Debug, Deserialize)] +pub struct MetricRequest { + pub metric_key: String, + pub views: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "view", rename_all = "snake_case")] +pub enum MetricViewRequest { + Period, + Peer { + cohort_key: Option, + }, + Timeseries { + bucket: Option, + #[serde(default)] + dimensions: Vec, + }, + Breakdown { + dimensions: Vec, + }, +} + +impl MetricViewRequest { + pub fn kind(&self) -> MetricResultViewKind { + match self { + Self::Period => MetricResultViewKind::Period, + Self::Peer { .. } => MetricResultViewKind::Peer, + Self::Timeseries { .. } => MetricResultViewKind::Timeseries, + Self::Breakdown { .. } => MetricResultViewKind::Breakdown, + } + } +} + +#[derive(Debug, Serialize)] +pub struct MetricResultsResponse { + pub metrics: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "computation", rename_all = "snake_case")] +pub enum MetricResultDto { + Sum { + metric_key: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + unit: Option, + format: MetricFormat, + direction: MetricDirection, + views: Vec, + }, + Count { + metric_key: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + unit: Option, + format: MetricFormat, + direction: MetricDirection, + views: Vec, + }, + CountDistinct { + metric_key: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + unit: Option, + format: MetricFormat, + direction: MetricDirection, + views: Vec, + }, + Ratio { + metric_key: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + unit: Option, + format: MetricFormat, + direction: MetricDirection, + scale: f64, + views: Vec, + }, + Distribution { + metric_key: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + unit: Option, + format: MetricFormat, + direction: MetricDirection, + statistic: DistributionStatistic, + views: Vec, + }, + Gauge { + metric_key: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + unit: Option, + format: MetricFormat, + direction: MetricDirection, + method: GaugeMethod, + views: Vec, + }, + Derived { + metric_key: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + unit: Option, + format: MetricFormat, + direction: MetricDirection, + views: Vec, + }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "view", rename_all = "snake_case")] +pub enum MetricResultViewDto { + Period { + values: Vec, + }, + Timeseries { + bucket: Bucket, + series: Vec, + }, + Peer { + values: Vec, + }, + Breakdown { + dimensions: Vec, + values: Vec, + }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MetricDimensionDto { + pub key: String, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Serialize)] +pub struct PeriodValueDto { + pub entity_id: String, + pub value: Option, +} + +#[derive(Debug, Serialize)] +pub struct TimeseriesDto { + pub entity_id: String, + pub dimensions: Vec, + pub points: Vec, +} + +#[derive(Debug, Serialize)] +pub struct TimeseriesPointDto { + pub bucket_start: String, + pub value: Option, +} + +#[derive(Debug, Serialize)] +pub struct PeerValueDto { + pub entity_id: String, + pub target_value: Option, + pub p25: Option, + pub median: Option, + pub p75: Option, + pub min: Option, + pub max: Option, + pub n: u64, +} + +#[derive(Debug, Serialize)] +pub struct BreakdownValueDto { + pub entity_id: String, + pub dimensions: Vec, + pub value: Option, +} diff --git a/src/backend/services/analytics/src/domain/metric_results/mod.rs b/src/backend/services/analytics/src/domain/metric_results/mod.rs new file mode 100644 index 000000000..7bd160d27 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_results/mod.rs @@ -0,0 +1,16 @@ +mod builder; +mod compiler; +mod definition; +mod dto; +mod validation; + +pub use builder::{ + build_breakdown_view, build_metric_result, build_peer_view, build_period_view, + build_timeseries_view, enforce_row_limit, +}; +pub use compiler::{ + BreakdownQueryRow, CompiledQuery, PeerQueryRow, PeriodQueryRow, TimeseriesQueryRow, + compile_view_query, +}; +pub use dto::{MetricResultViewDto, MetricResultsRequest, MetricResultsResponse}; +pub use validation::{ValidatedMetricResultsRequest, ValidatedMetricView, validate_request}; diff --git a/src/backend/services/analytics/src/domain/metric_results/validation.rs b/src/backend/services/analytics/src/domain/metric_results/validation.rs new file mode 100644 index 000000000..e31f2a299 --- /dev/null +++ b/src/backend/services/analytics/src/domain/metric_results/validation.rs @@ -0,0 +1,683 @@ +use std::collections::BTreeSet; + +use chrono::{Datelike, Duration, NaiveDate}; +use sea_orm::DatabaseConnection; +use toolkit_canonical_errors::CanonicalError; +use uuid::Uuid; + +use crate::api::error::MetricError; +use crate::domain::metric_definitions::{ExecutableMetric, MetricDefinition, load_definitions}; + +use super::definition::Bucket; +use super::dto::{MetricResultsRequest, MetricViewRequest}; + +const ROW_LIMIT: usize = 5000; +const MAX_METRICS: usize = 50; +const MAX_ENTITY_IDS: usize = 1000; +const MAX_PERIOD_DAYS: i64 = 400; + +#[derive(Debug)] +pub struct ValidatedMetricResultsRequest { + pub entity_type: String, + pub entity_ids: Vec, + pub from: NaiveDate, + pub to: NaiveDate, + pub metrics: Vec, +} + +#[derive(Debug)] +pub struct ValidatedMetricRequest { + pub def: MetricDefinition, + pub exec: ExecutableMetric, + pub views: Vec, +} + +#[derive(Debug, Clone)] +pub enum ValidatedMetricView { + Period, + Peer { + cohort_key: String, + }, + Timeseries { + bucket: Bucket, + dimensions: Vec, + }, + Breakdown { + dimensions: Vec, + }, +} + +struct RequestShape { + entity_type: String, + entity_ids: Vec, + from: NaiveDate, + to: NaiveDate, + metric_keys: Vec, +} + +pub async fn validate_request( + db: &DatabaseConnection, + tenant_id: Uuid, + req: MetricResultsRequest, +) -> Result { + let shape = validate_request_shape(&req)?; + let RequestShape { + entity_type, + entity_ids, + from, + to, + metric_keys, + } = shape; + + let mut definitions = load_definitions(db, tenant_id, &metric_keys).await?; + let mut metrics = Vec::with_capacity(req.metrics.len()); + + for metric in req.metrics { + let metric_key = metric.metric_key.trim(); + let def = definitions.remove(metric_key).ok_or_else(|| { + MetricError::invalid_argument() + .with_field_violation( + "metrics.metric_key", + format!("unknown or unavailable metric key: {metric_key}"), + "UNAVAILABLE", + ) + .create() + })?; + if def.base().entity_type != entity_type { + return invalid( + "entity.type", + format!( + "metric {} is defined for entity type {}", + def.key(), + def.base().entity_type + ), + ); + } + let Some(exec) = def.executable() else { + return unsupported_computation(&def); + }; + if metric.views.is_empty() { + return invalid( + "metrics.views", + format!("metric {} must request at least one view", def.key()), + ); + } + + let mut view_kinds = BTreeSet::new(); + let mut views = Vec::with_capacity(metric.views.len()); + for view in metric.views { + let kind = view.kind(); + if !view_kinds.insert(kind) { + return invalid( + "metrics.views", + format!("metric {} has duplicate {kind:?} view", def.key()), + ); + } + views.push(validate_view(&def, view)?); + } + + metrics.push(ValidatedMetricRequest { def, exec, views }); + } + + let validated = ValidatedMetricResultsRequest { + entity_type, + entity_ids, + from, + to, + metrics, + }; + validate_projected_row_limit(&validated)?; + Ok(validated) +} + +fn validate_request_shape(req: &MetricResultsRequest) -> Result { + if req.metrics.is_empty() { + return invalid("metrics", "metrics must not be empty"); + } + if req.metrics.len() > MAX_METRICS { + return invalid( + "metrics", + format!("at most {MAX_METRICS} metrics per request"), + ); + } + + let entity_type = normalize_entity_type(&req.entity.r#type)?; + let entity_ids = normalize_entity_ids(&entity_type, &req.entity.ids)?; + if entity_ids.len() > MAX_ENTITY_IDS { + return invalid( + "entity.ids", + format!("at most {MAX_ENTITY_IDS} entity ids per request"), + ); + } + let from = parse_date("period.from", &req.period.from)?; + let to = parse_date("period.to", &req.period.to)?; + if from > to { + return invalid("period", "period.from must be before or equal to period.to"); + } + if (to - from).num_days() >= MAX_PERIOD_DAYS { + return invalid( + "period", + format!("period must not exceed {MAX_PERIOD_DAYS} days"), + ); + } + + let mut seen_metric_keys = BTreeSet::new(); + let mut metric_keys = Vec::with_capacity(req.metrics.len()); + for metric in &req.metrics { + let metric_key = metric.metric_key.trim(); + if metric_key.is_empty() { + return invalid("metrics.metric_key", "metric_key must not be empty"); + } + if !seen_metric_keys.insert(metric_key.to_owned()) { + return invalid( + "metrics.metric_key", + format!("duplicate metric key: {metric_key}"), + ); + } + metric_keys.push(metric_key.to_owned()); + } + + Ok(RequestShape { + entity_type, + entity_ids, + from, + to, + metric_keys, + }) +} + +pub fn row_limit() -> usize { + ROW_LIMIT +} + +pub fn query_row_limit() -> usize { + ROW_LIMIT + 1 +} + +pub fn metric_result_too_large() -> CanonicalError { + MetricError::invalid_argument() + .with_field_violation( + "metric_results", + "Requested metric result exceeds the row limit. Reduce the date range, entities, metrics, or dimensions.", + "metric_result_too_large", + ) + .create() +} + +fn validate_view( + def: &MetricDefinition, + view: MetricViewRequest, +) -> Result { + match view { + MetricViewRequest::Period => Ok(ValidatedMetricView::Period), + MetricViewRequest::Peer { cohort_key } => { + let cohort_key = match cohort_key { + Some(key) => normalize_key("metrics.views.cohort_key", &key)?, + None => def.base().peer_cohort_key.clone().ok_or_else(|| { + MetricError::invalid_argument() + .with_field_violation( + "metrics.views.cohort_key", + format!("metric {} has no default peer cohort", def.key()), + "INVALID", + ) + .create() + })?, + }; + Ok(ValidatedMetricView::Peer { cohort_key }) + } + MetricViewRequest::Timeseries { bucket, dimensions } => { + Ok(ValidatedMetricView::Timeseries { + bucket: bucket.unwrap_or(Bucket::Day), + dimensions: validate_dimensions(def, "metrics.views.dimensions", dimensions)?, + }) + } + MetricViewRequest::Breakdown { dimensions } => { + if dimensions.is_empty() { + return invalid( + "metrics.views.dimensions", + format!( + "metric {} breakdown dimensions must not be empty", + def.key() + ), + ); + } + Ok(ValidatedMetricView::Breakdown { + dimensions: validate_dimensions(def, "metrics.views.dimensions", dimensions)?, + }) + } + } +} + +fn validate_dimensions( + def: &MetricDefinition, + field: &'static str, + dimensions: Vec, +) -> Result, CanonicalError> { + let mut seen = BTreeSet::new(); + let mut out = Vec::with_capacity(dimensions.len()); + for dimension in dimensions { + let dimension = normalize_key(field, &dimension)?; + if !seen.insert(dimension.clone()) { + return invalid(field, format!("duplicate dimension: {dimension}")); + } + let Some(valid_dimension) = def.allowed_dimension(&dimension) else { + return invalid( + field, + format!( + "metric {} does not support dimension {dimension}", + def.key() + ), + ); + }; + out.push(valid_dimension.to_owned()); + } + Ok(out) +} + +fn normalize_entity_type(entity_type: &str) -> Result { + normalize_key("entity.type", entity_type) +} + +fn normalize_entity_ids( + entity_type: &str, + entity_ids: &[String], +) -> Result, CanonicalError> { + let mut seen = BTreeSet::new(); + let mut out = Vec::with_capacity(entity_ids.len()); + for entity_id in entity_ids { + let entity_id = normalize_entity_id(entity_type, entity_id); + if entity_id.is_empty() { + continue; + } + if seen.insert(entity_id.clone()) { + out.push(entity_id); + } + } + if out.is_empty() { + return invalid("entity.ids", "entity.ids must not be empty"); + } + Ok(out) +} + +// Id normalization is a property of the entity type: person ids are emails +// and the observation sources emit them lowercased, so equality requires +// lowercasing here too. Other entity types keep their casing. +fn normalize_entity_id(entity_type: &str, entity_id: &str) -> String { + let trimmed = entity_id.trim(); + match entity_type { + "person" => trimmed.to_ascii_lowercase(), + _ => trimmed.to_owned(), + } +} + +fn normalize_key(field: &'static str, value: &str) -> Result { + let value = value.trim().to_ascii_lowercase(); + if value.is_empty() { + return invalid(field, "value must not be empty"); + } + if !value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') + || value + .as_bytes() + .first() + .is_some_and(|b| b.is_ascii_digit() || *b == b'_') + { + return invalid(field, "expected lowercase snake case"); + } + Ok(value) +} + +fn validate_projected_row_limit(req: &ValidatedMetricResultsRequest) -> Result<(), CanonicalError> { + let mut projected = 0usize; + + for metric in &req.metrics { + for view in &metric.views { + match view { + ValidatedMetricView::Period | ValidatedMetricView::Peer { .. } => { + projected = projected.saturating_add(req.entity_ids.len()); + } + ValidatedMetricView::Timeseries { bucket, .. } => { + projected = projected.saturating_add( + req.entity_ids + .len() + .saturating_mul(enumerate_buckets(req.from, req.to, *bucket).len()), + ); + } + ValidatedMetricView::Breakdown { .. } => {} + } + } + } + + if projected > ROW_LIMIT { + return Err(metric_result_too_large()); + } + Ok(()) +} + +pub fn enumerate_buckets(from: NaiveDate, to: NaiveDate, bucket: Bucket) -> Vec { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + let mut day = from; + while day <= to { + let bucket_start = match bucket { + Bucket::Day => day, + Bucket::Week => day - Duration::days(i64::from(day.weekday().num_days_from_monday())), + Bucket::Month => NaiveDate::from_ymd_opt(day.year(), day.month(), 1).unwrap_or(day), + }; + if seen.insert(bucket_start) { + out.push(bucket_start.to_string()); + } + day += Duration::days(1); + } + out +} + +fn parse_date(field: &'static str, value: &str) -> Result { + NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| { + MetricError::invalid_argument() + .with_field_violation(field, "expected YYYY-MM-DD", "INVALID") + .create() + }) +} + +fn unsupported_computation(def: &MetricDefinition) -> Result { + Err(MetricError::invalid_argument() + .with_field_violation( + "metrics.computation", + format!( + "metric {} uses unsupported computation {}", + def.key(), + def.computation().as_db() + ), + "UNSUPPORTED_COMPUTATION", + ) + .create()) +} + +fn invalid(field: &'static str, message: impl Into) -> Result { + Err(MetricError::invalid_argument() + .with_field_violation(field, message.into(), "INVALID") + .create()) +} + +#[cfg(test)] +mod tests { + use super::super::dto::MetricResultsEntity; + use super::*; + use crate::domain::metric_definitions::definition::{ + ExecutableMetric, MetricBase, MetricDefinition, MetricDirection, MetricFormat, MetricInput, + MetricInputRole, ObservationSource, SumMetricDefinition, + }; + + fn shape_request( + entity_ids: Vec<&str>, + from: &str, + to: &str, + metric_keys: Vec<&str>, + ) -> MetricResultsRequest { + MetricResultsRequest { + entity: MetricResultsEntity { + r#type: "person".to_owned(), + ids: entity_ids.into_iter().map(str::to_owned).collect(), + }, + period: super::super::dto::MetricResultsPeriod { + from: from.to_owned(), + to: to.to_owned(), + }, + metrics: metric_keys + .into_iter() + .map(|key| super::super::dto::MetricRequest { + metric_key: key.to_owned(), + views: vec![MetricViewRequest::Period], + }) + .collect(), + } + } + + fn sum_definition(dimensions: Vec<&str>) -> MetricDefinition { + MetricDefinition::Sum(SumMetricDefinition { + base: MetricBase { + key: "ai.accepted_lines".to_owned(), + label: "AI-added lines".to_owned(), + description: None, + entity_type: "person".to_owned(), + format: MetricFormat::Integer, + unit: None, + direction: MetricDirection::HigherIsBetter, + peer_cohort_key: Some("org_unit".to_owned()), + allowed_dimensions: dimensions.into_iter().map(str::to_owned).collect(), + }, + value: MetricInput { + role: MetricInputRole::Value, + observation_source: ObservationSource::AiMetricObservations, + source_key: "ai_usage".to_owned(), + measure_key: "accepted_lines".to_owned(), + }, + }) + } + + fn day(value: &str) -> NaiveDate { + match NaiveDate::parse_from_str(value, "%Y-%m-%d") { + Ok(date) => date, + Err(error) => panic!("bad test date {value}: {error}"), + } + } + + #[test] + fn shape_accepts_valid_request() { + let Ok(shape) = validate_request_shape(&shape_request( + vec!["A@x.io "], + "2026-01-01", + "2026-01-31", + vec!["ai.x"], + )) else { + panic!("expected valid shape"); + }; + assert_eq!(shape.entity_type, "person"); + assert_eq!(shape.entity_ids, vec!["a@x.io".to_owned()]); + assert_eq!(shape.metric_keys, vec!["ai.x".to_owned()]); + } + + #[test] + fn shape_rejects_too_many_metrics() { + let keys: Vec = (0..=MAX_METRICS).map(|i| format!("ai.m{i}")).collect(); + let req = shape_request( + vec!["a@x.io"], + "2026-01-01", + "2026-01-31", + keys.iter().map(String::as_str).collect(), + ); + assert!(validate_request_shape(&req).is_err()); + } + + #[test] + fn shape_rejects_too_many_entity_ids() { + let ids: Vec = (0..=MAX_ENTITY_IDS).map(|i| format!("p{i}@x.io")).collect(); + let req = shape_request( + ids.iter().map(String::as_str).collect(), + "2026-01-01", + "2026-01-31", + vec!["ai.x"], + ); + assert!(validate_request_shape(&req).is_err()); + } + + #[test] + fn shape_rejects_oversized_period_before_enumeration() { + let req = shape_request(vec!["a@x.io"], "0001-01-01", "9999-12-31", vec!["ai.x"]); + assert!(validate_request_shape(&req).is_err()); + } + + #[test] + fn shape_rejects_reversed_period() { + let req = shape_request(vec!["a@x.io"], "2026-02-01", "2026-01-01", vec!["ai.x"]); + assert!(validate_request_shape(&req).is_err()); + } + + #[test] + fn shape_rejects_duplicate_metric_keys() { + let req = shape_request( + vec!["a@x.io"], + "2026-01-01", + "2026-01-31", + vec!["ai.x", "ai.x"], + ); + assert!(validate_request_shape(&req).is_err()); + } + + #[test] + fn shape_rejects_all_blank_entity_ids() { + let req = shape_request(vec![" ", ""], "2026-01-01", "2026-01-31", vec!["ai.x"]); + assert!(validate_request_shape(&req).is_err()); + } + + #[test] + fn person_entity_ids_are_lowercased() { + let Ok(ids) = normalize_entity_ids("person", &[" A@X.io ".to_owned()]) else { + panic!("expected normalized ids"); + }; + assert_eq!(ids, vec!["a@x.io".to_owned()]); + } + + #[test] + fn non_person_entity_ids_keep_case() { + let Ok(ids) = normalize_entity_ids("repo", &[" Org/Repo-Name ".to_owned()]) else { + panic!("expected normalized ids"); + }; + assert_eq!(ids, vec!["Org/Repo-Name".to_owned()]); + } + + #[test] + fn normalize_key_enforces_snake_case() { + assert_eq!(normalize_key("f", " Tool ").ok().as_deref(), Some("tool")); + assert!(normalize_key("f", "").is_err()); + assert!(normalize_key("f", "1tool").is_err()); + assert!(normalize_key("f", "_tool").is_err()); + assert!(normalize_key("f", "tool-x").is_err()); + assert!(normalize_key("f", "tool x").is_err()); + assert_eq!( + normalize_key("f", "org_unit2").ok().as_deref(), + Some("org_unit2") + ); + } + + #[test] + fn validate_view_rejects_undeclared_dimension() { + let def = sum_definition(vec!["tool"]); + let view = MetricViewRequest::Breakdown { + dimensions: vec!["surface".to_owned()], + }; + assert!(validate_view(&def, view).is_err()); + } + + #[test] + fn validate_view_defaults_timeseries_bucket_to_day() { + let def = sum_definition(vec!["tool"]); + let view = MetricViewRequest::Timeseries { + bucket: None, + dimensions: vec![], + }; + match validate_view(&def, view) { + Ok(ValidatedMetricView::Timeseries { bucket, .. }) => assert_eq!(bucket, Bucket::Day), + other => panic!("expected timeseries, got {other:?}"), + } + } + + #[test] + fn validate_view_peer_uses_definition_default_cohort() { + let def = sum_definition(vec![]); + match validate_view(&def, MetricViewRequest::Peer { cohort_key: None }) { + Ok(ValidatedMetricView::Peer { cohort_key }) => assert_eq!(cohort_key, "org_unit"), + other => panic!("expected peer, got {other:?}"), + } + } + + #[test] + fn validate_view_rejects_empty_breakdown_dimensions() { + let def = sum_definition(vec!["tool"]); + let view = MetricViewRequest::Breakdown { dimensions: vec![] }; + assert!(validate_view(&def, view).is_err()); + } + + #[test] + fn enumerate_day_buckets_counts_days() { + let buckets = enumerate_buckets(day("2026-01-30"), day("2026-02-02"), Bucket::Day); + assert_eq!( + buckets, + vec!["2026-01-30", "2026-01-31", "2026-02-01", "2026-02-02"] + ); + } + + #[test] + fn enumerate_week_buckets_start_monday() { + let buckets = enumerate_buckets(day("2026-07-01"), day("2026-07-14"), Bucket::Week); + assert_eq!(buckets, vec!["2026-06-29", "2026-07-06", "2026-07-13"]); + } + + #[test] + fn enumerate_month_buckets_cross_year() { + let buckets = enumerate_buckets(day("2025-12-15"), day("2026-02-01"), Bucket::Month); + assert_eq!(buckets, vec!["2025-12-01", "2026-01-01", "2026-02-01"]); + } + + #[test] + fn enumerate_single_day_range() { + let buckets = enumerate_buckets(day("2026-07-02"), day("2026-07-02"), Bucket::Week); + assert_eq!(buckets, vec!["2026-06-29"]); + } + + #[test] + fn projected_row_limit_counts_timeseries_buckets() { + let def = sum_definition(vec![]); + let Some(exec) = def.executable() else { + panic!("sum must be executable"); + }; + let validated = ValidatedMetricResultsRequest { + entity_type: "person".to_owned(), + entity_ids: (0..100).map(|i| format!("p{i}@x.io")).collect(), + from: day("2026-01-01"), + to: day("2026-03-31"), + metrics: vec![ValidatedMetricRequest { + def, + exec, + views: vec![ValidatedMetricView::Timeseries { + bucket: Bucket::Day, + dimensions: vec![], + }], + }], + }; + assert!(validate_projected_row_limit(&validated).is_err()); + } + + #[test] + fn projected_row_limit_allows_small_requests() { + let def = sum_definition(vec![]); + let Some(exec) = def.executable() else { + panic!("sum must be executable"); + }; + let validated = ValidatedMetricResultsRequest { + entity_type: "person".to_owned(), + entity_ids: vec!["a@x.io".to_owned()], + from: day("2026-01-01"), + to: day("2026-01-31"), + metrics: vec![ValidatedMetricRequest { + def, + exec, + views: vec![ + ValidatedMetricView::Period, + ValidatedMetricView::Peer { + cohort_key: "org_unit".to_owned(), + }, + ], + }], + }; + assert!(validate_projected_row_limit(&validated).is_ok()); + } + + #[test] + fn executable_projection_covers_sum() { + let def = sum_definition(vec![]); + assert!(matches!(def.executable(), Some(ExecutableMetric::Sum(_)))); + } +} diff --git a/src/backend/services/analytics/src/domain/mod.rs b/src/backend/services/analytics/src/domain/mod.rs index b435a9b9b..8095c105a 100644 --- a/src/backend/services/analytics/src/domain/mod.rs +++ b/src/backend/services/analytics/src/domain/mod.rs @@ -2,6 +2,8 @@ pub mod admin_threshold; pub mod auth; pub mod catalog; pub mod metric; +pub mod metric_definitions; +pub mod metric_results; pub mod query; pub mod schema_validator; pub mod threshold; diff --git a/src/backend/services/analytics/src/gear.rs b/src/backend/services/analytics/src/gear.rs index b00bdaf91..133d20670 100644 --- a/src/backend/services/analytics/src/gear.rs +++ b/src/backend/services/analytics/src/gear.rs @@ -59,6 +59,12 @@ impl Gear for AnalyticsApiGear { // Run pending migrations. infra::db::run_migrations(&db).await?; + // Converge builtin metric definitions to the code registry before + // serving traffic. MySQL-only, so it does not violate the + // post-readiness ClickHouse rule; failure aborts startup because the + // registry state must be consistent before the first request. + crate::domain::metric_definitions::reconcile_builtin_definitions(&db).await?; + // Refuse to start if any required CHECK constraint is missing. See // `infra/db/check_probe` and DESIGN §2.2 // `cpt-metric-cat-constraint-mariadb-check`. @@ -125,6 +131,32 @@ impl Gear for AnalyticsApiGear { // Schema-validator (Refs #521). Held in AppState (admin-crud per-write // hook) and cloned into the post-init startup pass below. let validator = SchemaValidator::new(db.clone(), ch.clone()); + let metric_definition_validator = + crate::domain::metric_definitions::MetricDefinitionValidator::new( + db.clone(), + ch.clone(), + ); + if let Some(warehouse_tenant) = cfg + .metric_results + .single_tenant_warehouse_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + // The override maps EVERY tenant to one warehouse tenant, which is + // cross-tenant data exposure on a multi-tenant install. Require the + // install to declare itself single-tenant via the same signal the + // catalog stack uses (`metric_catalog.tenant_default_id`). + anyhow::ensure!( + cfg.metric_catalog.tenant_default_id.is_some(), + "metric_results.single_tenant_warehouse_id is set but metric_catalog.tenant_default_id is not; \ + this override is only valid on single-tenant installs — refusing to start" + ); + tracing::warn!( + warehouse_tenant = %warehouse_tenant, + "metric_results.single_tenant_warehouse_id is set: all tenants' metric-results queries read this warehouse tenant; valid only for single-tenant installs" + ); + } // Catalog auth-trait (Refs #522 / #525). v1 stub — see `domain::auth`. let tenant_auth: Arc = Arc::new( @@ -160,6 +192,9 @@ impl Gear for AnalyticsApiGear { tokio::spawn(async move { validator.validate_all().await; }); + tokio::spawn(async move { + metric_definition_validator.validate_all().await; + }); Ok(()) } @@ -199,6 +234,10 @@ pub async fn run_migrate(app: &toolkit::bootstrap::AppConfig) -> anyhow::Result< let db = infra::db::connect(&cfg.database_url).await?; infra::db::run_migrations(&db).await?; + // Same convergence as `init`: `migrate` run as a standalone step must + // leave builtin metric definitions matching the code registry. + crate::domain::metric_definitions::reconcile_builtin_definitions(&db).await?; + // Same probes as `init`. An operator running `migrate` standalone wants // the integrity signals too (DESIGN §2.2 / §3.6). infra::db::check_probe::assert_required_checks(&db).await?; diff --git a/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs new file mode 100644 index 000000000..48bd594c0 --- /dev/null +++ b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs @@ -0,0 +1,208 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +pub const REQUIRED_SOURCE_CHECKS: &[&str] = &[ + "chk_metric_sources_source_key_shape", + "chk_metric_sources_schema_error_biconditional", + "chk_metric_sources_schema_error_enum", +]; + +pub const REQUIRED_SOURCE_MEASURE_CHECKS: &[&str] = &[ + "chk_metric_source_measures_measure_key_shape", + "chk_metric_source_measures_schema_error_biconditional", + "chk_metric_source_measures_schema_error_enum", +]; + +pub const REQUIRED_SOURCE_DIMENSION_CHECKS: &[&str] = &[ + "chk_metric_source_dimensions_dimension_key_shape", + "chk_metric_source_dimensions_display_order_nonnegative", +]; + +pub const REQUIRED_DEFINITION_CHECKS: &[&str] = &[ + "chk_metric_definitions_metric_key_shape", + "chk_metric_definitions_entity_type_shape", + "chk_metric_definitions_peer_cohort_key_shape", + "chk_metric_definitions_computation_fields", + "chk_metric_definitions_version_positive", + "chk_metric_definitions_schema_error_biconditional", + "chk_metric_definitions_schema_error_enum", +]; + +pub const REQUIRED_INPUT_CHECKS: &[&str] = + &["chk_metric_definition_inputs_display_order_nonnegative"]; + +pub const REQUIRED_DIMENSION_CHECKS: &[&str] = + &["chk_metric_definition_dimensions_display_order_nonnegative"]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + for statement in SCHEMA_STATEMENTS { + conn.execute_unprepared(statement).await?; + } + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Err(DbErr::Custom("we have only forward migrations".to_owned())) + } +} + +const SCHEMA_STATEMENTS: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS metric_sources ( + id BINARY(16) NOT NULL PRIMARY KEY, + tenant_id BINARY(16) NULL, + tenant_id_sentinel BINARY(16) GENERATED ALWAYS AS (COALESCE(tenant_id, 0x00000000000000000000000000000000)) STORED, + source_key VARCHAR(128) NOT NULL, + source_kind ENUM('managed_observation','custom_observation_sql') NOT NULL, + source_ref VARCHAR(256) NOT NULL, + origin ENUM('builtin','custom') NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + schema_status ENUM('ok','error','unchecked') NOT NULL DEFAULT 'unchecked', + schema_checked_at DATETIME(3) NULL, + schema_error_code VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_metric_sources_tenant_key (tenant_id_sentinel, source_key), + CONSTRAINT chk_metric_sources_source_key_shape CHECK (source_key REGEXP BINARY '^[a-z][a-z0-9_]*$'), + CONSTRAINT chk_metric_sources_schema_error_biconditional CHECK ((schema_status = 'error') = (schema_error_code IS NOT NULL)), + CONSTRAINT chk_metric_sources_schema_error_enum CHECK (schema_error_code IS NULL OR schema_error_code IN ('table_not_found','column_not_found','dimension_not_covered','unknown')) + )", + "CREATE TABLE IF NOT EXISTS metric_source_measures ( + id BINARY(16) NOT NULL PRIMARY KEY, + source_id BINARY(16) NOT NULL, + measure_key VARCHAR(128) NOT NULL, + value_type ENUM('number','event','identifier') NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + schema_status ENUM('ok','error','unchecked') NOT NULL DEFAULT 'unchecked', + schema_checked_at DATETIME(3) NULL, + schema_error_code VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_metric_source_measures_key (source_id, measure_key), + CONSTRAINT fk_metric_source_measures_source FOREIGN KEY (source_id) REFERENCES metric_sources(id) ON DELETE CASCADE, + CONSTRAINT chk_metric_source_measures_measure_key_shape CHECK (measure_key REGEXP BINARY '^[a-z][a-z0-9_]*$'), + CONSTRAINT chk_metric_source_measures_schema_error_biconditional CHECK ((schema_status = 'error') = (schema_error_code IS NOT NULL)), + CONSTRAINT chk_metric_source_measures_schema_error_enum CHECK (schema_error_code IS NULL OR schema_error_code IN ('table_not_found','column_not_found','dimension_not_covered','unknown')) + )", + "CREATE TABLE IF NOT EXISTS metric_source_dimensions ( + id BINARY(16) NOT NULL PRIMARY KEY, + source_id BINARY(16) NOT NULL, + dimension_key VARCHAR(64) NOT NULL, + label VARCHAR(128) NOT NULL, + display_order INT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_metric_source_dimensions_key (source_id, dimension_key), + CONSTRAINT fk_metric_source_dimensions_source FOREIGN KEY (source_id) REFERENCES metric_sources(id) ON DELETE CASCADE, + CONSTRAINT chk_metric_source_dimensions_dimension_key_shape CHECK (dimension_key REGEXP BINARY '^[a-z][a-z0-9_]*$'), + CONSTRAINT chk_metric_source_dimensions_display_order_nonnegative CHECK (display_order >= 0) + )", + "CREATE TABLE IF NOT EXISTS metric_definitions ( + id BINARY(16) NOT NULL PRIMARY KEY, + tenant_id BINARY(16) NULL, + tenant_id_sentinel BINARY(16) GENERATED ALWAYS AS (COALESCE(tenant_id, 0x00000000000000000000000000000000)) STORED, + metric_key VARCHAR(128) NOT NULL, + label VARCHAR(128) NOT NULL, + description VARCHAR(2048) NULL, + unit VARCHAR(32) NULL, + format ENUM('integer','decimal','currency','percent') NOT NULL, + direction ENUM('higher_is_better','lower_is_better','neutral') NOT NULL, + entity_type VARCHAR(64) NOT NULL, + computation_type ENUM('sum','count','count_distinct','ratio','distribution','gauge','derived') NOT NULL, + scale DOUBLE NULL, + distribution_statistic ENUM('p50','p75','p90','p95','p99','avg') NULL, + gauge_method ENUM('latest','min','max','avg') NULL, + peer_cohort_key VARCHAR(64) NULL, + origin ENUM('builtin','custom') NOT NULL, + definition_version INT NOT NULL DEFAULT 1, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + schema_status ENUM('ok','error','unchecked') NOT NULL DEFAULT 'unchecked', + schema_checked_at DATETIME(3) NULL, + schema_error_code VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_metric_definitions_tenant_key (tenant_id_sentinel, metric_key), + KEY idx_metric_definitions_metric_key (metric_key), + CONSTRAINT chk_metric_definitions_metric_key_shape CHECK (metric_key REGEXP BINARY '^[a-z][a-z0-9_]*[.][a-z][a-z0-9_]*$'), + CONSTRAINT chk_metric_definitions_entity_type_shape CHECK (entity_type REGEXP BINARY '^[a-z][a-z0-9_]*$'), + CONSTRAINT chk_metric_definitions_peer_cohort_key_shape CHECK (peer_cohort_key IS NULL OR peer_cohort_key REGEXP BINARY '^[a-z][a-z0-9_]*$'), + CONSTRAINT chk_metric_definitions_computation_fields CHECK ( + (computation_type IN ('sum','count','count_distinct','derived') AND scale IS NULL AND distribution_statistic IS NULL AND gauge_method IS NULL) + OR (computation_type = 'ratio' AND scale IS NOT NULL AND distribution_statistic IS NULL AND gauge_method IS NULL) + OR (computation_type = 'distribution' AND scale IS NULL AND distribution_statistic IS NOT NULL AND gauge_method IS NULL) + OR (computation_type = 'gauge' AND scale IS NULL AND distribution_statistic IS NULL AND gauge_method IS NOT NULL) + ), + CONSTRAINT chk_metric_definitions_version_positive CHECK (definition_version > 0), + CONSTRAINT chk_metric_definitions_schema_error_biconditional CHECK ((schema_status = 'error') = (schema_error_code IS NOT NULL)), + CONSTRAINT chk_metric_definitions_schema_error_enum CHECK (schema_error_code IS NULL OR schema_error_code IN ('table_not_found','column_not_found','dimension_not_covered','unknown')) + )", + "CREATE TABLE IF NOT EXISTS metric_definition_inputs ( + id BINARY(16) NOT NULL PRIMARY KEY, + metric_definition_id BINARY(16) NOT NULL, + input_role ENUM('value','event','numerator','denominator','sample','snapshot','dependency') NOT NULL, + source_measure_id BINARY(16) NOT NULL, + display_order INT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_metric_definition_inputs_role_measure (metric_definition_id, input_role, source_measure_id), + CONSTRAINT fk_metric_definition_inputs_definition FOREIGN KEY (metric_definition_id) REFERENCES metric_definitions(id) ON DELETE CASCADE, + CONSTRAINT fk_metric_definition_inputs_measure FOREIGN KEY (source_measure_id) REFERENCES metric_source_measures(id) ON DELETE RESTRICT, + CONSTRAINT chk_metric_definition_inputs_display_order_nonnegative CHECK (display_order >= 0) + )", + "CREATE TABLE IF NOT EXISTS metric_definition_dimensions ( + id BINARY(16) NOT NULL PRIMARY KEY, + metric_definition_id BINARY(16) NOT NULL, + source_dimension_id BINARY(16) NOT NULL, + display_order INT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_metric_definition_dimensions_dimension (metric_definition_id, source_dimension_id), + CONSTRAINT fk_metric_definition_dimensions_definition FOREIGN KEY (metric_definition_id) REFERENCES metric_definitions(id) ON DELETE CASCADE, + CONSTRAINT fk_metric_definition_dimensions_source_dimension FOREIGN KEY (source_dimension_id) REFERENCES metric_source_dimensions(id) ON DELETE RESTRICT, + CONSTRAINT chk_metric_definition_dimensions_display_order_nonnegative CHECK (display_order >= 0) + )", +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::metric_definitions::error_code::ALL_METRIC_SCHEMA_ERROR_CODES; + + #[test] + fn schema_error_check_lists_match_error_code_enum() { + let expected = ALL_METRIC_SCHEMA_ERROR_CODES + .iter() + .map(|code| format!("'{}'", code.as_db_str())) + .collect::>() + .join(","); + let expected_clause = format!("schema_error_code IN ({expected})"); + + let mut check_count = 0; + for statement in SCHEMA_STATEMENTS { + if statement.contains("schema_error_code IN (") { + assert!( + statement.contains(&expected_clause), + "CHECK list out of sync with MetricSchemaErrorCode in: {statement}" + ); + check_count += 1; + } + } + assert_eq!(check_count, 3); + } + + #[test] + fn every_required_check_appears_in_schema() { + let all_sql = SCHEMA_STATEMENTS.join("\n"); + for check in REQUIRED_SOURCE_CHECKS + .iter() + .chain(REQUIRED_SOURCE_MEASURE_CHECKS) + .chain(REQUIRED_SOURCE_DIMENSION_CHECKS) + .chain(REQUIRED_DEFINITION_CHECKS) + .chain(REQUIRED_INPUT_CHECKS) + .chain(REQUIRED_DIMENSION_CHECKS) + { + assert!(all_sql.contains(check), "missing CHECK {check}"); + } + } +} diff --git a/src/backend/services/analytics/src/migration/mod.rs b/src/backend/services/analytics/src/migration/mod.rs index 3e0b5dfed..f2f5a7414 100644 --- a/src/backend/services/analytics/src/migration/mod.rs +++ b/src/backend/services/analytics/src/migration/mod.rs @@ -45,6 +45,7 @@ mod m20260623_000002_seed_ai_personal_metric_catalog; mod m20260624_000001_collab_zulip_chat; mod m20260624_000002_seed_zulip_collab_catalog; mod m20260624_000003_ic_chart_loc_git_breakdown; +mod m20260625_000001_metric_definitions; use sea_orm_migration::prelude::*; @@ -99,6 +100,7 @@ impl MigratorTrait for Migrator { Box::new(m20260624_000001_collab_zulip_chat::Migration), Box::new(m20260624_000002_seed_zulip_collab_catalog::Migration), Box::new(m20260624_000003_ic_chart_loc_git_breakdown::Migration), + Box::new(m20260625_000001_metric_definitions::Migration), ] } } @@ -121,6 +123,30 @@ pub const REQUIRED_CHECKS_BY_TABLE: &[(&str, &[&str])] = &[ "threshold_lock_audit", m20260522_000003_threshold_lock_audit::REQUIRED_CHECKS, ), + ( + "metric_sources", + m20260625_000001_metric_definitions::REQUIRED_SOURCE_CHECKS, + ), + ( + "metric_source_measures", + m20260625_000001_metric_definitions::REQUIRED_SOURCE_MEASURE_CHECKS, + ), + ( + "metric_source_dimensions", + m20260625_000001_metric_definitions::REQUIRED_SOURCE_DIMENSION_CHECKS, + ), + ( + "metric_definitions", + m20260625_000001_metric_definitions::REQUIRED_DEFINITION_CHECKS, + ), + ( + "metric_definition_inputs", + m20260625_000001_metric_definitions::REQUIRED_INPUT_CHECKS, + ), + ( + "metric_definition_dimensions", + m20260625_000001_metric_definitions::REQUIRED_DIMENSION_CHECKS, + ), ]; #[cfg(test)] @@ -157,6 +183,30 @@ mod tests { "threshold_lock_audit", m20260522_000003_threshold_lock_audit::REQUIRED_CHECKS, ), + ( + "metric_sources", + m20260625_000001_metric_definitions::REQUIRED_SOURCE_CHECKS, + ), + ( + "metric_source_measures", + m20260625_000001_metric_definitions::REQUIRED_SOURCE_MEASURE_CHECKS, + ), + ( + "metric_source_dimensions", + m20260625_000001_metric_definitions::REQUIRED_SOURCE_DIMENSION_CHECKS, + ), + ( + "metric_definitions", + m20260625_000001_metric_definitions::REQUIRED_DEFINITION_CHECKS, + ), + ( + "metric_definition_inputs", + m20260625_000001_metric_definitions::REQUIRED_INPUT_CHECKS, + ), + ( + "metric_definition_dimensions", + m20260625_000001_metric_definitions::REQUIRED_DIMENSION_CHECKS, + ), ]; for &(table, checks) in expected { diff --git a/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_assistant_usage.sql b/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_assistant_usage.sql index 070436e43..b6565d6ba 100644 --- a/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_assistant_usage.sql +++ b/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_assistant_usage.sql @@ -38,7 +38,9 @@ SELECT lower(trim(email)) AS email, toDate(date) AS day, 'chatgpt' AS tool, + 'ChatGPT' AS tool_label, 'chat' AS surface, + 'Chat' AS surface_label, -- chat is not session-bounded in the API → session_count NULL CAST(NULL AS Nullable(UInt32)) AS session_count, CAST(NULL AS Nullable(UInt32)) AS conversation_count, diff --git a/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_dev_usage.sql b/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_dev_usage.sql index 8ea0e3411..2d2c3de0a 100644 --- a/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_dev_usage.sql +++ b/src/ingestion/connectors/ai/chatgpt-team/dbt/chatgpt_team__ai_dev_usage.sql @@ -42,8 +42,10 @@ SELECT CAST(NULL AS Nullable(String)) AS api_key_id, toDate(date) AS day, 'codex' AS tool, + 'Codex' AS tool_label, -- Codex threads ≈ coding sessions. Non-nullable UInt32 per contract. toUInt32(coalesce(toUInt32OrNull(toString(n_threads)), 0)) AS session_count, + toUInt32OrNull(toString(n_threads)) AS conversation_count, toUInt32(coalesce(toUInt32OrNull(toString(lines_added)), 0)) AS lines_added, -- Codex does not surface AI-removed lines / total keystrokes. CAST(NULL AS Nullable(UInt32)) AS lines_removed, diff --git a/src/ingestion/connectors/ai/claude-admin/dbt/claude_admin__ai_dev_usage.sql b/src/ingestion/connectors/ai/claude-admin/dbt/claude_admin__ai_dev_usage.sql index 7b4e82e06..02cda0f0a 100644 --- a/src/ingestion/connectors/ai/claude-admin/dbt/claude_admin__ai_dev_usage.sql +++ b/src/ingestion/connectors/ai/claude-admin/dbt/claude_admin__ai_dev_usage.sql @@ -99,7 +99,9 @@ SELECT CASE WHEN u.actor_type = 'api_actor' THEN k.api_key_id END AS api_key_id, u.day, 'claude_code' AS tool, + 'Claude Code' AS tool_label, toUInt32(u.sessions_sum) AS session_count, + toUInt32OrNull(toString(u.sessions_sum)) AS conversation_count, toUInt32(u.lines_added_sum) AS lines_added, toUInt32(u.lines_removed_sum) AS lines_removed, -- total_lines_added/removed: Admin code_usage doesn't expose total keystrokes. NULL. @@ -115,6 +117,10 @@ SELECT -- CE-specific columns — NULL for Admin (Admin code_usage does not expose git attribution). CAST(NULL AS Nullable(UInt32)) AS commits_count, CAST(NULL AS Nullable(UInt32)) AS pull_requests_count, + -- prs_with_cc_count / prs_total_count: Claude Team-only (Anthropic GitHub-app attribution). + -- Structural NULL per Silver NULL-policy (presence of column required for UNION ALL parity). + CAST(NULL AS Nullable(UInt32)) AS prs_with_cc_count, + CAST(NULL AS Nullable(UInt32)) AS prs_total_count, CAST(NULL AS Nullable(String)) AS tool_action_breakdown_json, 'claude_admin' AS source, 'insight_claude_admin' AS data_source, diff --git a/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_assistant_usage.sql b/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_assistant_usage.sql index ad661cc5a..179aab778 100644 --- a/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_assistant_usage.sql +++ b/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_assistant_usage.sql @@ -90,7 +90,9 @@ chat AS ( email, day, 'claude' AS tool, + 'Claude' AS tool_label, 'chat' AS surface, + 'Chat' AS surface_label, -- chat is not session-bounded in the API → session_count NULL CAST(NULL AS Nullable(UInt32)) AS session_count, toUInt32OrNull(toString(chat_conversation_count)) AS conversation_count, @@ -138,7 +140,9 @@ excel AS ( email, day, 'claude' AS tool, + 'Claude' AS tool_label, 'excel' AS surface, + 'Excel' AS surface_label, toUInt32OrNull(toString(excel_session_count)) AS session_count, CAST(NULL AS Nullable(UInt32)) AS conversation_count, toUInt32OrNull(toString(excel_message_count)) AS message_count, @@ -173,7 +177,9 @@ powerpoint AS ( email, day, 'claude' AS tool, + 'Claude' AS tool_label, 'powerpoint' AS surface, + 'PowerPoint' AS surface_label, toUInt32OrNull(toString(powerpoint_session_count)) AS session_count, CAST(NULL AS Nullable(UInt32)) AS conversation_count, toUInt32OrNull(toString(powerpoint_message_count)) AS message_count, @@ -208,7 +214,9 @@ cowork AS ( email, day, 'claude' AS tool, + 'Claude' AS tool_label, 'cowork' AS surface, + 'Cowork' AS surface_label, toUInt32OrNull(toString(cowork_session_count)) AS session_count, CAST(NULL AS Nullable(UInt32)) AS conversation_count, toUInt32OrNull(toString(cowork_message_count)) AS message_count, @@ -251,7 +259,9 @@ cross_surface AS ( email, day, 'claude' AS tool, + 'Claude' AS tool_label, 'cross' AS surface, + 'Cross' AS surface_label, CAST(NULL AS Nullable(UInt32)) AS session_count, CAST(NULL AS Nullable(UInt32)) AS conversation_count, CAST(NULL AS Nullable(UInt32)) AS message_count, diff --git a/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_dev_usage.sql b/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_dev_usage.sql index 95169756e..e77c77ceb 100644 --- a/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_dev_usage.sql +++ b/src/ingestion/connectors/ai/claude-enterprise/dbt/claude_enterprise__ai_dev_usage.sql @@ -53,7 +53,9 @@ SELECT CAST(NULL AS Nullable(String)) AS api_key_id, toDate(parseDateTimeBestEffortOrNull(date)) AS day, 'claude_code' AS tool, + 'Claude Code' AS tool_label, toUInt32(coalesce(code_session_count, 0)) AS session_count, + toUInt32OrNull(toString(code_session_count)) AS conversation_count, toUInt32(coalesce(code_lines_added, 0)) AS lines_added, toUInt32(coalesce(code_lines_removed, 0)) AS lines_removed, -- Enterprise reports AI-accepted lines only — no view of total user keystrokes. diff --git a/src/ingestion/connectors/ai/claude-team/dbt/claude_team__ai_dev_usage.sql b/src/ingestion/connectors/ai/claude-team/dbt/claude_team__ai_dev_usage.sql index f27f9c352..15ae92f83 100644 --- a/src/ingestion/connectors/ai/claude-team/dbt/claude_team__ai_dev_usage.sql +++ b/src/ingestion/connectors/ai/claude-team/dbt/claude_team__ai_dev_usage.sql @@ -54,7 +54,9 @@ SELECT CAST(NULL AS Nullable(String)) AS api_key_id, toDate(metric_date) AS day, 'claude_code' AS tool, + 'Claude Code' AS tool_label, toUInt32(coalesce(total_sessions, 0)) AS session_count, + toUInt32OrNull(toString(total_sessions)) AS conversation_count, toUInt32(coalesce(total_lines_accepted, 0)) AS lines_added, -- NULL per NULL-policy (PR #553): Claude Team does not expose AI-removed -- lines — structural absence, not zero. diff --git a/src/ingestion/connectors/ai/cursor/dbt/cursor__ai_dev_usage.sql b/src/ingestion/connectors/ai/cursor/dbt/cursor__ai_dev_usage.sql index c977b7843..9e58ac57e 100644 --- a/src/ingestion/connectors/ai/cursor/dbt/cursor__ai_dev_usage.sql +++ b/src/ingestion/connectors/ai/cursor/dbt/cursor__ai_dev_usage.sql @@ -39,7 +39,9 @@ SELECT CAST(NULL AS Nullable(String)) AS api_key_id, toDate(fromUnixTimestamp64Milli(CAST(date AS Int64))) AS day, 'cursor' AS tool, + 'Cursor' AS tool_label, toUInt32(1) AS session_count, + CAST(NULL AS Nullable(UInt32)) AS conversation_count, toUInt32(coalesce(acceptedLinesAdded, 0)) AS lines_added, toUInt32(coalesce(acceptedLinesDeleted, 0)) AS lines_removed, -- total_lines_added/removed = ALL lines the user wrote/deleted that day diff --git a/src/ingestion/connectors/ai/github-copilot/dbt/copilot__ai_dev_usage.sql b/src/ingestion/connectors/ai/github-copilot/dbt/copilot__ai_dev_usage.sql index 8fb5c408e..e3345132d 100644 --- a/src/ingestion/connectors/ai/github-copilot/dbt/copilot__ai_dev_usage.sql +++ b/src/ingestion/connectors/ai/github-copilot/dbt/copilot__ai_dev_usage.sql @@ -104,10 +104,12 @@ SELECT CAST(NULL AS Nullable(String)) AS api_key_id, toDate(parseDateTimeBestEffortOrNull(m.day)) AS day, 'copilot' AS tool, + 'GitHub Copilot' AS tool_label, -- session_count: Copilot doesn't expose a per-day session counter; -- presence of an activity row implies at least one active session. -- Match Cursor's convention: 1 per active day. toUInt32(1) AS session_count, + CAST(NULL AS Nullable(UInt32)) AS conversation_count, toUInt32(coalesce(m.loc_added_sum, 0)) AS lines_added, toUInt32(coalesce(m.loc_deleted_sum, 0)) AS lines_removed, -- See header comment — Copilot reports AI-accepted lines only. diff --git a/src/ingestion/dbt/dbt_project.yml b/src/ingestion/dbt/dbt_project.yml index 61cf264a5..0dafc2eec 100644 --- a/src/ingestion/dbt/dbt_project.yml +++ b/src/ingestion/dbt/dbt_project.yml @@ -7,6 +7,7 @@ profile: ingestion model-paths: - identity - ../silver + - ../gold - ../connectors macro-paths: diff --git a/src/ingestion/silver/ai/class_ai_assistant_usage.sql b/src/ingestion/silver/ai/class_ai_assistant_usage.sql index 511908970..107e5cf41 100644 --- a/src/ingestion/silver/ai/class_ai_assistant_usage.sql +++ b/src/ingestion/silver/ai/class_ai_assistant_usage.sql @@ -5,6 +5,7 @@ schema='silver', engine='ReplacingMergeTree(_version)', order_by=['unique_key'], + on_schema_change='append_new_columns', settings={'allow_nullable_key': 1}, tags=['silver'] ) }} diff --git a/src/ingestion/silver/ai/class_ai_dev_usage.sql b/src/ingestion/silver/ai/class_ai_dev_usage.sql index 9e2d91e33..7d46c204d 100644 --- a/src/ingestion/silver/ai/class_ai_dev_usage.sql +++ b/src/ingestion/silver/ai/class_ai_dev_usage.sql @@ -5,6 +5,7 @@ schema='silver', engine='ReplacingMergeTree(_version)', order_by=['unique_key'], + on_schema_change='append_new_columns', settings={'allow_nullable_key': 1}, tags=['silver'] ) }} diff --git a/src/ingestion/silver/ai/schema.yml b/src/ingestion/silver/ai/schema.yml index 43505258f..bb7de0c15 100644 --- a/src/ingestion/silver/ai/schema.yml +++ b/src/ingestion/silver/ai/schema.yml @@ -6,7 +6,15 @@ models: Unified per-person per-day AI developer tool usage across Cursor, Claude Code (from Claude Enterprise for Enterprise orgs; from Claude Admin for Admin-only orgs), GitHub Copilot, and future sources (Windsurf, Codex). - One row per (tenant, email, day, tool). Feeds Gold metrics: + One row per (tenant, email, day, tool). + CONTRACT — activity invariant: a row exists ONLY when the person had real + activity with the tool that day. Every staging model in the + silver:class_ai_dev_usage tag must gate emission on a source-appropriate + activity signal (cursor: isActive=true; claude-team: status='active'; + claude-enterprise/copilot/chatgpt-team: activity-counter filters). + Downstream consumers (insight.ai_metric_observations active_day) rely on + row existence and must NOT re-derive activity from counters. + Feeds Gold metrics: cursor_active / cc_active / active_ai_members / ai_tools / ai_sessions / cursor_acceptance / cc_tool_acceptance / cursor_completions / cursor_agents / cursor_lines / cc_sessions / cc_lines / ai_loc_share_pct @@ -57,6 +65,21 @@ models: - accepted_values: arguments: values: ['cursor', 'claude_code', 'copilot', 'windsurf', 'codex'] + - name: tool_label + description: > + Display label for the tool, declared by the emitting staging model + (the connector owns its display name). Consumed by Gold dimension + tuples; no label mapping may live downstream. + tests: + - not_null + - name: conversation_count + description: > + Dev-tool conversations/threads on this day, populated ONLY by + connectors whose source actually reports them (Claude Code sessions, + Codex threads). Structural NULL where the source has no conversation + concept (Cursor, Copilot) — never a synthetic marker. Gold + dev_conversations sums this column with no tool filter; a connector + opts in by mapping the column. - name: session_count description: "Sessions recorded on this day. For Cursor: 1 per active day (Cursor only exposes an is-active flag, not session counts)." tests: @@ -250,7 +273,15 @@ models: Unified per-person per-day AI assistant surface usage. Covers Claude Enterprise chat, office (Excel/PowerPoint), cowork, and cross (surface-agnostic counters, currently web_search) surfaces. - One row per (tenant, email, day, surface). Feeds Gold metrics for + One row per (tenant, email, day, surface). + CONTRACT — activity invariant: a row exists ONLY when the person had real + activity on the surface that day. Every staging model in the + silver:class_ai_assistant_usage tag must gate emission on its surface + counters (claude-enterprise: per-surface counter filters; chatgpt-team: + chat counter filter). Downstream consumers + (insight.ai_metric_observations active_day) rely on row existence and + must NOT re-derive activity from counters. + Feeds Gold metrics for assistant adoption: chat_active / excel_active / powerpoint_active / cowork_active / cross_active / ai_adoption_pct (email IS NOT NULL AND conversation_count > 0). @@ -286,6 +317,13 @@ models: - accepted_values: arguments: values: ['claude', 'chatgpt', 'gemini'] + - name: tool_label + description: > + Display label for the assistant vendor, declared by the emitting + staging model. Consumed by Gold dimension tuples; no label mapping + may live downstream. + tests: + - not_null - name: surface description: > Per-vendor surface discriminator. For tool='claude': 'chat' = Claude.ai @@ -298,6 +336,13 @@ models: - accepted_values: arguments: values: ['chat', 'excel', 'powerpoint', 'cowork', 'cross'] + - name: surface_label + description: > + Display label for the surface, declared by the emitting staging + model. Consumed by Gold dimension tuples; no label mapping may live + downstream. + tests: + - not_null - name: session_count description: > Number of distinct sessions on the surface. From 7c2c24bbd63e686d84dd6129e38f44875cebee6b Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Sun, 5 Jul 2026 16:20:11 +0200 Subject: [PATCH 02/20] feat(ingestion): managed metric observation gold models for AI usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbt-owned gold views emit the source measure observation contract for the metrics runtime: insight.ai_metric_observations (measure streams from the AI dev and assistant classes) and insight.metric_entity_cohorts_current (person cohort membership for peer comparison), with schema tests and a cohort-uniqueness check. The AI class contracts gain connector-declared semantics: tool_label / surface_label display labels and conversation_count (populated only by sources that report conversations). Class rows carry an activity invariant — a row exists only for real activity, enforced per staging model and guarded by data-quality tests — so gold derives active days from row existence and contains no vendor-specific columns, tool names, or label mappings. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- .../assert_ai_assistant_usage_rows_active.sql | 34 +++ .../ai/assert_ai_dev_usage_rows_active.sql | 34 +++ .../assert_metric_entity_cohorts_unique.sql | 14 + src/ingestion/gold/ai_metric_observations.sql | 287 ++++++++++++++++++ .../gold/metric_entity_cohorts_current.sql | 37 +++ src/ingestion/gold/schema.yml | 109 +++++++ .../scripts/create-bronze-placeholders.sh | 40 +++ 7 files changed, 555 insertions(+) create mode 100644 src/ingestion/dbt/tests/ai/assert_ai_assistant_usage_rows_active.sql create mode 100644 src/ingestion/dbt/tests/ai/assert_ai_dev_usage_rows_active.sql create mode 100644 src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql create mode 100644 src/ingestion/gold/ai_metric_observations.sql create mode 100644 src/ingestion/gold/metric_entity_cohorts_current.sql create mode 100644 src/ingestion/gold/schema.yml diff --git a/src/ingestion/dbt/tests/ai/assert_ai_assistant_usage_rows_active.sql b/src/ingestion/dbt/tests/ai/assert_ai_assistant_usage_rows_active.sql new file mode 100644 index 000000000..77b7e5c13 --- /dev/null +++ b/src/ingestion/dbt/tests/ai/assert_ai_assistant_usage_rows_active.sql @@ -0,0 +1,34 @@ +{{ config( + tags=['data_quality'], + severity='warn', + store_failures=true, + meta={ + 'title': 'class_ai_assistant_usage rows carry real activity', + 'domain': 'ai', + 'category': 'grain', + 'tier': 'error', + 'remediation': 'The class contract guarantees that a (person, day, tool, surface) row exists only when the person actually used the surface that day — insight.ai_metric_observations derives active_day from row existence. A row here means a staging model in the silver:class_ai_assistant_usage tag emitted a zero-activity row: fix that model''s emission filter, do not patch consumers.' + } +) }} +SELECT + insight_tenant_id, + email, + day, + tool, + surface, + source +FROM {{ ref('class_ai_assistant_usage') }} +WHERE coalesce(session_count, 0) = 0 + AND coalesce(conversation_count, 0) = 0 + AND coalesce(message_count, 0) = 0 + AND coalesce(action_count, 0) = 0 + AND coalesce(files_uploaded_count, 0) = 0 + AND coalesce(artifacts_created_count, 0) = 0 + AND coalesce(projects_created_count, 0) = 0 + AND coalesce(projects_used_count, 0) = 0 + AND coalesce(skills_used_count, 0) = 0 + AND coalesce(connectors_used_count, 0) = 0 + AND coalesce(thinking_message_count, 0) = 0 + AND coalesce(dispatch_turn_count, 0) = 0 + AND coalesce(search_count, 0) = 0 + AND coalesce(cost_cents, 0) = 0 diff --git a/src/ingestion/dbt/tests/ai/assert_ai_dev_usage_rows_active.sql b/src/ingestion/dbt/tests/ai/assert_ai_dev_usage_rows_active.sql new file mode 100644 index 000000000..8fb08420f --- /dev/null +++ b/src/ingestion/dbt/tests/ai/assert_ai_dev_usage_rows_active.sql @@ -0,0 +1,34 @@ +{{ config( + tags=['data_quality'], + severity='warn', + store_failures=true, + meta={ + 'title': 'class_ai_dev_usage rows carry real activity', + 'domain': 'ai', + 'category': 'grain', + 'tier': 'error', + 'remediation': 'The class contract guarantees that a (person, day, tool) row exists only when the person actually used the tool that day — insight.ai_metric_observations derives active_day from row existence. A row here means a staging model in the silver:class_ai_dev_usage tag emitted a zero-activity row (seat/roster entry): fix that model''s emission filter, do not patch consumers.' + } +) }} +SELECT + insight_tenant_id, + email, + day, + tool, + source +FROM {{ ref('class_ai_dev_usage') }} +WHERE coalesce(session_count, 0) = 0 + AND coalesce(conversation_count, 0) = 0 + AND coalesce(lines_added, 0) = 0 + AND coalesce(lines_removed, 0) = 0 + AND coalesce(total_lines_added, 0) = 0 + AND coalesce(total_lines_removed, 0) = 0 + AND coalesce(tool_use_offered, 0) = 0 + AND coalesce(tool_use_accepted, 0) = 0 + AND coalesce(agent_sessions, 0) = 0 + AND coalesce(chat_requests, 0) = 0 + AND coalesce(cost_cents, 0) = 0 + AND coalesce(commits_count, 0) = 0 + AND coalesce(pull_requests_count, 0) = 0 + AND coalesce(prs_with_cc_count, 0) = 0 + AND coalesce(prs_total_count, 0) = 0 diff --git a/src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql b/src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql new file mode 100644 index 000000000..db2cdbe1d --- /dev/null +++ b/src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql @@ -0,0 +1,14 @@ +-- Build-integrity check (untagged → error severity under `dbt build`). +-- The analytics-api peer view joins insight.metric_entity_cohorts_current on +-- (tenant_id, entity_type, entity_id, cohort_key) and assumes exactly one row +-- per key — duplicate rows fan out the join and corrupt peer percentiles. +-- Any returned row is a violation of that contract. +SELECT + tenant_id, + entity_type, + entity_id, + cohort_key, + count() AS row_count +FROM {{ ref('metric_entity_cohorts_current') }} +GROUP BY tenant_id, entity_type, entity_id, cohort_key +HAVING count() > 1 diff --git a/src/ingestion/gold/ai_metric_observations.sql b/src/ingestion/gold/ai_metric_observations.sql new file mode 100644 index 000000000..d0bfa56bb --- /dev/null +++ b/src/ingestion/gold/ai_metric_observations.sql @@ -0,0 +1,287 @@ +{{ config( + materialized='view', + schema='insight', + alias='ai_metric_observations', + tags=['gold'] +) }} + +-- Source measure observations for the unified metrics runtime. Reads only +-- class-contract fields: activity is row existence (the class contract +-- guarantees rows exist only for real activity — see silver/ai/schema.yml), +-- display labels come from tool_label / surface_label, and conversation +-- semantics come from data presence (conversation_count is NULL for sources +-- without a conversation concept). No vendor-specific columns, tool names, or +-- label mappings may appear in this model. + +WITH +ai_dev_usage_source AS ( + SELECT + insight_tenant_id AS tenant_id, + lower(email) AS entity_id, + day AS metric_date, + coalesce(nullIf(tool, ''), '__unknown__') AS tool_value, + if( + coalesce(nullIf(tool, ''), '__unknown__') = '__unknown__', + 'Unknown', + coalesce(nullIf(tool_label, ''), tool) + ) AS tool_label_value, + conversation_count, + lines_added, + lines_removed, + tool_use_offered, + tool_use_accepted, + cost_cents + FROM {{ ref('class_ai_dev_usage') }} + WHERE email IS NOT NULL + AND email != '' +), +ai_assistant_usage_source AS ( + SELECT + insight_tenant_id AS tenant_id, + lower(email) AS entity_id, + day AS metric_date, + coalesce(nullIf(tool, ''), '__unknown__') AS tool_value, + if( + coalesce(nullIf(tool, ''), '__unknown__') = '__unknown__', + 'Unknown', + coalesce(nullIf(tool_label, ''), tool) + ) AS tool_label_value, + coalesce(nullIf(surface, ''), '__unknown__') AS surface_value, + if( + coalesce(nullIf(surface, ''), '__unknown__') = '__unknown__', + 'Unknown', + coalesce(nullIf(surface_label, ''), surface) + ) AS surface_label_value, + conversation_count, + message_count, + action_count, + cost_cents + FROM {{ ref('class_ai_assistant_usage') }} + WHERE email IS NOT NULL + AND email != '' +), +ai_dev_usage_dimensions AS ( + SELECT + *, + CAST( + [tuple('tool', tool_value, tool_label_value)] + AS Array(Tuple(key String, value String, label Nullable(String))) + ) AS tool_dimensions + FROM ai_dev_usage_source +), +ai_assistant_usage_dimensions AS ( + SELECT + *, + CAST( + [tuple('tool', tool_value, tool_label_value)] + AS Array(Tuple(key String, value String, label Nullable(String))) + ) AS tool_dimensions, + CAST( + [ + tuple('tool', tool_value, tool_label_value), + tuple('surface', surface_value, surface_label_value) + ] AS Array(Tuple(key String, value String, label Nullable(String))) + ) AS tool_surface_dimensions + FROM ai_assistant_usage_source +), +ai_active_day_source AS ( + SELECT DISTINCT + tenant_id, + entity_id, + metric_date + FROM ai_dev_usage_source + + UNION DISTINCT + + SELECT DISTINCT + tenant_id, + entity_id, + metric_date + FROM ai_assistant_usage_source +), +measure_observations AS ( + SELECT + tenant_id, + entity_id, + metric_date, + 'accepted_lines' AS measure_key, + if( + countIf(lines_added IS NOT NULL) > 0, + sumIf(toFloat64(lines_added), lines_added IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_dimensions AS dimensions + FROM ai_dev_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'removed_lines' AS measure_key, + if( + countIf(lines_removed IS NOT NULL) > 0, + sumIf(toFloat64(lines_removed), lines_removed IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_dimensions AS dimensions + FROM ai_dev_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'active_day' AS measure_key, + toNullable(toFloat64(1)) AS value, + CAST([] AS Array(Tuple(key String, value String, label Nullable(String)))) AS dimensions + FROM ai_active_day_source + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'cost_usd' AS measure_key, + if( + countIf(cost_cents IS NOT NULL) > 0, + sumIf(toFloat64(cost_cents), cost_cents IS NOT NULL) / 100, + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_dimensions AS dimensions + FROM ai_dev_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'cost_usd' AS measure_key, + if( + countIf(cost_cents IS NOT NULL) > 0, + sumIf(toFloat64(cost_cents), cost_cents IS NOT NULL) / 100, + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_dimensions AS dimensions + FROM ai_assistant_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'accepted_edit_actions' AS measure_key, + if( + countIf(tool_use_accepted IS NOT NULL) > 0, + sumIf(toFloat64(tool_use_accepted), tool_use_accepted IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_dimensions AS dimensions + FROM ai_dev_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'tool_use_offered' AS measure_key, + if( + countIf(tool_use_offered IS NOT NULL) > 0, + sumIf(toFloat64(tool_use_offered), tool_use_offered IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_dimensions AS dimensions + FROM ai_dev_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'dev_conversations' AS measure_key, + if( + countIf(conversation_count IS NOT NULL) > 0, + sumIf(toFloat64(conversation_count), conversation_count IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_dimensions AS dimensions + FROM ai_dev_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'assistant_messages' AS measure_key, + if( + countIf(message_count IS NOT NULL) > 0, + sumIf(toFloat64(message_count), message_count IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_surface_dimensions AS dimensions + FROM ai_assistant_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_surface_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'assistant_actions' AS measure_key, + if( + countIf(action_count IS NOT NULL) > 0, + sumIf(toFloat64(action_count), action_count IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_surface_dimensions AS dimensions + FROM ai_assistant_usage_dimensions + GROUP BY tenant_id, entity_id, metric_date, tool_surface_dimensions + + UNION ALL + + SELECT + tenant_id, + entity_id, + metric_date, + 'chat_assistant_conversations' AS measure_key, + if( + countIf(conversation_count IS NOT NULL) > 0, + sumIf(toFloat64(conversation_count), conversation_count IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + tool_surface_dimensions AS dimensions + FROM ai_assistant_usage_dimensions + WHERE surface_value = 'chat' + GROUP BY tenant_id, entity_id, metric_date, tool_surface_dimensions +) +SELECT + assumeNotNull(tenant_id) AS tenant_id, + 'ai_usage' AS source_key, + 'person' AS entity_type, + assumeNotNull(entity_id) AS entity_id, + assumeNotNull(metric_date) AS metric_date, + CAST(NULL AS Nullable(DateTime64(3))) AS observed_at, + measure_key, + value, + CAST(NULL AS Nullable(String)) AS subject_key, + dimensions +FROM measure_observations +WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND metric_date IS NOT NULL diff --git a/src/ingestion/gold/metric_entity_cohorts_current.sql b/src/ingestion/gold/metric_entity_cohorts_current.sql new file mode 100644 index 000000000..90b98f861 --- /dev/null +++ b/src/ingestion/gold/metric_entity_cohorts_current.sql @@ -0,0 +1,37 @@ +{{ config( + materialized='view', + schema='insight', + alias='metric_entity_cohorts_current', + tags=['gold'] +) }} + +SELECT + assumeNotNull(tenant_id) AS tenant_id, + 'person' AS entity_type, + assumeNotNull(entity_id) AS entity_id, + 'org_unit' AS cohort_key, + cohort_id +FROM ( + SELECT + workspace_id AS tenant_id, + lower(assumeNotNull(email)) AS entity_id, + coalesce( + nullIf(toString(org_unit_id), ''), + nullIf(department_name, '') + ) AS cohort_id + FROM {{ ref('class_people') }} + WHERE email IS NOT NULL + AND email != '' + AND workspace_id IS NOT NULL + AND workspace_id != '' + ORDER BY + tenant_id, + entity_id, + coalesce(parseDateTimeBestEffortOrNull(toString(valid_from)), toDateTime('1970-01-01')) DESC, + unique_key DESC + LIMIT 1 BY tenant_id, entity_id +) +WHERE tenant_id IS NOT NULL + AND tenant_id != '' + AND entity_id IS NOT NULL + AND entity_id != '' diff --git a/src/ingestion/gold/schema.yml b/src/ingestion/gold/schema.yml new file mode 100644 index 000000000..619c8871e --- /dev/null +++ b/src/ingestion/gold/schema.yml @@ -0,0 +1,109 @@ +version: 2 + +models: + - name: ai_metric_observations + description: > + Source measure observations for the unified metrics runtime. One row per + (tenant, entity, day, measure, dimension tuple). Emits measures, not + metrics: the analytics-api metric registry binds input roles to + measure keys and compiles result queries against this relation. The + column set is a published contract — the analytics-api schema validator + probes it at runtime (`OBSERVATION_COLUMNS`); changing it requires a + coordinated backend change. + columns: + - name: tenant_id + description: "Tenant isolation field" + tests: + - not_null + - name: source_key + description: "Logical source key registered in the metric registry" + tests: + - not_null + - accepted_values: + arguments: + values: ['ai_usage'] + - name: entity_type + description: "Measured entity type" + tests: + - not_null + - accepted_values: + arguments: + values: ['person'] + - name: entity_id + description: "Normalized entity identifier (lowercased email for person)" + tests: + - not_null + - name: metric_date + description: "Observation date" + tests: + - not_null + - name: observed_at + description: > + Observation timestamp for gauge/latest semantics. NULL for day-grain + aggregate measures. + - name: measure_key + description: "Source measure key registered in the metric registry" + tests: + - not_null + - accepted_values: + arguments: + values: + - accepted_lines + - removed_lines + - active_day + - cost_usd + - accepted_edit_actions + - tool_use_offered + - dev_conversations + - assistant_messages + - assistant_actions + - chat_assistant_conversations + - name: value + description: > + Measure value. NULL only when the source cannot provide a value for + an otherwise-present observation row. + - name: subject_key + description: > + Distinct-count subject for count_distinct semantics. NULL for + measures without distinct-count support. + - name: dimensions + description: > + Array(Tuple(key, value, label)) of all dimensions the source emits + for the measure. Missing dimension values use value '__unknown__' + and label 'Unknown'. + + - name: metric_entity_cohorts_current + description: > + Current cohort membership per entity for peer comparison. One row per + (tenant, entity_type, entity_id, cohort_key) — uniqueness is asserted by + tests/gold/assert_metric_entity_cohorts_unique.sql and relied on by the + analytics-api peer view (join fan-out would corrupt percentiles). The + column set is a published contract probed by the analytics-api schema + validator (`COHORT_COLUMNS`). + columns: + - name: tenant_id + description: "Tenant isolation field" + tests: + - not_null + - name: entity_type + description: "Entity type the cohort row applies to" + tests: + - not_null + - accepted_values: + arguments: + values: ['person'] + - name: entity_id + description: "Normalized entity identifier (lowercased email for person)" + tests: + - not_null + - name: cohort_key + description: "Cohort basis identifier" + tests: + - not_null + - accepted_values: + arguments: + values: ['org_unit'] + - name: cohort_id + description: > + Cohort membership value: org unit id when present, department name + fallback otherwise. NULL when the person has neither. diff --git a/src/ingestion/scripts/create-bronze-placeholders.sh b/src/ingestion/scripts/create-bronze-placeholders.sh index a30365eec..4073d59cf 100644 --- a/src/ingestion/scripts/create-bronze-placeholders.sh +++ b/src/ingestion/scripts/create-bronze-placeholders.sh @@ -214,7 +214,9 @@ CREATE TABLE IF NOT EXISTS silver.class_ai_dev_usage ( api_key_id Nullable(String), day Date, tool String, + tool_label String DEFAULT '', is_active UInt8, + conversation_count Nullable(Float64), agent_sessions Nullable(Float64), chat_requests Nullable(Float64), tool_use_offered Nullable(Float64), @@ -257,6 +259,8 @@ else ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS source_id String; ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS unique_key String; ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS api_key_id Nullable(String); +ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS tool_label String DEFAULT ''; +ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS conversation_count Nullable(Float64); ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS lines_removed Nullable(Float64); ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS total_lines_removed Nullable(Float64); ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS commits_count Nullable(Float64); @@ -389,7 +393,9 @@ CREATE TABLE IF NOT EXISTS silver.class_ai_assistant_usage ( email String, day Date, tool String, + tool_label String DEFAULT '', surface String, + surface_label String DEFAULT '', session_count Nullable(UInt32), conversation_count Nullable(UInt32), message_count Nullable(UInt32), @@ -411,6 +417,21 @@ CREATE TABLE IF NOT EXISTS silver.class_ai_assistant_usage ( _version UInt64 ) ENGINE = ReplacingMergeTree(_version) ORDER BY unique_key COMMENT 'INSIGHT_PLACEHOLDER_v1'; SQL +else + class_ai_assistant_placeholder_count="$( + printf "SELECT count() FROM system.tables WHERE database='silver' AND name='class_ai_assistant_usage' AND comment='INSIGHT_PLACEHOLDER_v1'" | + _ch_http_query | + tr -d '[:space:]' + )" + if [[ "$class_ai_assistant_placeholder_count" == "1" ]]; then + echo " Reconciling placeholder schema: silver.class_ai_assistant_usage" + run_ch <<'SQL' +ALTER TABLE silver.class_ai_assistant_usage ADD COLUMN IF NOT EXISTS tool_label String DEFAULT ''; +ALTER TABLE silver.class_ai_assistant_usage ADD COLUMN IF NOT EXISTS surface_label String DEFAULT ''; +SQL + else + echo " Skipping placeholder schema reconciliation: silver.class_ai_assistant_usage is not a placeholder" + fi fi # silver.class_people — identity dbt model. Used by crm-gold-views and any @@ -421,11 +442,30 @@ if ! ch_table_exists silver class_people; then run_ch <<'SQL' CREATE TABLE IF NOT EXISTS silver.class_people ( unique_key String, + workspace_id String DEFAULT '', email Nullable(String), + valid_from Nullable(String), department_name Nullable(String), + org_unit_id Nullable(String), _version UInt64 ) ENGINE = ReplacingMergeTree(_version) ORDER BY unique_key COMMENT 'INSIGHT_PLACEHOLDER_v1'; SQL +else + class_people_placeholder_count="$( + printf "SELECT count() FROM system.tables WHERE database='silver' AND name='class_people' AND comment='INSIGHT_PLACEHOLDER_v1'" | + _ch_http_query | + tr -d '[:space:]' + )" + if [[ "$class_people_placeholder_count" == "1" ]]; then + echo " Reconciling placeholder schema: silver.class_people" + run_ch <<'SQL' +ALTER TABLE silver.class_people ADD COLUMN IF NOT EXISTS workspace_id String DEFAULT ''; +ALTER TABLE silver.class_people ADD COLUMN IF NOT EXISTS valid_from Nullable(String); +ALTER TABLE silver.class_people ADD COLUMN IF NOT EXISTS org_unit_id Nullable(String); +SQL + else + echo " Skipping placeholder schema reconciliation: silver.class_people is not a placeholder" + fi fi # silver.class_crm_users — CRM dbt model (HubSpot owners + Salesforce users). From 20a48fcb85908977ad5daf36de0f2430894d596b Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Sun, 5 Jul 2026 16:20:11 +0200 Subject: [PATCH 03/20] docs(metrics): metrics domain spec with authoring guide The metrics system contract moves to docs/domain/metrics/specs/DESIGN.md with an Adding a Metric guide covering the three authoring cases (existing measure, new measure, new source) and the rules that hold for every case. Root AGENTS.md routes metric work to the spec; the legacy ad-hoc gold-view + catalog-seed path is frozen for new metrics. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/README.md | 33 +++ docs/domain/metrics/specs/DESIGN.md | 419 ++++++++++++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 docs/domain/metrics/README.md create mode 100644 docs/domain/metrics/specs/DESIGN.md diff --git a/docs/domain/metrics/README.md b/docs/domain/metrics/README.md new file mode 100644 index 000000000..cd7e4120d --- /dev/null +++ b/docs/domain/metrics/README.md @@ -0,0 +1,33 @@ +# Metrics Domain + +The unified metrics system: metrics are defined once in a typed registry, +computed by one generic runtime over normalized source measure observations, +and served self-describing through `POST /v1/metric-results`. All new metrics +are authored through this system. + +## Documents + +| Document | Description | +|---|---| +| [`specs/DESIGN.md`](specs/DESIGN.md) | System contract: observation contract, registry model, computations, result API, validation, authoring guide ("Adding a Metric") | + +## Implementation + +| Layer | Location | +|---|---| +| Metric registry (builtin seeds) | [`src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs`](../../../src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs) | +| Definition loading, reconciler, schema validator | [`src/backend/services/analytics-api/src/domain/metric_definitions/`](../../../src/backend/services/analytics-api/src/domain/metric_definitions/) | +| Result runtime (validation, query compiler, response builder) | [`src/backend/services/analytics-api/src/domain/metric_results/`](../../../src/backend/services/analytics-api/src/domain/metric_results/) | +| Result endpoint | [`src/backend/services/analytics-api/src/api/metric_results.rs`](../../../src/backend/services/analytics-api/src/api/metric_results.rs) | +| Registry schema migration | [`src/backend/services/analytics-api/src/migration/m20260625_000001_metric_definitions.rs`](../../../src/backend/services/analytics-api/src/migration/m20260625_000001_metric_definitions.rs) | +| Managed observation sources (dbt gold models) | [`src/ingestion/gold/`](../../../src/ingestion/gold/) | +| Class-contract data-quality tests | [`src/ingestion/dbt/tests/ai/`](../../../src/ingestion/dbt/tests/ai/) | + +## Boundaries + +- The AI class contracts feeding the observation models are documented in + [`src/ingestion/silver/ai/schema.yml`](../../../src/ingestion/silver/ai/schema.yml) + (activity invariant, label and conversation-count semantics). +- The legacy metric path ([`metric-catalog/`](../metric-catalog/) + + ad-hoc `insight.*` gold views) is frozen for new metrics and remains only + until its consumers migrate. diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md new file mode 100644 index 000000000..bcd28331f --- /dev/null +++ b/docs/domain/metrics/specs/DESIGN.md @@ -0,0 +1,419 @@ +# Technical Design — Metrics + +Status: active implementation contract. + +The metrics system computes metric result views from typed metric definitions +and normalized source measure observations. Metrics are authored, requested, +and rendered through one structured path: a registry defines metric semantics, +dbt gold models emit source measure observations, and one generic runtime +compiles and serves every metric. + +New metrics MUST be added through this system. The legacy path — ad-hoc +`insight.*` gold views in `src/ingestion/scripts/migrations/` plus +`metric_catalog` seed migrations — is frozen for new metrics and remains only +until its existing consumers migrate. + +## Goals + +- Define metrics once and query them through one generic runtime. +- Model metrics by semantic computation, not by current UI cards. +- Support multiple entity types, with `person` as the first consumer. +- Keep backend responses self-describing enough for frontend rendering. +- Keep chart choice and layout out of backend metric contracts. +- Use typed Rust and TypeScript unions for states that cannot coexist. + +## Source Measure Observation Contract + +Managed observation sources expose rows shaped like: + +```sql +tenant_id String, +source_key String, +entity_type String, +entity_id String, +metric_date Date, +observed_at Nullable(DateTime64(3)), +measure_key String, +value Nullable(Float64), +subject_key Nullable(String), +dimensions Array(Tuple(key String, value String, label Nullable(String))) +``` + +Rules: + +- Observations belong to source measures, not final metrics. +- `source_key` identifies the logical source. +- `measure_key` identifies the source measure. +- `entity_type` and `entity_id` identify the measured entity. +- `observed_at` is available for gauge/latest semantics. +- `subject_key` is available for distinct-count semantics. +- Missing dimensions use value `__unknown__` and label `Unknown`. +- Observations do not contain chart metadata. +- Observations do not contain cohort membership. Peer comparison reads the + cohort view directly. + +## Managed Source Ownership + +Managed observation sources and the cohort view are dbt gold models +(`src/ingestion/gold/`), materialized as views in the `insight` database: + +- `insight.ai_metric_observations` +- `insight.metric_entity_cohorts_current` + +dbt owns lineage to silver, build ordering, column documentation, and data +tests (including cohort uniqueness). The backend owns the registry, query +compilation, and runtime schema validation against these relations. Column +changes are coordinated changes: dbt model + `schema.yml` + backend +`OBSERVATION_COLUMNS`/`COHORT_COLUMNS` + this document. + +The cohort view is unique per `(tenant_id, entity_type, entity_id, +cohort_key)`. The peer query relies on this; a dbt build-integrity test +asserts it. + +## Computations + +Executable computation vocabulary: + +```text +sum +count +count_distinct +ratio +distribution +gauge +derived +``` + +Current execution support: + +```text +sum executable +ratio executable +others typed and stored, rejected with UNSUPPORTED_COMPUTATION when requested +``` + +Semantics: + +- `sum`: sum one numeric measure. +- `count`: count source rows for one event measure. +- `count_distinct`: count distinct `subject_key` values. +- `ratio`: aggregate numerator and denominator measures first, then divide. +- `distribution`: compute one configured statistic from sample values. +- `gauge`: compute one configured snapshot method. +- `derived`: reserved for expressions over other metrics. + +Ratios use: + +```text +sum(numerator) / nullIf(sum(denominator), 0) * scale +``` + +They are not averages of row-level ratios. + +Ratio numerator and denominator inputs must resolve to measures of the same +source. Cross-source ratios are a configuration error; they belong to the +`derived` computation when it becomes executable. + +## Storage Model + +Metric definitions are stored separately from legacy metric/catalog concepts. + +Tables: + +```text +metric_sources +metric_source_measures +metric_source_dimensions +metric_definitions +metric_definition_inputs +metric_definition_dimensions +``` + +`metric_sources` stores typed source refs. + +`metric_source_measures` stores measures available from a source. + +`metric_source_dimensions` stores dimensions available from a source. + +`metric_definitions` stores metric metadata and computation type: + +```text +metric_key +label +description +unit +format +direction +entity_type +computation_type +scale +distribution_statistic +gauge_method +peer_cohort_key +origin +definition_version +is_enabled +schema_status +schema_error_code +``` + +`metric_definition_inputs` maps input roles to source measures: + +```text +value +event +numerator +denominator +sample +snapshot +dependency +``` + +`metric_definition_dimensions` maps metrics to source dimensions. + +Rules: + +- Product definitions have `tenant_id = NULL`. +- Tenant definitions override product definitions for the same key. +- Disabled definitions, sources, or measures are unavailable. +- Schema-error definitions, sources, or measures are unavailable. +- A disabled or schema-error tenant definition falls back to the product + definition for the same key instead of shadowing it. +- Raw DB source refs are converted into typed backend enums before SQL generation. + +## Builtin Seed Reconciliation + +Builtin definitions are declared in one code registry +(`src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs`) +and converged into the DB by a startup reconciler, not by migrations. +Migrations own schema only. + +Rules: + +- The reconciler runs synchronously after migrations, before serving traffic, + and on the `migrate` CLI command. +- Upserts are idempotent and race-safe across replicas. +- Builtin sources, measures, and definitions absent from the registry are + disabled, never deleted. +- Source dimension rows have no enabled flag; rows removed from the registry + stay in place and are inert unless a definition links them. +- Tenant-owned rows are never touched by reconciliation. +- Warm environments converge to the registry state on every deploy. + +## Result API + +Endpoint: + +```http +POST /v1/metric-results +``` + +Request: + +```ts +type MetricResultsRequest = { + entity: { type: string; ids: string[] } + period: { from: string; to: string } + metrics: Array<{ + metric_key: string + views: Array< + | { view: "period" } + | { view: "peer"; cohort_key?: string } + | { view: "timeseries"; bucket?: "day" | "week" | "month"; dimensions?: string[] } + | { view: "breakdown"; dimensions: string[] } + > + }> +} +``` + +Response: + +```ts +type MetricResult = + | { computation: "sum"; views: MetricResultView[] } + | { computation: "count"; views: MetricResultView[] } + | { computation: "count_distinct"; views: MetricResultView[] } + | { computation: "ratio"; scale: number; views: MetricResultView[] } + | { computation: "distribution"; statistic: string; views: MetricResultView[] } + | { computation: "gauge"; method: string; views: MetricResultView[] } + | { computation: "derived"; views: MetricResultView[] } +``` + +Every metric result also includes: + +```text +metric_key +label +description +unit +format +direction +``` + +View values use `entity_id`, not person-specific fields. + +## Runtime Flow + +1. Resolve tenant from request context. +2. Validate entity, period, metric keys, view specs, and dimensions. +3. Load visible metric definitions from DB. +4. Convert DB rows into Rust discriminated unions. +5. Reject unsupported computations with `UNSUPPORTED_COMPUTATION`. +6. Compile one ClickHouse query per requested metric view. +7. Execute queries with bounded concurrency. +8. Shape rows into typed result views. +9. Enforce final response row cap. +10. Return metrics in request order. + +Execution rules: + +- `sum` no rows returns `0`. +- `ratio` missing or zero denominator returns `null`. +- Ungrouped timeseries are dense per requested entity and bucket. +- Dimensioned timeseries are dense per requested entity, observed dimension group, and bucket. +- Breakdown returns observed dimension groups only. +- Peer starts from the generic current cohort view so zero-activity peers can be included. +- Target entities missing cohort membership are omitted from peer values. +- Null values are excluded from peer percentiles and `n`. + +## Validation + +Request caps, checked before any per-request enumeration work: + +- at most 50 metrics per request. +- at most 1000 entity ids per request. +- at most 400 days per period. + +Entity id normalization is a property of the entity type: `person` ids are +emails and are trimmed and lowercased to match observation sources, which +emit lowercased emails; other entity types are trimmed only. + +Reject with a client error when: + +- entity type or ids are empty. +- a request cap is exceeded. +- period dates are invalid or reversed. +- metrics are empty. +- metric keys are empty, duplicated, unknown, disabled, or schema-error. +- a metric requests no views. +- a metric requests the same view twice. +- a requested dimension is empty, duplicated, or not declared for the metric. +- a breakdown has no dimensions. +- a peer view has no requested or default cohort key. +- projected or final result size exceeds the row cap. +- computation is typed but not executable. + +## Authorization + +v1 decision: any authenticated member of a tenant may query metric results +for any entity ids in that tenant. Peer views expose aggregates only (no peer +entity ids); period, timeseries, and breakdown views expose per-entity values. +Entity-level scoping (self, reports, role-based) is deferred to the real +authorization system; this endpoint must adopt it when it lands. + +Schema validation checks: + +- managed source refs map to backend source enums. +- source observation views expose required columns. +- generic cohort view exposes required columns. +- declared dimensions are present on every recent row of each observed input + measure; a covered-measure gap is a schema error. +- input measures without recent observations downgrade the definition to + `unchecked`, never `error`: filtered measures legitimately go quiet, and + absence of data is indistinguishable from an unemitted measure. +- probe failures never overwrite a previously established status. +- warehouse diagnostics stay server-side. + +## Adding a Metric + +Built-in metrics are authored by Insight developers through the registry and +the managed observation models. There are exactly three cases; pick the first +one that applies. + +### Case 1: metric over an existing measure + +The measure already appears in a managed observation source (check the +`measures` list of the source in `builtin.rs` and the emitting gold model). + +1. Add one `MetricSeed` to `BUILTIN_METRICS` in + `src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs`: + metric key (`namespace.metric_name`, lowercase snake case), label, + description, unit, format, direction, entity type, computation type, + input role mapping to the measure, allowed dimensions, peer cohort key. +2. Run `cargo test -p analytics-api` — the registry invariant tests validate + key shapes, input/measure references, and computation field combinations. + +The reconciler seeds the definition on the next deploy. No SQL, no migration, +no dbt change. + +### Case 2: new measure from an existing source + +The source exists but does not emit the measure yet. + +1. Add the measure branch to the source's gold model in `src/ingestion/gold/` + (one `UNION ALL` branch emitting the observation contract for the new + `measure_key`). Read only class-contract columns; never vendor-specific + ones — if the fact you need is not in the class contract, extend the class + contract first (staging models declare semantics, see the class + `schema.yml`). +2. Add the `measure_key` to the gold model's `schema.yml` `accepted_values` + test. +3. Add a `MeasureSeed` to the source in `builtin.rs`. +4. Add the `MetricSeed` as in case 1. +5. Validate: `dbt parse` (dummy profile) + `cargo test -p analytics-api`. + +### Case 3: new observation source + +The metric family reads data no managed source covers. + +1. Create a dbt gold model in `src/ingestion/gold/` emitting the source + measure observation contract, `schema=insight`, `ref()`-ing silver models. + Document columns and measure keys in `src/ingestion/gold/schema.yml`. +2. Add an `ObservationSource` enum variant and `from_ref`/`table_ref` mapping + in `src/backend/services/analytics-api/src/domain/metric_definitions/definition.rs`. +3. Add a `BuiltinSource` (source + measures + dimensions) to `builtin.rs`. +4. Add `MetricSeed`s as in case 1. +5. Validate: `dbt parse` + `cargo test -p analytics-api`. The runtime schema + validator probes the new relation at startup. + +### Rules that hold for every case + +- No metric-key-specific branches in runtime code. +- No vendor names, vendor columns, or label mappings in gold models — labels + and taxonomy come from class-contract columns declared by staging. +- No new `metric_catalog` seed migrations and no new ad-hoc `insight.*` views + for metrics. +- Do not add runtime formula JSON until generation exists. + +Future developer-side generation may use source models and formulas to produce +the managed observation SQL and seed rows, but runtime execution still +consumes typed definitions and source measure observations. + +## Custom Metric Gate + +Runtime-authored metrics require one of: + +- generated managed observation SQL plus generated definition/source seed rows. +- validated custom observation SQL that emits the source measure observation contract. + +Until one exists, custom definitions can be stored but cannot produce new source observations. The runtime only executes metrics whose inputs resolve to available, validated source measures. + +## Frontend Contract + +Frontend collection rendering: + +- requests metric keys and views. +- treats configured required views as required. +- normalizes response arrays only for local lookup. +- renders using returned label, unit, format, direction, and computation. +- owns chart choice and layout. + +Backend responses do not include chart metadata. + +## Non-Goals + +- No custom metric authoring UI in this pass. +- No custom SQL execution in this pass. +- No public source labels in metric results. +- No metric-key-specific branches in result compilation. +- No partial responses for oversized results. From f57ac21f4e211f2a7757e9f2c6efaf9aea778b77 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Sun, 5 Jul 2026 17:15:39 +0200 Subject: [PATCH 04/20] refactor(ingestion): shape macros for metric observation branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every measure branch in the gold observation models is a call to a shape macro — sum_measure for aggregated numerics (optional contract- dimension filter) and presence_measure for row-existence markers. The observation contract columns and the null-preserving aggregation idiom live in one place, and authoring a measure is a one-call branch. Macros map to computation shapes, not metrics; a new macro is added only when a new computation kind becomes executable. Filter predicates may reference only class-contract dimension values, per the authoring guide. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 19 +- .../macros/metric_observation_measures.sql | 38 ++++ src/ingestion/gold/ai_metric_observations.sql | 169 ++---------------- 3 files changed, 65 insertions(+), 161 deletions(-) create mode 100644 src/ingestion/dbt/macros/metric_observation_measures.sql diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index bcd28331f..c8cb7306f 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -350,12 +350,16 @@ no dbt change. The source exists but does not emit the measure yet. -1. Add the measure branch to the source's gold model in `src/ingestion/gold/` - (one `UNION ALL` branch emitting the observation contract for the new - `measure_key`). Read only class-contract columns; never vendor-specific - ones — if the fact you need is not in the class contract, extend the class - contract first (staging models declare semantics, see the class - `schema.yml`). +1. Add the measure branch to the source's gold model in `src/ingestion/gold/`: + one `UNION ALL` entry calling a shape macro from + `src/ingestion/dbt/macros/metric_observation_measures.sql` — + `sum_measure(measure_key, relation, value_expr, dimensions_col, + where=none)` for aggregated numerics, `presence_measure(measure_key, + relations)` for row-existence markers. Every branch is a shape-macro call; + a new macro is added only when a new computation kind becomes executable. + Read only class-contract columns; never vendor-specific ones — if the fact + you need is not in the class contract, extend the class contract first + (staging models declare semantics, see the class `schema.yml`). 2. Add the `measure_key` to the gold model's `schema.yml` `accepted_values` test. 3. Add a `MeasureSeed` to the source in `builtin.rs`. @@ -381,6 +385,9 @@ The metric family reads data no managed source covers. - No metric-key-specific branches in runtime code. - No vendor names, vendor columns, or label mappings in gold models — labels and taxonomy come from class-contract columns declared by staging. +- Measure filter predicates (`where=` on shape macros) may reference only + class-contract dimension columns and their normalized values — never vendor + columns, tool names, or label text. - No new `metric_catalog` seed migrations and no new ad-hoc `insight.*` views for metrics. - Do not add runtime formula JSON until generation exists. diff --git a/src/ingestion/dbt/macros/metric_observation_measures.sql b/src/ingestion/dbt/macros/metric_observation_measures.sql new file mode 100644 index 000000000..3da4c4129 --- /dev/null +++ b/src/ingestion/dbt/macros/metric_observation_measures.sql @@ -0,0 +1,38 @@ +{% macro sum_measure(measure_key, relation, value_expr, dimensions_col, where=none) %} + SELECT + tenant_id, + entity_id, + metric_date, + '{{ measure_key }}' AS measure_key, + if( + countIf(({{ value_expr }}) IS NOT NULL) > 0, + sumIf(toFloat64({{ value_expr }}), ({{ value_expr }}) IS NOT NULL), + CAST(NULL AS Nullable(Float64)) + ) AS value, + {{ dimensions_col }} AS dimensions + FROM {{ relation }} + {% if where %}WHERE {{ where }} + {% endif %}GROUP BY tenant_id, entity_id, metric_date, {{ dimensions_col }} +{% endmacro %} + +{% macro presence_measure(measure_key, relations) %} + SELECT + tenant_id, + entity_id, + metric_date, + '{{ measure_key }}' AS measure_key, + toNullable(toFloat64(1)) AS value, + CAST([] AS Array(Tuple(key String, value String, label Nullable(String)))) AS dimensions + FROM ( + {%- for relation in relations %} + SELECT DISTINCT + tenant_id, + entity_id, + metric_date + FROM {{ relation }} + {%- if not loop.last %} + UNION DISTINCT + {%- endif %} + {%- endfor %} + ) +{% endmacro %} diff --git a/src/ingestion/gold/ai_metric_observations.sql b/src/ingestion/gold/ai_metric_observations.sql index d0bfa56bb..9b60c15da 100644 --- a/src/ingestion/gold/ai_metric_observations.sql +++ b/src/ingestion/gold/ai_metric_observations.sql @@ -11,7 +11,9 @@ -- display labels come from tool_label / surface_label, and conversation -- semantics come from data presence (conversation_count is NULL for sources -- without a conversation concept). No vendor-specific columns, tool names, or --- label mappings may appear in this model. +-- label mappings may appear in this model. Every measure is emitted through +-- the shape macros in macros/metric_observation_measures.sql; filter +-- predicates may reference only class-contract dimension values. WITH ai_dev_usage_source AS ( @@ -84,191 +86,48 @@ ai_assistant_usage_dimensions AS ( ) AS tool_surface_dimensions FROM ai_assistant_usage_source ), -ai_active_day_source AS ( - SELECT DISTINCT - tenant_id, - entity_id, - metric_date - FROM ai_dev_usage_source - - UNION DISTINCT - - SELECT DISTINCT - tenant_id, - entity_id, - metric_date - FROM ai_assistant_usage_source -), measure_observations AS ( - SELECT - tenant_id, - entity_id, - metric_date, - 'accepted_lines' AS measure_key, - if( - countIf(lines_added IS NOT NULL) > 0, - sumIf(toFloat64(lines_added), lines_added IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_dimensions AS dimensions - FROM ai_dev_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + {{ sum_measure('accepted_lines', 'ai_dev_usage_dimensions', 'lines_added', 'tool_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'removed_lines' AS measure_key, - if( - countIf(lines_removed IS NOT NULL) > 0, - sumIf(toFloat64(lines_removed), lines_removed IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_dimensions AS dimensions - FROM ai_dev_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + {{ sum_measure('removed_lines', 'ai_dev_usage_dimensions', 'lines_removed', 'tool_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'active_day' AS measure_key, - toNullable(toFloat64(1)) AS value, - CAST([] AS Array(Tuple(key String, value String, label Nullable(String)))) AS dimensions - FROM ai_active_day_source + {{ presence_measure('active_day', ['ai_dev_usage_source', 'ai_assistant_usage_source']) }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'cost_usd' AS measure_key, - if( - countIf(cost_cents IS NOT NULL) > 0, - sumIf(toFloat64(cost_cents), cost_cents IS NOT NULL) / 100, - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_dimensions AS dimensions - FROM ai_dev_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + {{ sum_measure('cost_usd', 'ai_dev_usage_dimensions', 'cost_cents / 100', 'tool_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'cost_usd' AS measure_key, - if( - countIf(cost_cents IS NOT NULL) > 0, - sumIf(toFloat64(cost_cents), cost_cents IS NOT NULL) / 100, - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_dimensions AS dimensions - FROM ai_assistant_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + {{ sum_measure('cost_usd', 'ai_assistant_usage_dimensions', 'cost_cents / 100', 'tool_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'accepted_edit_actions' AS measure_key, - if( - countIf(tool_use_accepted IS NOT NULL) > 0, - sumIf(toFloat64(tool_use_accepted), tool_use_accepted IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_dimensions AS dimensions - FROM ai_dev_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + {{ sum_measure('accepted_edit_actions', 'ai_dev_usage_dimensions', 'tool_use_accepted', 'tool_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'tool_use_offered' AS measure_key, - if( - countIf(tool_use_offered IS NOT NULL) > 0, - sumIf(toFloat64(tool_use_offered), tool_use_offered IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_dimensions AS dimensions - FROM ai_dev_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + {{ sum_measure('tool_use_offered', 'ai_dev_usage_dimensions', 'tool_use_offered', 'tool_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'dev_conversations' AS measure_key, - if( - countIf(conversation_count IS NOT NULL) > 0, - sumIf(toFloat64(conversation_count), conversation_count IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_dimensions AS dimensions - FROM ai_dev_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_dimensions + {{ sum_measure('dev_conversations', 'ai_dev_usage_dimensions', 'conversation_count', 'tool_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'assistant_messages' AS measure_key, - if( - countIf(message_count IS NOT NULL) > 0, - sumIf(toFloat64(message_count), message_count IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_surface_dimensions AS dimensions - FROM ai_assistant_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_surface_dimensions + {{ sum_measure('assistant_messages', 'ai_assistant_usage_dimensions', 'message_count', 'tool_surface_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'assistant_actions' AS measure_key, - if( - countIf(action_count IS NOT NULL) > 0, - sumIf(toFloat64(action_count), action_count IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_surface_dimensions AS dimensions - FROM ai_assistant_usage_dimensions - GROUP BY tenant_id, entity_id, metric_date, tool_surface_dimensions + {{ sum_measure('assistant_actions', 'ai_assistant_usage_dimensions', 'action_count', 'tool_surface_dimensions') }} UNION ALL - SELECT - tenant_id, - entity_id, - metric_date, - 'chat_assistant_conversations' AS measure_key, - if( - countIf(conversation_count IS NOT NULL) > 0, - sumIf(toFloat64(conversation_count), conversation_count IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, - tool_surface_dimensions AS dimensions - FROM ai_assistant_usage_dimensions - WHERE surface_value = 'chat' - GROUP BY tenant_id, entity_id, metric_date, tool_surface_dimensions + {{ sum_measure('chat_assistant_conversations', 'ai_assistant_usage_dimensions', 'conversation_count', 'tool_surface_dimensions', where="surface_value = 'chat'") }} ) SELECT assumeNotNull(tenant_id) AS tenant_id, From 7ce547ceb05984cdf86752288d21c477df968552 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Mon, 6 Jul 2026 11:05:03 +0200 Subject: [PATCH 05/20] docs(metrics): align references with the analytics service name Path references, the cargo package name in the authoring guide, the config env-var prefix, and dbt artifact descriptions now use the analytics service naming. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/README.md | 10 +++++----- docs/domain/metrics/specs/DESIGN.md | 12 ++++++------ src/backend/services/analytics/src/config.rs | 2 +- .../gold/assert_metric_entity_cohorts_unique.sql | 2 +- src/ingestion/gold/schema.yml | 8 ++++---- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/domain/metrics/README.md b/docs/domain/metrics/README.md index cd7e4120d..acb294ed8 100644 --- a/docs/domain/metrics/README.md +++ b/docs/domain/metrics/README.md @@ -15,11 +15,11 @@ are authored through this system. | Layer | Location | |---|---| -| Metric registry (builtin seeds) | [`src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs`](../../../src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs) | -| Definition loading, reconciler, schema validator | [`src/backend/services/analytics-api/src/domain/metric_definitions/`](../../../src/backend/services/analytics-api/src/domain/metric_definitions/) | -| Result runtime (validation, query compiler, response builder) | [`src/backend/services/analytics-api/src/domain/metric_results/`](../../../src/backend/services/analytics-api/src/domain/metric_results/) | -| Result endpoint | [`src/backend/services/analytics-api/src/api/metric_results.rs`](../../../src/backend/services/analytics-api/src/api/metric_results.rs) | -| Registry schema migration | [`src/backend/services/analytics-api/src/migration/m20260625_000001_metric_definitions.rs`](../../../src/backend/services/analytics-api/src/migration/m20260625_000001_metric_definitions.rs) | +| Metric registry (builtin seeds) | [`src/backend/services/analytics/src/domain/metric_definitions/builtin.rs`](../../../src/backend/services/analytics/src/domain/metric_definitions/builtin.rs) | +| Definition loading, reconciler, schema validator | [`src/backend/services/analytics/src/domain/metric_definitions/`](../../../src/backend/services/analytics/src/domain/metric_definitions/) | +| Result runtime (validation, query compiler, response builder) | [`src/backend/services/analytics/src/domain/metric_results/`](../../../src/backend/services/analytics/src/domain/metric_results/) | +| Result endpoint | [`src/backend/services/analytics/src/api/metric_results.rs`](../../../src/backend/services/analytics/src/api/metric_results.rs) | +| Registry schema migration | [`src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs`](../../../src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs) | | Managed observation sources (dbt gold models) | [`src/ingestion/gold/`](../../../src/ingestion/gold/) | | Class-contract data-quality tests | [`src/ingestion/dbt/tests/ai/`](../../../src/ingestion/dbt/tests/ai/) | diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index c8cb7306f..4661b23f7 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -184,7 +184,7 @@ Rules: ## Builtin Seed Reconciliation Builtin definitions are declared in one code registry -(`src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs`) +(`src/backend/services/analytics/src/domain/metric_definitions/builtin.rs`) and converged into the DB by a startup reconciler, not by migrations. Migrations own schema only. @@ -336,11 +336,11 @@ The measure already appears in a managed observation source (check the `measures` list of the source in `builtin.rs` and the emitting gold model). 1. Add one `MetricSeed` to `BUILTIN_METRICS` in - `src/backend/services/analytics-api/src/domain/metric_definitions/builtin.rs`: + `src/backend/services/analytics/src/domain/metric_definitions/builtin.rs`: metric key (`namespace.metric_name`, lowercase snake case), label, description, unit, format, direction, entity type, computation type, input role mapping to the measure, allowed dimensions, peer cohort key. -2. Run `cargo test -p analytics-api` — the registry invariant tests validate +2. Run `cargo test -p analytics` — the registry invariant tests validate key shapes, input/measure references, and computation field combinations. The reconciler seeds the definition on the next deploy. No SQL, no migration, @@ -364,7 +364,7 @@ The source exists but does not emit the measure yet. test. 3. Add a `MeasureSeed` to the source in `builtin.rs`. 4. Add the `MetricSeed` as in case 1. -5. Validate: `dbt parse` (dummy profile) + `cargo test -p analytics-api`. +5. Validate: `dbt parse` (dummy profile) + `cargo test -p analytics`. ### Case 3: new observation source @@ -374,10 +374,10 @@ The metric family reads data no managed source covers. measure observation contract, `schema=insight`, `ref()`-ing silver models. Document columns and measure keys in `src/ingestion/gold/schema.yml`. 2. Add an `ObservationSource` enum variant and `from_ref`/`table_ref` mapping - in `src/backend/services/analytics-api/src/domain/metric_definitions/definition.rs`. + in `src/backend/services/analytics/src/domain/metric_definitions/definition.rs`. 3. Add a `BuiltinSource` (source + measures + dimensions) to `builtin.rs`. 4. Add `MetricSeed`s as in case 1. -5. Validate: `dbt parse` + `cargo test -p analytics-api`. The runtime schema +5. Validate: `dbt parse` + `cargo test -p analytics`. The runtime schema validator probes the new relation at startup. ### Rules that hold for every case diff --git a/src/backend/services/analytics/src/config.rs b/src/backend/services/analytics/src/config.rs index dc6cdcf24..4f892eb14 100644 --- a/src/backend/services/analytics/src/config.rs +++ b/src/backend/services/analytics/src/config.rs @@ -67,7 +67,7 @@ pub struct MetricResultsConfig { /// unless the install declares itself single-tenant via /// `metric_catalog.tenant_default_id`, and logs a warning when active. /// - /// Env: `ANALYTICS__metric_results__single_tenant_warehouse_id`. + /// Env: `APP__gears__analytics__config__metric_results__single_tenant_warehouse_id`. #[serde(default)] pub single_tenant_warehouse_id: Option, } diff --git a/src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql b/src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql index db2cdbe1d..9f2bf5a27 100644 --- a/src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql +++ b/src/ingestion/dbt/tests/gold/assert_metric_entity_cohorts_unique.sql @@ -1,5 +1,5 @@ -- Build-integrity check (untagged → error severity under `dbt build`). --- The analytics-api peer view joins insight.metric_entity_cohorts_current on +-- The analytics service peer view joins insight.metric_entity_cohorts_current on -- (tenant_id, entity_type, entity_id, cohort_key) and assumes exactly one row -- per key — duplicate rows fan out the join and corrupt peer percentiles. -- Any returned row is a violation of that contract. diff --git a/src/ingestion/gold/schema.yml b/src/ingestion/gold/schema.yml index 619c8871e..c6b43fc65 100644 --- a/src/ingestion/gold/schema.yml +++ b/src/ingestion/gold/schema.yml @@ -5,9 +5,9 @@ models: description: > Source measure observations for the unified metrics runtime. One row per (tenant, entity, day, measure, dimension tuple). Emits measures, not - metrics: the analytics-api metric registry binds input roles to + metrics: the analytics service metric registry binds input roles to measure keys and compiles result queries against this relation. The - column set is a published contract — the analytics-api schema validator + column set is a published contract — the analytics service schema validator probes it at runtime (`OBSERVATION_COLUMNS`); changing it requires a coordinated backend change. columns: @@ -77,8 +77,8 @@ models: Current cohort membership per entity for peer comparison. One row per (tenant, entity_type, entity_id, cohort_key) — uniqueness is asserted by tests/gold/assert_metric_entity_cohorts_unique.sql and relied on by the - analytics-api peer view (join fan-out would corrupt percentiles). The - column set is a published contract probed by the analytics-api schema + analytics service peer view (join fan-out would corrupt percentiles). The + column set is a published contract probed by the analytics service schema validator (`COHORT_COLUMNS`). columns: - name: tenant_id From 69fc0e373dd0ee18123efa391fa9a70a0fc282dc Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Mon, 6 Jul 2026 11:14:40 +0200 Subject: [PATCH 06/20] =?UTF-8?q?docs(metrics):=20concepts=20section=20?= =?UTF-8?q?=E2=80=94=20observations,=20definitions,=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/README.md | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/domain/metrics/README.md b/docs/domain/metrics/README.md index acb294ed8..5d9250234 100644 --- a/docs/domain/metrics/README.md +++ b/docs/domain/metrics/README.md @@ -5,6 +5,51 @@ computed by one generic runtime over normalized source measure observations, and served self-describing through `POST /v1/metric-results`. All new metrics are authored through this system. +## Concepts + +Three layers, one handshake in the middle. + +An **observation** is a recorded fact: "alice, June 3rd, Claude Code, +`accepted_edit_actions` = 17". It says what happened, to whom, when, and how +much — and deliberately nothing about what it means. No name a user would +see, no formula, no chart. The data side (dbt gold models over silver +classes) produces millions of these in one fixed row shape, and its only job +is to record them honestly. + +A **definition** is a catalog card holding meaning: "there is a metric +`ai.tool_acceptance_rate`; compute it as `accepted_edit_actions` divided by +`tool_use_offered`, times 100; show as percent; higher is better; may be +split by tool; compare within org unit". No data lives here — only meaning +and instructions, stored in the registry and authored as one Rust struct per +metric. + +A **metric result** is the computed answer the user sees. It is not stored +anywhere: at request time the runtime applies a definition to the matching +observations — "alice, January: 312 ÷ 405 = 77%" — and returns it labeled and +ready to render. + +The split exists because one fact serves many meanings and one meaning serves +many questions. The same `accepted_edit_actions` observation is the whole +value of `ai.accepted_edit_actions` and the numerator of +`ai.tool_acceptance_rate` — recorded once, interpreted twice. The same +definition answers any period, person, team, dimension split, or peer +comparison without new code. Each side changes without touching the other: +renaming a metric edits a card; a vendor API change fixes fact recording +while every card keeps working. + +The handshake is the source measure observation contract (see +[`specs/DESIGN.md`](specs/DESIGN.md)): the data side promises to emit facts +in that shape, definitions reference facts only by measure key, and the +runtime can therefore connect any definition to any matching facts without +either side knowing the other exists. + +| | Observation | Definition | Metric result | +|---|---|---|---| +| What | a fact | the meaning of facts | the computed answer | +| Lives | ClickHouse rows | registry (MariaDB, seeded from Rust) | nowhere — made per request | +| Knows | what happened | what it is called, how to compute, how to show | both, combined | +| Authored by | connector + gold model | one struct per metric | nobody — the runtime derives it | + ## Documents | Document | Description | From 265e48390a397acd8e8f6fed36cd04b499060ce0 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Mon, 6 Jul 2026 14:00:21 +0200 Subject: [PATCH 07/20] feat(analytics): explanation field on metric definitions Metric definitions carry two text tiers: description (short qualifier) and explanation (full meaning and scope, shown in info surfaces). Both ride the metric result response. Builtin AI metrics get explanations covering what counts and which tool families are in scope. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 5 ++++- .../src/domain/metric_definitions/builtin.rs | 21 +++++++++++++++++++ .../domain/metric_definitions/definition.rs | 1 + .../domain/metric_definitions/repository.rs | 4 ++++ .../src/domain/metric_definitions/seeds.rs | 6 ++++-- .../src/domain/metric_results/builder.rs | 8 +++++++ .../src/domain/metric_results/compiler.rs | 1 + .../src/domain/metric_results/dto.rs | 14 +++++++++++++ .../src/domain/metric_results/validation.rs | 1 + .../m20260625_000001_metric_definitions.rs | 1 + 10 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index 4661b23f7..c69fd060a 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -141,6 +141,7 @@ metric_definition_dimensions metric_key label description +explanation unit format direction @@ -245,6 +246,7 @@ Every metric result also includes: metric_key label description +explanation unit format direction @@ -412,7 +414,8 @@ Frontend collection rendering: - requests metric keys and views. - treats configured required views as required. - normalizes response arrays only for local lookup. -- renders using returned label, unit, format, direction, and computation. +- renders using returned label, description, explanation, unit, format, + direction, and computation. - owns chart choice and layout. Backend responses do not include chart metadata. diff --git a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs index fc50bc3c9..5d5bc3835 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs @@ -25,6 +25,7 @@ pub struct MetricSeed { pub source_key: &'static str, pub label: &'static str, pub description: Option<&'static str>, + pub explanation: Option<&'static str>, pub unit: Option<&'static str>, pub format: &'static str, pub direction: &'static str, @@ -109,6 +110,7 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI-added lines", description: Some("Accepted added coding output"), + explanation: Some("Accepted AI-generated added lines across coding AI tools."), unit: Some("lines"), format: "integer", direction: "higher_is_better", @@ -129,6 +131,7 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI-removed lines", description: Some("Accepted deleted coding output"), + explanation: Some("Accepted AI-generated removed lines across coding AI tools."), unit: Some("lines"), format: "integer", direction: "higher_is_better", @@ -149,6 +152,9 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI active days", description: Some("Days with any AI activity across dev and assistant tools"), + explanation: Some( + "Distinct days with person-attributed AI activity across dev and assistant tools.", + ), unit: Some("days"), format: "integer", direction: "higher_is_better", @@ -169,6 +175,9 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI cost", description: Some("Reported AI spend across dev and assistant tools"), + explanation: Some( + "Person-attributed AI spend across dev and assistant tools, where the connector reports cost.", + ), unit: None, format: "currency", direction: "lower_is_better", @@ -189,6 +198,7 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "Accepted AI edits", description: Some("Accepted tool or edit suggestions"), + explanation: Some("Accepted AI edit or tool suggestions across supported coding AI tools."), unit: Some("actions"), format: "integer", direction: "higher_is_better", @@ -209,6 +219,7 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI tool acceptance", description: Some("Accepted divided by offered AI edits"), + explanation: Some("Accepted AI edit or tool suggestions divided by offered suggestions."), unit: Some("percent"), format: "percent", direction: "higher_is_better", @@ -235,6 +246,9 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI assistant messages", description: Some("Assistant messages"), + explanation: Some( + "Person-attributed assistant messages from supported AI assistant tools.", + ), unit: Some("messages"), format: "integer", direction: "higher_is_better", @@ -255,6 +269,7 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI assistant actions", description: Some("Assistant actions"), + explanation: Some("Person-attributed assistant actions from supported AI assistant tools."), unit: Some("actions"), format: "integer", direction: "higher_is_better", @@ -275,6 +290,9 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI dev conversations", description: Some("Coding tool conversations where the source reports them"), + explanation: Some( + "Person-attributed coding conversations from dev tools that report them.", + ), unit: Some("conversations"), format: "integer", direction: "higher_is_better", @@ -295,6 +313,9 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ source_key: "ai_usage", label: "AI chat conversations", description: Some("Chat assistant conversations"), + explanation: Some( + "Person-attributed chat assistant conversations from supported AI chat tools.", + ), unit: Some("conversations"), format: "integer", direction: "higher_is_better", diff --git a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs index 4ac0dc018..82e1b0d3c 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs @@ -87,6 +87,7 @@ pub struct MetricBase { pub key: String, pub label: String, pub description: Option, + pub explanation: Option, pub entity_type: String, pub format: MetricFormat, pub unit: Option, diff --git a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs index 6428b31b0..dc37174ad 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs @@ -19,6 +19,7 @@ struct DefinitionRow { metric_key: String, label: String, description: Option, + explanation: Option, unit: Option, format: String, direction: String, @@ -188,6 +189,7 @@ async fn fetch_definition_rows( d.metric_key AS metric_key, \ d.label AS label, \ d.description AS description, \ + d.explanation AS explanation, \ d.unit AS unit, \ d.format AS format, \ d.direction AS direction, \ @@ -447,6 +449,7 @@ fn build_base( key: row.metric_key.clone(), label: row.label.clone(), description: row.description.clone(), + explanation: row.explanation.clone(), entity_type: row.entity_type.clone(), format, unit: row.unit.clone(), @@ -719,6 +722,7 @@ mod tests { metric_key: metric_key.to_owned(), label: "Label".to_owned(), description: None, + explanation: None, unit: None, format: "integer".to_owned(), direction: "neutral".to_owned(), diff --git a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs index df631ab50..aaffc6352 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs @@ -100,13 +100,14 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( db.execute(Statement::from_sql_and_values( db.get_database_backend(), "INSERT INTO metric_definitions \ - (id, tenant_id, metric_key, label, description, unit, format, direction, entity_type, \ + (id, tenant_id, metric_key, label, description, explanation, unit, format, direction, entity_type, \ computation_type, scale, distribution_statistic, gauge_method, peer_cohort_key, \ origin, definition_version, is_enabled) \ - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'builtin', 1, TRUE) \ + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'builtin', 1, TRUE) \ ON DUPLICATE KEY UPDATE \ label = VALUES(label), \ description = VALUES(description), \ + explanation = VALUES(explanation), \ unit = VALUES(unit), \ format = VALUES(format), \ direction = VALUES(direction), \ @@ -124,6 +125,7 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( Value::from(metric.metric_key), Value::from(metric.label), nullable_str(metric.description), + nullable_str(metric.explanation), nullable_str(metric.unit), Value::from(metric.format), Value::from(metric.direction), diff --git a/src/backend/services/analytics/src/domain/metric_results/builder.rs b/src/backend/services/analytics/src/domain/metric_results/builder.rs index 76edd2375..56d87412f 100644 --- a/src/backend/services/analytics/src/domain/metric_results/builder.rs +++ b/src/backend/services/analytics/src/domain/metric_results/builder.rs @@ -157,6 +157,7 @@ pub fn build_metric_result( metric_key: sum.base.key.clone(), label: sum.base.label.clone(), description: sum.base.description.clone(), + explanation: sum.base.explanation.clone(), unit: sum.base.unit.clone(), format: sum.base.format, direction: sum.base.direction, @@ -166,6 +167,7 @@ pub fn build_metric_result( metric_key: count.base.key.clone(), label: count.base.label.clone(), description: count.base.description.clone(), + explanation: count.base.explanation.clone(), unit: count.base.unit.clone(), format: count.base.format, direction: count.base.direction, @@ -175,6 +177,7 @@ pub fn build_metric_result( metric_key: count.base.key.clone(), label: count.base.label.clone(), description: count.base.description.clone(), + explanation: count.base.explanation.clone(), unit: count.base.unit.clone(), format: count.base.format, direction: count.base.direction, @@ -184,6 +187,7 @@ pub fn build_metric_result( metric_key: ratio.base.key.clone(), label: ratio.base.label.clone(), description: ratio.base.description.clone(), + explanation: ratio.base.explanation.clone(), unit: ratio.base.unit.clone(), format: ratio.base.format, direction: ratio.base.direction, @@ -194,6 +198,7 @@ pub fn build_metric_result( metric_key: distribution.base.key.clone(), label: distribution.base.label.clone(), description: distribution.base.description.clone(), + explanation: distribution.base.explanation.clone(), unit: distribution.base.unit.clone(), format: distribution.base.format, direction: distribution.base.direction, @@ -204,6 +209,7 @@ pub fn build_metric_result( metric_key: gauge.base.key.clone(), label: gauge.base.label.clone(), description: gauge.base.description.clone(), + explanation: gauge.base.explanation.clone(), unit: gauge.base.unit.clone(), format: gauge.base.format, direction: gauge.base.direction, @@ -214,6 +220,7 @@ pub fn build_metric_result( metric_key: derived.base.key.clone(), label: derived.base.label.clone(), description: derived.base.description.clone(), + explanation: derived.base.explanation.clone(), unit: derived.base.unit.clone(), format: derived.base.format, direction: derived.base.direction, @@ -313,6 +320,7 @@ mod tests { key: "ai.accepted_lines".to_owned(), label: "AI-added lines".to_owned(), description: None, + explanation: None, entity_type: "person".to_owned(), format: MetricFormat::Integer, unit: None, diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs index 32475fc2a..0d15d9ad8 100644 --- a/src/backend/services/analytics/src/domain/metric_results/compiler.rs +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -475,6 +475,7 @@ mod tests { key: "ai.accepted_lines".to_owned(), label: "AI-added lines".to_owned(), description: None, + explanation: None, entity_type: "person".to_owned(), format: MetricFormat::Integer, unit: None, diff --git a/src/backend/services/analytics/src/domain/metric_results/dto.rs b/src/backend/services/analytics/src/domain/metric_results/dto.rs index 1bc0dec29..dd40413ca 100644 --- a/src/backend/services/analytics/src/domain/metric_results/dto.rs +++ b/src/backend/services/analytics/src/domain/metric_results/dto.rs @@ -71,6 +71,8 @@ pub enum MetricResultDto { label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, unit: Option, format: MetricFormat, direction: MetricDirection, @@ -81,6 +83,8 @@ pub enum MetricResultDto { label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, unit: Option, format: MetricFormat, direction: MetricDirection, @@ -91,6 +95,8 @@ pub enum MetricResultDto { label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, unit: Option, format: MetricFormat, direction: MetricDirection, @@ -101,6 +107,8 @@ pub enum MetricResultDto { label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, unit: Option, format: MetricFormat, direction: MetricDirection, @@ -112,6 +120,8 @@ pub enum MetricResultDto { label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, unit: Option, format: MetricFormat, direction: MetricDirection, @@ -123,6 +133,8 @@ pub enum MetricResultDto { label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, unit: Option, format: MetricFormat, direction: MetricDirection, @@ -134,6 +146,8 @@ pub enum MetricResultDto { label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, unit: Option, format: MetricFormat, direction: MetricDirection, diff --git a/src/backend/services/analytics/src/domain/metric_results/validation.rs b/src/backend/services/analytics/src/domain/metric_results/validation.rs index e31f2a299..2559f5baf 100644 --- a/src/backend/services/analytics/src/domain/metric_results/validation.rs +++ b/src/backend/services/analytics/src/domain/metric_results/validation.rs @@ -441,6 +441,7 @@ mod tests { key: "ai.accepted_lines".to_owned(), label: "AI-added lines".to_owned(), description: None, + explanation: None, entity_type: "person".to_owned(), format: MetricFormat::Integer, unit: None, diff --git a/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs index 48bd594c0..901e81508 100644 --- a/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs +++ b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs @@ -107,6 +107,7 @@ const SCHEMA_STATEMENTS: &[&str] = &[ metric_key VARCHAR(128) NOT NULL, label VARCHAR(128) NOT NULL, description VARCHAR(2048) NULL, + explanation VARCHAR(4096) NULL, unit VARCHAR(32) NULL, format ENUM('integer','decimal','currency','percent') NOT NULL, direction ENUM('higher_is_better','lower_is_better','neutral') NOT NULL, From 8b814346c9e76d317fb4b7b3b417ba18289e0799 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Mon, 6 Jul 2026 14:07:08 +0200 Subject: [PATCH 08/20] refactor(analytics): typed metric seed registry Seed vocabulary fields use the domain enums instead of strings: format, direction, and input roles reuse the definition-side types with paired as_db accessors; source kind, measure value type, entity type, and cohort key get authoring enums; the computation and its parameters collapse into one data-carrying SeedComputation so invalid combinations (ratio without scale) cannot be expressed. The DB-check-mirroring test is replaced by the type system; round-trip tests pin the as_db/from_db pairs. Regenerates the analytics OpenAPI document for the /v1/metric-results route and the explanation response field. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- .../components/backend/analytics/openapi.json | 4371 +++++++++-------- .../src/domain/metric_definitions/builtin.rs | 334 +- .../domain/metric_definitions/definition.rs | 76 + .../src/domain/metric_definitions/seeds.rs | 26 +- 4 files changed, 2494 insertions(+), 2313 deletions(-) diff --git a/docs/components/backend/analytics/openapi.json b/docs/components/backend/analytics/openapi.json index 58a0ee136..c55fe2666 100644 --- a/docs/components/backend/analytics/openapi.json +++ b/docs/components/backend/analytics/openapi.json @@ -1,3016 +1,3109 @@ { - "components": { - "schemas": { - "AdminMetricThresholdView": { - "description": "On-wire shape of one `metric_threshold` row in list / get responses.\n\n`metric_key` is NOT serialized — same backend-internal opacity rule the\nread endpoint follows (`domain/catalog/response.rs::MetricView`).\nConsumers identify a metric by `metric_id`.\n\nThe OpenAPI component is named `AdminMetricThresholdView` (via\n`#[schema(as)]`) to disambiguate from the catalog read path's\n`ThresholdView` (`domain::catalog::response::ThresholdView`, registered as\n`CatalogThresholdView`), which is a different wire shape. `#[schema(as)]`\nrenames only the OpenAPI component — it does NOT affect serde / the wire\nformat.", - "properties": { - "alert_bad": { - "format": "double", - "type": [ - "number", - "null" - ] + "openapi": "3.1.0", + "info": { + "title": "Analytics API", + "description": "Read-only query service over predefined ClickHouse metrics. Admins define metrics (named SQL queries) in MariaDB; the frontend queries them by UUID with OData-style filtering. The API Gateway mounts this service at /api/analytics.", + "version": "1.0.0" + }, + "paths": { + "/v1/admin/metric-thresholds": { + "get": { + "summary": "List admin metric thresholds", + "operationId": "analytics_api.admin.thresholds.list", + "responses": { + "200": { + "description": "List of metric thresholds", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } }, - "alert_trigger": { - "format": "double", - "type": [ - "number", - "null" - ] + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "good": { - "format": "double", - "type": "number" + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "id": { - "format": "uuid", - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "is_locked": { - "type": "boolean" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "lock_reason": { - "type": [ - "string", - "null" - ] + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "locked_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "locked_by": { - "type": [ - "string", - "null" - ] + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "summary": "Create an admin metric threshold", + "operationId": "analytics_api.admin.thresholds.create", + "requestBody": { + "description": "Metric threshold to create", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRequest" + } + } }, - "metric_id": { - "description": "UUIDv7 of the corresponding `metric_catalog` row.", - "format": "uuid", - "type": "string" + "required": true + }, + "responses": { + "201": { + "description": "Created metric threshold", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMetricThresholdView" + } + } + } }, - "role_slug": { - "description": "Empty-string sentinel collapsed to `None` on the wire so the JSON\nshape is `null` instead of `\"\"` (the latter would confuse FE\n\"is this set?\" predicates).", - "type": [ - "string", - "null" - ] + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "schema_error_code": { - "description": "Canonical error code (`table_not_found | column_not_found |\nclickhouse_unreachable | unknown`) when `schema_status = \"error\"`,\notherwise omitted.", - "type": [ - "string", - "null" - ] + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "schema_status": { - "description": "One of `ok | error | unchecked`, joined from `metric_catalog.schema_status`\n(DESIGN §3.3 \"Schema status surface\"). Lets the admin UI flag a\nbroken metric before the operator submits a write.", - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "scope": { - "$ref": "#/components/schemas/Scope" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "team_id": { - "type": [ - "string", - "null" - ] + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "tenant_id": { - "description": "`Some(_)` for tenant-scoped rows, `None` for `product-default`.", - "format": "uuid", - "type": [ - "string", - "null" - ] + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "warn": { - "format": "double", - "type": "number" + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "id", - "metric_id", - "scope", - "good", - "warn", - "is_locked", - "schema_status" - ], - "type": "object" - }, - "BatchQueryItem": { - "allOf": [ - { - "$ref": "#/components/schemas/QueryRequest" - }, + "security": [ { - "properties": { - "id": { - "type": [ - "string", - "null" - ] - }, - "metric_id": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "metric_id" - ], - "type": "object" + "bearerAuth": [] } ] - }, - "BatchQueryRequest": { - "properties": { - "queries": { - "items": { - "$ref": "#/components/schemas/BatchQueryItem" - }, - "type": "array" - } - }, - "required": [ - "queries" - ], - "type": "object" - }, - "BatchQueryResponse": { - "properties": { - "results": { - "items": { - "$ref": "#/components/schemas/BatchQueryResult" - }, - "type": "array" - } - }, - "required": [ - "results" - ], - "type": "object" - }, - "BatchQueryResult": { - "oneOf": [ - { - "allOf": [ - { - "$ref": "#/components/schemas/QueryResponse" - }, - { - "properties": { - "id": { - "type": [ - "string", - "null" - ] - }, - "metric_id": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "metric_id" - ], - "type": "object" - }, - { - "properties": { - "status": { - "enum": [ - "ok" - ], - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" + } + }, + "/v1/admin/metric-thresholds/{id}": { + "get": { + "summary": "Get an admin metric threshold by id", + "operationId": "analytics_api.admin.thresholds.get", + "responses": { + "200": { + "description": "Metric threshold", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMetricThresholdView" + } } - ] + } }, - { - "properties": { - "error": { - "$ref": "#/components/schemas/Problem" - }, - "id": { - "type": [ - "string", - "null" - ] - }, - "metric_id": { - "format": "uuid", - "type": "string" - }, - "status": { - "enum": [ - "error" - ], - "type": "string" + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } } - }, - "required": [ - "metric_id", - "error", - "status" - ], - "type": "object" - } - ] - }, - "CatalogResponse": { - "description": "Top-level response body. `tenant_id` is echoed for client-side cache\nreasoning AND re-asserted on cache hydrate as defense in depth against a\nmisconfigured cache backend serving a sibling tenant's payload.\n\n`links` carries the `metric_query_catalog` M:N mapping per ADR-003. The\nmapping is time/filter-invariant, so consumers cache it for the same TTL as\nthe catalog itself; see [`MetricQueryLink`].", - "properties": { - "generated_at": { - "format": "date-time", - "type": "string" - }, - "links": { - "items": { - "$ref": "#/components/schemas/MetricQueryLink" - }, - "type": "array" - }, - "metrics": { - "items": { - "$ref": "#/components/schemas/MetricView" - }, - "type": "array" - }, - "tenant_id": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "tenant_id", - "generated_at", - "metrics", - "links" - ], - "type": "object" - }, - "CatalogThresholdView": { - "description": "Resolved threshold for one metric.\n\n`good` / `warn` are `f64` on the wire — DECIMAL(20,6) in the DB rounds-trips\nthrough DOUBLE for every seed value (integers and one-decimal floats). If\nfuture seed entries need full-precision decimals, this is the place to switch\nto a string serializer; the FE byte-for-byte comparison gate (PRD §12) is\nthe regression detector.\n\nThe OpenAPI component is named `CatalogThresholdView` (via `#[schema(as)]`)\nto disambiguate from the admin-CRUD `ThresholdView`\n(`domain::admin_threshold::dto::ThresholdView`), which is a different wire\nshape registered under `AdminMetricThresholdView`. `#[schema(as)]` renames\nonly the OpenAPI component — it does NOT affect serde / the wire format.", - "properties": { - "alert_bad": { - "format": "double", - "type": [ - "number", - "null" - ] - }, - "alert_trigger": { - "format": "double", - "type": [ - "number", - "null" - ] - }, - "bounded_by_lock": { - "description": "`true` iff the walk halted on a locked broader-scope row before reaching\nthe most-specific candidate. Separate signal from `resolved_from`, which\nalways names the row that won.", - "type": "boolean" - }, - "good": { - "format": "double", - "type": "number" - }, - "resolved_from": { - "description": "One of `\"team+role\" | \"team\" | \"role\" | \"tenant\" | \"product-default\"`.\nNames the row that won the walk.", - "type": "string" - }, - "warn": { - "format": "double", - "type": "number" - } - }, - "required": [ - "good", - "warn", - "resolved_from", - "bounded_by_lock" - ], - "type": "object" - }, - "ColumnListResponse": { - "description": "Response envelope for `GET /v1/columns` and `GET /v1/columns/{table}`\n(`{ \"items\": [TableColumn] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the\nhandlers emit — gives the column-list endpoints a real OpenAPI schema.", - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/TableColumn" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" - }, - "CreateMetricRequest": { - "description": "Request to create a new metric.", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "query_ref": { - "type": "string" - } - }, - "required": [ - "name", - "query_ref" - ], - "type": "object" - }, - "CreateRequest": { - "additionalProperties": false, - "description": "`POST /v1/admin/metric-thresholds` body — create a new threshold row.\n\n`tenant_id` / `id` / `locked_by` / `locked_at` / `created_at` /\n`updated_at` are NOT accepted from the body. `deny_unknown_fields`\nenforces that at the serde layer.\n\n`role_slug` / `team_id` use `Option` — `None` is the canonical\nempty-string sentinel (DESIGN §3.7 + `infra/cache/catalog_cache.rs::cache_field`).", - "properties": { - "alert_bad": { - "format": "double", - "type": [ - "number", - "null" - ] - }, - "alert_trigger": { - "format": "double", - "type": [ - "number", - "null" - ] - }, - "good": { - "format": "double", - "type": "number" - }, - "is_locked": { - "type": "boolean" + } }, - "lock_reason": { - "type": [ - "string", - "null" - ] + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "metric_id": { - "format": "uuid", - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "role_slug": { - "type": [ - "string", - "null" - ] + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "scope": { - "$ref": "#/components/schemas/Scope" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "team_id": { - "type": [ - "string", - "null" - ] + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "warn": { - "format": "double", - "type": "number" + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "metric_id", - "scope", - "good", - "warn" - ], - "type": "object" - }, - "CreateThresholdRequest": { - "description": "Request to create a threshold.", - "properties": { - "field_name": { - "type": "string" - }, - "level": { - "description": "Result level: `good`, `warning`, `critical`.", - "type": "string" - }, - "operator": { - "description": "Comparison operator: `gt`, `ge`, `lt`, `le`, `eq`.", - "type": "string" - }, - "value": { - "format": "double", - "type": "number" + "security": [ + { + "bearerAuth": [] } - }, - "required": [ - "field_name", - "operator", - "value", - "level" - ], - "type": "object" + ] }, - "GetMetricsRequest": { - "additionalProperties": false, - "description": "Request body for `POST /v1/catalog/get_metrics`.\n\n`tenant_id` is intentionally NOT accepted here — it is resolved server-side\nfrom the session by `tenant_middleware` (Refs #522 auth-trait). Allowing a\nbody-supplied `tenant_id` would open a cross-tenant disclosure surface.\n`deny_unknown_fields` enforces that defensively at the parser layer: a\ncaller that smuggles `\"tenant_id\": \"...\"` into the body gets a 400 instead\nof a silent ignore.", - "properties": { - "role_slug": { - "description": "Role slug for `role` / `team+role` resolution chains. `None` and `Some(\"\")`\nare semantically identical and produce the same cache key (canonical\nempty-string sentinel — see `cache_key` in the cache layer).", - "type": [ - "string", - "null" - ] + "put": { + "summary": "Update an admin metric threshold", + "operationId": "analytics_api.admin.thresholds.update", + "requestBody": { + "description": "Metric threshold fields to update", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRequest" + } + } }, - "team_id": { - "description": "Team id for `team` / `team+role` resolution chains. Same `None` vs `Some(\"\")`\nequivalence as `role_slug`.", - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ListResponse": { - "description": "`GET /v1/admin/metric-thresholds` response envelope.\n\nWraps `items` in an object (instead of a bare array) so future\nadditions (pagination cursor, count, generated-at) are additive and\nnon-breaking. Mirrors the catalog read endpoint's envelope shape.", - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/AdminMetricThresholdView" - }, - "type": "array" - } + "required": true }, - "required": [ - "items" - ], - "type": "object" - }, - "Metric": { - "description": "A metric definition — an admin-configured SQL query against `ClickHouse`.\n\nThe `query_ref` field holds raw `ClickHouse` SQL. The query engine wraps it\nas a subquery, appending security filters + `OData` filters as parameterized\nWHERE clauses.", - "properties": { - "created_at": { - "format": "date-time", - "type": "string" + "responses": { + "200": { + "description": "Updated metric threshold", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMetricThresholdView" + } + } + } }, - "description": { - "type": [ - "string", - "null" - ] + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "id": { - "format": "uuid", - "type": "string" + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "insight_tenant_id": { - "format": "uuid", - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "is_enabled": { - "type": "boolean" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "name": { - "type": "string" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "query_ref": { - "type": "string" + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "updated_at": { - "format": "date-time", - "type": "string" + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "id", - "insight_tenant_id", - "name", - "query_ref", - "is_enabled", - "created_at", - "updated_at" - ], - "type": "object" - }, - "MetricListResponse": { - "description": "Response envelope for `GET /v1/metrics` (`{ \"items\": [MetricSummary] }`).\n\nDocs-only wrapper: the handler emits the same object shape via an inline\n`serde_json::json!` literal. Existing on the wire; this type just gives the\nlist endpoint a real OpenAPI schema instead of a generic object.", - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/MetricSummary" - }, - "type": "array" + "security": [ + { + "bearerAuth": [] } - }, - "required": [ - "items" - ], - "type": "object" + ] }, - "MetricQueryLink": { - "description": "One link row from `metric_query_catalog`. Tells a consumer which catalog\nrows a `metrics.query_ref` emits when executed — the M:N answer ADR-001\nadded at the DB layer, surfaced here so consumers don't have to derive it\nby joining on backend-internal `metric_key` strings.\n\n`catalog_metric_ids` is the set of `metric_catalog.id` UUIDs the query\nproduces. The set is empty only when the linked catalog rows are all\n`is_enabled = false` (filtered out of the `metrics` array) — consumers\ndegrade gracefully on empty.", - "properties": { - "catalog_metric_ids": { - "description": "`metric_catalog.id` UUIDs this query emits. Sorted ascending so the\nwire payload is byte-stable for cache + diff tooling.", - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" + "delete": { + "summary": "Delete an admin metric threshold", + "operationId": "analytics_api.admin.thresholds.delete", + "responses": { + "204": { + "description": "Metric threshold deleted" }, - "query_id": { - "description": "`metrics.id` — the ClickHouse `query_ref` row this link is FROM.", - "format": "uuid", - "type": "string" - } - }, - "required": [ - "query_id", - "catalog_metric_ids" - ], - "type": "object" - }, - "MetricSummary": { - "description": "Summary returned in list endpoints (no `query_ref`).", - "properties": { - "description": { - "type": [ - "string", - "null" - ] + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "id": { - "format": "uuid", - "type": "string" + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ], - "type": "object" - }, - "MetricView": { - "description": "One catalog metric on the wire. `metric_key` is surfaced per ADR-002 as the\ntransitional FE-bridge identifier; consumers MUST still key lookups by `id`.", - "properties": { - "description": { - "type": [ - "string", - "null" - ] + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "format": { - "type": [ - "string", - "null" - ] + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "higher_is_better": { - "type": "boolean" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "id": { - "format": "uuid", - "type": "string" + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "is_member_scale": { - "type": "boolean" + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/v1/catalog/get_metrics": { + "post": { + "summary": "Read the metric catalog for the request context", + "operationId": "analytics_api.catalog.get_metrics", + "requestBody": { + "description": "Catalog read request context (role, team)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMetricsRequest" + } + } }, - "label": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "description": "Resolved metric catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogResponse" + } + } + } }, - "metric_key": { - "description": "Backend's `.` identifier. Surfaced per ADR-002\nso the FE can align compiled-in `BULLET_DEFS` constants to wire rows\nduring the catalog-hydration transitional release; the stable lookup\nkey remains `id`.", - "type": "string" + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "schema_error_code": { - "description": "Canonical code from `{ table_not_found, column_not_found,\nclickhouse_unreachable, unknown }`, only present when `schema_status = \"error\"`.\nRaw ClickHouse error text NEVER reaches consumers per DESIGN §3.3.", - "type": [ - "string", - "null" - ] + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "schema_status": { - "description": "`\"ok\" | \"error\" | \"unchecked\"` — sourced from `metric_catalog.schema_status`.\nConsumers render `\"unchecked\"` the same as `\"ok\"` (validator hasn't run\nyet); only `\"error\"` triggers the broken-metric indicator.", - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "source_tags": { - "items": { - "type": "string" - }, - "type": "array" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "sublabel": { - "type": [ - "string", - "null" - ] + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "thresholds": { - "$ref": "#/components/schemas/CatalogThresholdView" + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "unit": { - "type": [ - "string", - "null" - ] + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "id", - "metric_key", - "label", - "higher_is_better", - "is_member_scale", - "source_tags", - "schema_status", - "thresholds" - ], - "type": "object" - }, - "PageInfo": { - "description": "Pagination info.", - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "has_next": { - "type": "boolean" + "security": [ + { + "bearerAuth": [] } - }, - "required": [ - "has_next" - ], - "type": "object" - }, - "Person": { - "description": "Person info returned by the Identity service.", - "properties": { - "department": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "division": { - "type": "string" - }, - "email": { - "type": "string" + ] + } + }, + "/v1/columns": { + "get": { + "summary": "List queryable columns", + "operationId": "analytics_api.columns.list", + "responses": { + "200": { + "description": "List of columns", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnListResponse" + } + } + } }, - "first_name": { - "type": "string" + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "job_title": { - "type": "string" + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "last_name": { - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "status": { - "type": "string" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "subordinates": { - "items": { - "$ref": "#/components/schemas/Subordinate" - }, - "type": "array" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "supervisor_email": { - "type": [ - "string", - "null" - ] + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "supervisor_name": { - "type": [ - "string", - "null" - ] + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "email", - "display_name", - "first_name", - "last_name", - "department", - "division", - "job_title", - "status", - "subordinates" - ], - "type": "object" - }, - "Problem": { - "description": "RFC 9457 problem+json. `context` varies by error category.", - "properties": { - "context": { - "type": "object" + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/v1/columns/{table}": { + "get": { + "summary": "List queryable columns for a table", + "operationId": "analytics_api.columns.list_for_table", + "responses": { + "200": { + "description": "List of columns", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnListResponse" + } + } + } }, - "detail": { - "type": "string" + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "instance": { - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "status": { - "format": "int32", - "type": "integer" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "title": { - "type": "string" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "trace_id": { - "type": "string" + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "type": { - "type": "string" + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "type", - "title", - "status", - "detail", - "context" - ], - "type": "object" - }, - "QueryRequest": { - "description": "Query request body for `POST /v1/metrics/{id}/query`.\n\nUses `OData`-style parameters: `$filter`, `$orderby`, `$select`, `$top`, `$skip`.", - "properties": { - "$filter": { - "description": "`OData` filter expression.\ne.g. `\"metric_date ge '2026-03-01' and metric_date lt '2026-04-01'\"`.", - "type": [ - "string", - "null" - ] + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/v1/metric-results": { + "post": { + "summary": "Compute metric results", + "operationId": "analytics_api.metric_results.create", + "responses": { + "200": { + "description": "Metric results", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } }, - "$orderby": { - "description": "`OData` ordering expression.\ne.g. `\"metric_date desc\"`.", - "type": [ - "string", - "null" - ] + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "$select": { - "description": "Comma-separated list of columns to return.\ne.g. `\"person_id, avg_hours, metric_date\"`.", - "type": [ - "string", - "null" - ] + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "$skip": { - "description": "Opaque cursor for keyset pagination (from previous `page_info.cursor`).", - "type": [ - "string", - "null" - ] + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "$top": { - "description": "Maximum number of rows (default 25, max 200).", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - "type": "object" - }, - "QueryResponse": { - "description": "Query response with cursor-based pagination.\n\n`items` rows carry a per-metric dynamic schema (the `SELECT` columns vary by\nmetric), so each row is an untyped JSON object.", - "properties": { - "items": { - "items": {}, - "type": "array" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "page_info": { - "$ref": "#/components/schemas/PageInfo" - } - }, - "required": [ - "items", - "page_info" - ], - "type": "object" - }, - "Scope": { - "description": "Canonical scope values for `metric_threshold.scope`. Mirrors the DB-side\nENUM declared in `migration/m20260522_000002_metric_threshold.rs` line\n102–106 and the resolver's `Scope` (kept as a separate type because the\nresolver's enum is private to that module).\n\nWire form is the dash-keyed string the DB stores — deserializing via\n`serde(rename_all = \"kebab-case\")` would NOT produce the right value for\n`team+role` (kebab would yield `team-role`), so we spell each variant\nexplicitly with `#[serde(rename = ...)]`.", - "enum": [ - "product-default", - "tenant", - "role", - "team", - "team+role" - ], - "type": "string" - }, - "Subordinate": { - "description": "Subordinate summary.", - "properties": { - "display_name": { - "type": "string" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "email": { - "type": "string" + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "job_title": { - "type": "string" + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "email", - "display_name", - "job_title" - ], - "type": "object" - }, - "TableColumn": { - "description": "A column in the `ClickHouse` schema catalog.", - "properties": { - "clickhouse_table": { - "type": "string" + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/v1/metrics": { + "get": { + "summary": "List metrics", + "operationId": "analytics_api.metrics.list", + "responses": { + "200": { + "description": "List of metrics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricListResponse" + } + } + } }, - "field_description": { - "type": [ - "string", - "null" - ] + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "field_name": { - "type": "string" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "id": { - "format": "uuid", - "type": "string" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "insight_tenant_id": { - "format": "uuid", - "type": [ - "string", - "null" - ] + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "id", - "clickhouse_table", - "field_name" - ], - "type": "object" + "security": [ + { + "bearerAuth": [] + } + ] }, - "Threshold": { - "description": "A threshold rule — configured per metric, per field.\n\nThe query engine evaluates every result row against the metric's thresholds\nand attaches a `_thresholds` map to the response.", - "properties": { - "created_at": { - "format": "date-time", - "type": "string" + "post": { + "summary": "Create a metric", + "operationId": "analytics_api.metrics.create", + "requestBody": { + "description": "Metric to create", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMetricRequest" + } + } }, - "field_name": { - "type": "string" + "required": true + }, + "responses": { + "201": { + "description": "Created metric", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Metric" + } + } + } }, - "id": { - "format": "uuid", - "type": "string" + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "insight_tenant_id": { - "format": "uuid", - "type": "string" + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "level": { - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "metric_id": { - "format": "uuid", - "type": "string" + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "operator": { - "type": "string" + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "updated_at": { - "format": "date-time", - "type": "string" + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "value": { - "format": "double", - "type": "number" + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "required": [ - "id", - "insight_tenant_id", - "metric_id", - "field_name", - "operator", - "value", - "level", - "created_at", - "updated_at" - ], - "type": "object" - }, - "ThresholdListResponse": { - "description": "Response envelope for `GET /v1/metrics/{id}/thresholds`\n(`{ \"items\": [Threshold] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the list\nhandler emits.", - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/Threshold" - }, - "type": "array" + "security": [ + { + "bearerAuth": [] } - }, - "required": [ - "items" - ], - "type": "object" - }, - "UpdateMetricRequest": { - "description": "Request to update a metric.\n\n`description` uses double-Option to distinguish:\n- absent field → leave unchanged\n- explicit `null` → clear to None\n- `\"some text\"` → set to Some(\"some text\")", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "is_enabled": { - "type": [ - "boolean", - "null" - ] - }, - "name": { - "type": [ - "string", - "null" - ] + ] + } + }, + "/v1/metrics/queries": { + "post": { + "summary": "Query metrics in batch", + "operationId": "analytics_api.metrics.query_batch", + "requestBody": { + "description": "Batch of per-metric queries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchQueryRequest" + } + } }, - "query_ref": { - "type": [ - "string", - "null" - ] - } + "required": true }, - "type": "object" - }, - "UpdateRequest": { - "additionalProperties": false, - "description": "`PUT /v1/admin/metric-thresholds/{id}` body — update an existing row.\n\n`scope` / `role_slug` / `team_id` are intentionally accepted here even\nthough they're immutable post-create: when present, the gauntlet\ncompares the value to the row's current value and rejects with\n`failed_precondition` + `type: \"immutable_field\"` if they differ. Re-\nscoping requires DELETE + POST per DESIGN §3.7 line 1034.", - "properties": { - "alert_bad": { - "format": "double", - "type": [ - "number", - "null" - ] - }, - "alert_trigger": { - "format": "double", - "type": [ - "number", - "null" - ] - }, - "good": { - "format": "double", - "type": "number" - }, - "is_locked": { - "type": "boolean" - }, - "lock_reason": { - "type": [ - "string", - "null" - ] + "responses": { + "200": { + "description": "Batch query result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchQueryResponse" + } + } + } }, - "role_slug": { - "type": [ - "string", - "null" - ] + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "scope": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/Scope", - "description": "Echoed by the caller as a sanity check; the gauntlet validates it\nagainst the row's current value." + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } } - ] + } }, - "team_id": { - "type": [ - "string", - "null" - ] + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "warn": { - "format": "double", - "type": "number" - } - }, - "required": [ - "good", - "warn" - ], - "type": "object" - }, - "UpdateThresholdRequest": { - "description": "Request to update a threshold.", - "properties": { - "field_name": { - "type": [ - "string", - "null" - ] + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "level": { - "type": [ - "string", - "null" - ] + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "operator": { - "type": [ - "string", - "null" - ] + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } }, - "value": { - "format": "double", - "type": [ - "number", - "null" - ] + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } } }, - "type": "object" + "security": [ + { + "bearerAuth": [] + } + ] } }, - "securitySchemes": { - "bearerAuth": { - "bearerFormat": "JWT", - "scheme": "bearer", - "type": "http" - } - } - }, - "info": { - "description": "Read-only query service over predefined ClickHouse metrics. Admins define metrics (named SQL queries) in MariaDB; the frontend queries them by UUID with OData-style filtering. The API Gateway mounts this service at /api/analytics.", - "title": "Analytics API", - "version": "1.0.0" - }, - "openapi": "3.1.0", - "paths": { - "/v1/admin/metric-thresholds": { + "/v1/metrics/{id}": { "get": { - "operationId": "analytics_api.admin.thresholds.list", + "summary": "Get a metric by id", + "operationId": "analytics_api.metrics.get", "responses": { "200": { + "description": "Metric", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListResponse" + "$ref": "#/components/schemas/Metric" } } - }, - "description": "List of metric thresholds" + } }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "List admin metric thresholds" + ] }, - "post": { - "operationId": "analytics_api.admin.thresholds.create", + "put": { + "summary": "Update a metric", + "operationId": "analytics_api.metrics.update", "requestBody": { + "description": "Metric fields to update", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateRequest" + "$ref": "#/components/schemas/UpdateMetricRequest" } } }, - "description": "Metric threshold to create", "required": true }, "responses": { - "201": { + "200": { + "description": "Updated metric", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMetricThresholdView" + "$ref": "#/components/schemas/Metric" } } - }, - "description": "Created metric threshold" + } }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "Create an admin metric threshold" - } - }, - "/v1/admin/metric-thresholds/{id}": { + ] + }, "delete": { - "operationId": "analytics_api.admin.thresholds.delete", + "summary": "Delete a metric", + "operationId": "analytics_api.metrics.delete", "responses": { "204": { - "description": "Metric threshold deleted" + "description": "Metric deleted" }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "Delete an admin metric threshold" - }, - "get": { - "operationId": "analytics_api.admin.thresholds.get", + ] + } + }, + "/v1/metrics/{id}/query": { + "post": { + "summary": "Query a single metric", + "operationId": "analytics_api.metrics.query", + "requestBody": { + "description": "OData-style query parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" + } + } + }, + "required": true + }, "responses": { "200": { + "description": "Query result", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMetricThresholdView" + "$ref": "#/components/schemas/QueryResponse" } } - }, - "description": "Metric threshold" + } }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "Get an admin metric threshold by id" - }, - "put": { - "operationId": "analytics_api.admin.thresholds.update", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRequest" - } - } - }, - "description": "Metric threshold fields to update", - "required": true - }, + ] + } + }, + "/v1/metrics/{id}/thresholds": { + "get": { + "summary": "List thresholds for a metric", + "operationId": "analytics_api.thresholds.list", "responses": { "200": { + "description": "List of thresholds", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMetricThresholdView" + "$ref": "#/components/schemas/ThresholdListResponse" } } - }, - "description": "Updated metric threshold" + } }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "Update an admin metric threshold" - } - }, - "/v1/catalog/get_metrics": { + ] + }, "post": { - "operationId": "analytics_api.catalog.get_metrics", + "summary": "Create a threshold for a metric", + "operationId": "analytics_api.thresholds.create", "requestBody": { + "description": "Threshold to create", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetMetricsRequest" + "$ref": "#/components/schemas/CreateThresholdRequest" } } }, - "description": "Catalog read request context (role, team)", "required": true }, "responses": { - "200": { + "201": { + "description": "Created threshold", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CatalogResponse" + "$ref": "#/components/schemas/Threshold" } } - }, - "description": "Resolved metric catalog" + } }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "Read the metric catalog for the request context" + ] } }, - "/v1/columns": { - "get": { - "operationId": "analytics_api.columns.list", + "/v1/metrics/{id}/thresholds/{tid}": { + "put": { + "summary": "Update a threshold", + "operationId": "analytics_api.thresholds.update", + "requestBody": { + "description": "Threshold fields to update", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateThresholdRequest" + } + } + }, + "required": true + }, "responses": { "200": { + "description": "Updated threshold", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ColumnListResponse" + "$ref": "#/components/schemas/Threshold" } } - }, - "description": "List of columns" + } }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "List queryable columns" - } - }, - "/v1/columns/{table}": { - "get": { - "operationId": "analytics_api.columns.list_for_table", + ] + }, + "delete": { + "summary": "Delete a threshold", + "operationId": "analytics_api.thresholds.delete", "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ColumnListResponse" - } - } - }, - "description": "List of columns" + "204": { + "description": "Threshold deleted" }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } - ], - "summary": "List queryable columns for a table" + ] } }, - "/v1/metrics": { + "/v1/persons/{email}": { "get": { - "operationId": "analytics_api.metrics.list", + "summary": "Resolve a person by email", + "operationId": "analytics_api.persons.get", "responses": { "200": { + "description": "Person", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetricListResponse" + "$ref": "#/components/schemas/Person" } } - }, - "description": "List of metrics" + } }, "400": { + "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Bad Request" + } }, "401": { + "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Unauthorized" + } }, "403": { + "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Forbidden" + } }, "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Not Found" + } }, "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Conflict" + } }, "429": { + "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Too Many Requests" + } }, "500": { + "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - }, - "description": "Internal Server Error" + } } }, "security": [ { "bearerAuth": [] } + ] + } + } + }, + "components": { + "schemas": { + "AdminMetricThresholdView": { + "type": "object", + "description": "On-wire shape of one `metric_threshold` row in list / get responses.\n\n`metric_key` is NOT serialized — same backend-internal opacity rule the\nread endpoint follows (`domain/catalog/response.rs::MetricView`).\nConsumers identify a metric by `metric_id`.\n\nThe OpenAPI component is named `AdminMetricThresholdView` (via\n`#[schema(as)]`) to disambiguate from the catalog read path's\n`ThresholdView` (`domain::catalog::response::ThresholdView`, registered as\n`CatalogThresholdView`), which is a different wire shape. `#[schema(as)]`\nrenames only the OpenAPI component — it does NOT affect serde / the wire\nformat.", + "required": [ + "id", + "metric_id", + "scope", + "good", + "warn", + "is_locked", + "schema_status" ], - "summary": "List metrics" - }, - "post": { - "operationId": "analytics_api.metrics.create", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMetricRequest" - } - } + "properties": { + "alert_bad": { + "type": [ + "number", + "null" + ], + "format": "double" }, - "description": "Metric to create", - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Metric" - } - } - }, - "description": "Created metric" + "alert_trigger": { + "type": [ + "number", + "null" + ], + "format": "double" }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "good": { + "type": "number", + "format": "double" }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "id": { + "type": "string", + "format": "uuid" + }, + "is_locked": { + "type": "boolean" + }, + "lock_reason": { + "type": [ + "string", + "null" + ] + }, + "locked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "locked_by": { + "type": [ + "string", + "null" + ] + }, + "metric_id": { + "type": "string", + "format": "uuid", + "description": "UUIDv7 of the corresponding `metric_catalog` row." + }, + "role_slug": { + "type": [ + "string", + "null" + ], + "description": "Empty-string sentinel collapsed to `None` on the wire so the JSON\nshape is `null` instead of `\"\"` (the latter would confuse FE\n\"is this set?\" predicates)." + }, + "schema_error_code": { + "type": [ + "string", + "null" + ], + "description": "Canonical error code (`table_not_found | column_not_found |\nclickhouse_unreachable | unknown`) when `schema_status = \"error\"`,\notherwise omitted." + }, + "schema_status": { + "type": "string", + "description": "One of `ok | error | unchecked`, joined from `metric_catalog.schema_status`\n(DESIGN §3.3 \"Schema status surface\"). Lets the admin UI flag a\nbroken metric before the operator submits a write." }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "scope": { + "$ref": "#/components/schemas/Scope" }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "team_id": { + "type": [ + "string", + "null" + ] }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "tenant_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "`Some(_)` for tenant-scoped rows, `None` for `product-default`." }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "warn": { + "type": "number", + "format": "double" + } + } + }, + "BatchQueryItem": { + "allOf": [ + { + "$ref": "#/components/schemas/QueryRequest" }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } + { + "type": "object", + "required": [ + "metric_id" + ], + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "metric_id": { + "type": "string", + "format": "uuid" } - }, - "description": "Internal Server Error" + } } - }, - "security": [ - { - "bearerAuth": [] + ] + }, + "BatchQueryRequest": { + "type": "object", + "required": [ + "queries" + ], + "properties": { + "queries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchQueryItem" + } } + } + }, + "BatchQueryResponse": { + "type": "object", + "required": [ + "results" ], - "summary": "Create a metric" - } - }, - "/v1/metrics/queries": { - "post": { - "operationId": "analytics_api.metrics.query_batch", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchQueryRequest" - } + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchQueryResult" } - }, - "description": "Batch of per-metric queries", - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchQueryResponse" + } + } + }, + "BatchQueryResult": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/QueryResponse" + }, + { + "type": "object", + "required": [ + "metric_id" + ], + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "metric_id": { + "type": "string", + "format": "uuid" + } } - } - }, - "description": "Batch query result" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" + }, + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + } } } - }, - "description": "Bad Request" + ] }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } + { + "type": "object", + "required": [ + "metric_id", + "error", + "status" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Problem" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "metric_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "error" + ] } - }, - "description": "Unauthorized" + } + } + ] + }, + "CatalogResponse": { + "type": "object", + "description": "Top-level response body. `tenant_id` is echoed for client-side cache\nreasoning AND re-asserted on cache hydrate as defense in depth against a\nmisconfigured cache backend serving a sibling tenant's payload.\n\n`links` carries the `metric_query_catalog` M:N mapping per ADR-003. The\nmapping is time/filter-invariant, so consumers cache it for the same TTL as\nthe catalog itself; see [`MetricQueryLink`].", + "required": [ + "tenant_id", + "generated_at", + "metrics", + "links" + ], + "properties": { + "generated_at": { + "type": "string", + "format": "date-time" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricQueryLink" + } }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "metrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricView" + } }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "tenant_id": { + "type": "string", + "format": "uuid" + } + } + }, + "CatalogThresholdView": { + "type": "object", + "description": "Resolved threshold for one metric.\n\n`good` / `warn` are `f64` on the wire — DECIMAL(20,6) in the DB rounds-trips\nthrough DOUBLE for every seed value (integers and one-decimal floats). If\nfuture seed entries need full-precision decimals, this is the place to switch\nto a string serializer; the FE byte-for-byte comparison gate (PRD §12) is\nthe regression detector.\n\nThe OpenAPI component is named `CatalogThresholdView` (via `#[schema(as)]`)\nto disambiguate from the admin-CRUD `ThresholdView`\n(`domain::admin_threshold::dto::ThresholdView`), which is a different wire\nshape registered under `AdminMetricThresholdView`. `#[schema(as)]` renames\nonly the OpenAPI component — it does NOT affect serde / the wire format.", + "required": [ + "good", + "warn", + "resolved_from", + "bounded_by_lock" + ], + "properties": { + "alert_bad": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "alert_trigger": { + "type": [ + "number", + "null" + ], + "format": "double" }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "bounded_by_lock": { + "type": "boolean", + "description": "`true` iff the walk halted on a locked broader-scope row before reaching\nthe most-specific candidate. Separate signal from `resolved_from`, which\nalways names the row that won." }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" + "good": { + "type": "number", + "format": "double" + }, + "resolved_from": { + "type": "string", + "description": "One of `\"team+role\" | \"team\" | \"role\" | \"tenant\" | \"product-default\"`.\nNames the row that won the walk." + }, + "warn": { + "type": "number", + "format": "double" } - }, - "security": [ - { - "bearerAuth": [] + } + }, + "ColumnListResponse": { + "type": "object", + "description": "Response envelope for `GET /v1/columns` and `GET /v1/columns/{table}`\n(`{ \"items\": [TableColumn] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the\nhandlers emit — gives the column-list endpoints a real OpenAPI schema.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TableColumn" + } } + } + }, + "CreateMetricRequest": { + "type": "object", + "description": "Request to create a new metric.", + "required": [ + "name", + "query_ref" ], - "summary": "Query metrics in batch" - } - }, - "/v1/metrics/{id}": { - "delete": { - "operationId": "analytics_api.metrics.delete", - "responses": { - "204": { - "description": "Metric deleted" + "properties": { + "description": { + "type": [ + "string", + "null" + ] }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "name": { + "type": "string" }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "query_ref": { + "type": "string" + } + } + }, + "CreateRequest": { + "type": "object", + "description": "`POST /v1/admin/metric-thresholds` body — create a new threshold row.\n\n`tenant_id` / `id` / `locked_by` / `locked_at` / `created_at` /\n`updated_at` are NOT accepted from the body. `deny_unknown_fields`\nenforces that at the serde layer.\n\n`role_slug` / `team_id` use `Option` — `None` is the canonical\nempty-string sentinel (DESIGN §3.7 + `infra/cache/catalog_cache.rs::cache_field`).", + "required": [ + "metric_id", + "scope", + "good", + "warn" + ], + "properties": { + "alert_bad": { + "type": [ + "number", + "null" + ], + "format": "double" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "alert_trigger": { + "type": [ + "number", + "null" + ], + "format": "double" }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "good": { + "type": "number", + "format": "double" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "is_locked": { + "type": "boolean" }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "lock_reason": { + "type": [ + "string", + "null" + ] }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Delete a metric" - }, - "get": { - "operationId": "analytics_api.metrics.get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Metric" - } - } - }, - "description": "Metric" + "metric_id": { + "type": "string", + "format": "uuid" }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "role_slug": { + "type": [ + "string", + "null" + ] }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "scope": { + "$ref": "#/components/schemas/Scope" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "team_id": { + "type": [ + "string", + "null" + ] }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "warn": { + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "CreateThresholdRequest": { + "type": "object", + "description": "Request to create a threshold.", + "required": [ + "field_name", + "operator", + "value", + "level" + ], + "properties": { + "field_name": { + "type": "string" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "level": { + "type": "string", + "description": "Result level: `good`, `warning`, `critical`." }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "operator": { + "type": "string", + "description": "Comparison operator: `gt`, `ge`, `lt`, `le`, `eq`." + }, + "value": { + "type": "number", + "format": "double" + } + } + }, + "GetMetricsRequest": { + "type": "object", + "description": "Request body for `POST /v1/catalog/get_metrics`.\n\n`tenant_id` is intentionally NOT accepted here — it is resolved server-side\nfrom the session by `tenant_middleware` (Refs #522 auth-trait). Allowing a\nbody-supplied `tenant_id` would open a cross-tenant disclosure surface.\n`deny_unknown_fields` enforces that defensively at the parser layer: a\ncaller that smuggles `\"tenant_id\": \"...\"` into the body gets a 400 instead\nof a silent ignore.", + "properties": { + "role_slug": { + "type": [ + "string", + "null" + ], + "description": "Role slug for `role` / `team+role` resolution chains. `None` and `Some(\"\")`\nare semantically identical and produce the same cache key (canonical\nempty-string sentinel — see `cache_key` in the cache layer)." }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" + "team_id": { + "type": [ + "string", + "null" + ], + "description": "Team id for `team` / `team+role` resolution chains. Same `None` vs `Some(\"\")`\nequivalence as `role_slug`." } }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Get a metric by id" + "additionalProperties": false }, - "put": { - "operationId": "analytics_api.metrics.update", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMetricRequest" - } + "ListResponse": { + "type": "object", + "description": "`GET /v1/admin/metric-thresholds` response envelope.\n\nWraps `items` in an object (instead of a bare array) so future\nadditions (pagination cursor, count, generated-at) are additive and\nnon-breaking. Mirrors the catalog read endpoint's envelope shape.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminMetricThresholdView" } + } + } + }, + "Metric": { + "type": "object", + "description": "A metric definition — an admin-configured SQL query against `ClickHouse`.\n\nThe `query_ref` field holds raw `ClickHouse` SQL. The query engine wraps it\nas a subquery, appending security filters + `OData` filters as parameterized\nWHERE clauses.", + "required": [ + "id", + "insight_tenant_id", + "name", + "query_ref", + "is_enabled", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" }, - "description": "Metric fields to update", - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Metric" - } - } - }, - "description": "Updated metric" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "description": { + "type": [ + "string", + "null" + ] }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "id": { + "type": "string", + "format": "uuid" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "insight_tenant_id": { + "type": "string", + "format": "uuid" }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "is_enabled": { + "type": "boolean" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "name": { + "type": "string" }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "query_ref": { + "type": "string" }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearerAuth": [] + "updated_at": { + "type": "string", + "format": "date-time" } + } + }, + "MetricListResponse": { + "type": "object", + "description": "Response envelope for `GET /v1/metrics` (`{ \"items\": [MetricSummary] }`).\n\nDocs-only wrapper: the handler emits the same object shape via an inline\n`serde_json::json!` literal. Existing on the wire; this type just gives the\nlist endpoint a real OpenAPI schema instead of a generic object.", + "required": [ + "items" ], - "summary": "Update a metric" - } - }, - "/v1/metrics/{id}/query": { - "post": { - "operationId": "analytics_api.metrics.query", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryRequest" - } + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricSummary" } - }, - "description": "OData-style query parameters", - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryResponse" - } - } + } + } + }, + "MetricQueryLink": { + "type": "object", + "description": "One link row from `metric_query_catalog`. Tells a consumer which catalog\nrows a `metrics.query_ref` emits when executed — the M:N answer ADR-001\nadded at the DB layer, surfaced here so consumers don't have to derive it\nby joining on backend-internal `metric_key` strings.\n\n`catalog_metric_ids` is the set of `metric_catalog.id` UUIDs the query\nproduces. The set is empty only when the linked catalog rows are all\n`is_enabled = false` (filtered out of the `metrics` array) — consumers\ndegrade gracefully on empty.", + "required": [ + "query_id", + "catalog_metric_ids" + ], + "properties": { + "catalog_metric_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" }, - "description": "Query result" + "description": "`metric_catalog.id` UUIDs this query emits. Sorted ascending so the\nwire payload is byte-stable for cache + diff tooling." }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "query_id": { + "type": "string", + "format": "uuid", + "description": "`metrics.id` — the ClickHouse `query_ref` row this link is FROM." + } + } + }, + "MetricSummary": { + "type": "object", + "description": "Summary returned in list endpoints (no `query_ref`).", + "required": [ + "id", + "name" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ] }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "id": { + "type": "string", + "format": "uuid" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "name": { + "type": "string" + } + } + }, + "MetricView": { + "type": "object", + "description": "One catalog metric on the wire. `metric_key` is surfaced per ADR-002 as the\ntransitional FE-bridge identifier; consumers MUST still key lookups by `id`.", + "required": [ + "id", + "metric_key", + "label", + "higher_is_better", + "is_member_scale", + "source_tags", + "schema_status", + "thresholds" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ] }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "format": { + "type": [ + "string", + "null" + ] }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "higher_is_better": { + "type": "boolean" }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "id": { + "type": "string", + "format": "uuid" }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Query a single metric" - } - }, - "/v1/metrics/{id}/thresholds": { - "get": { - "operationId": "analytics_api.thresholds.list", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ThresholdListResponse" - } - } - }, - "description": "List of thresholds" + "is_member_scale": { + "type": "boolean" }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "label": { + "type": "string" }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "metric_key": { + "type": "string", + "description": "Backend's `.` identifier. Surfaced per ADR-002\nso the FE can align compiled-in `BULLET_DEFS` constants to wire rows\nduring the catalog-hydration transitional release; the stable lookup\nkey remains `id`." }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "schema_error_code": { + "type": [ + "string", + "null" + ], + "description": "Canonical code from `{ table_not_found, column_not_found,\nclickhouse_unreachable, unknown }`, only present when `schema_status = \"error\"`.\nRaw ClickHouse error text NEVER reaches consumers per DESIGN §3.3." }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "schema_status": { + "type": "string", + "description": "`\"ok\" | \"error\" | \"unchecked\"` — sourced from `metric_catalog.schema_status`.\nConsumers render `\"unchecked\"` the same as `\"ok\"` (validator hasn't run\nyet); only `\"error\"` triggers the broken-metric indicator." }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "source_tags": { + "type": "array", + "items": { + "type": "string" + } }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "sublabel": { + "type": [ + "string", + "null" + ] }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearerAuth": [] + "thresholds": { + "$ref": "#/components/schemas/CatalogThresholdView" + }, + "unit": { + "type": [ + "string", + "null" + ] } + } + }, + "PageInfo": { + "type": "object", + "description": "Pagination info.", + "required": [ + "has_next" ], - "summary": "List thresholds for a metric" + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "has_next": { + "type": "boolean" + } + } }, - "post": { - "operationId": "analytics_api.thresholds.create", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateThresholdRequest" - } - } + "Person": { + "type": "object", + "description": "Person info returned by the Identity service.", + "required": [ + "email", + "display_name", + "first_name", + "last_name", + "department", + "division", + "job_title", + "status", + "subordinates" + ], + "properties": { + "department": { + "type": "string" }, - "description": "Threshold to create", - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Threshold" - } - } - }, - "description": "Created threshold" + "display_name": { + "type": "string" }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "division": { + "type": "string" }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "email": { + "type": "string" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "first_name": { + "type": "string" }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "job_title": { + "type": "string" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "last_name": { + "type": "string" }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "status": { + "type": "string" + }, + "subordinates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Subordinate" + } }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearerAuth": [] + "supervisor_email": { + "type": [ + "string", + "null" + ] + }, + "supervisor_name": { + "type": [ + "string", + "null" + ] } + } + }, + "Problem": { + "type": "object", + "description": "RFC 9457 problem+json. `context` varies by error category.", + "required": [ + "type", + "title", + "status", + "detail", + "context" ], - "summary": "Create a threshold for a metric" - } - }, - "/v1/metrics/{id}/thresholds/{tid}": { - "delete": { - "operationId": "analytics_api.thresholds.delete", - "responses": { - "204": { - "description": "Threshold deleted" + "properties": { + "context": { + "type": "object" }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "detail": { + "type": "string" }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "instance": { + "type": "string" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "status": { + "type": "integer", + "format": "int32" }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "title": { + "type": "string" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "trace_id": { + "type": "string" }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "type": { + "type": "string" + } + } + }, + "QueryRequest": { + "type": "object", + "description": "Query request body for `POST /v1/metrics/{id}/query`.\n\nUses `OData`-style parameters: `$filter`, `$orderby`, `$select`, `$top`, `$skip`.", + "properties": { + "$filter": { + "type": [ + "string", + "null" + ], + "description": "`OData` filter expression.\ne.g. `\"metric_date ge '2026-03-01' and metric_date lt '2026-04-01'\"`." }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" + "$orderby": { + "type": [ + "string", + "null" + ], + "description": "`OData` ordering expression.\ne.g. `\"metric_date desc\"`." + }, + "$select": { + "type": [ + "string", + "null" + ], + "description": "Comma-separated list of columns to return.\ne.g. `\"person_id, avg_hours, metric_date\"`." + }, + "$skip": { + "type": [ + "string", + "null" + ], + "description": "Opaque cursor for keyset pagination (from previous `page_info.cursor`)." + }, + "$top": { + "type": "integer", + "format": "int64", + "description": "Maximum number of rows (default 25, max 200).", + "minimum": 0 } - }, - "security": [ - { - "bearerAuth": [] + } + }, + "QueryResponse": { + "type": "object", + "description": "Query response with cursor-based pagination.\n\n`items` rows carry a per-metric dynamic schema (the `SELECT` columns vary by\nmetric), so each row is an untyped JSON object.", + "required": [ + "items", + "page_info" + ], + "properties": { + "items": { + "type": "array", + "items": {} + }, + "page_info": { + "$ref": "#/components/schemas/PageInfo" } + } + }, + "Scope": { + "type": "string", + "description": "Canonical scope values for `metric_threshold.scope`. Mirrors the DB-side\nENUM declared in `migration/m20260522_000002_metric_threshold.rs` line\n102–106 and the resolver's `Scope` (kept as a separate type because the\nresolver's enum is private to that module).\n\nWire form is the dash-keyed string the DB stores — deserializing via\n`serde(rename_all = \"kebab-case\")` would NOT produce the right value for\n`team+role` (kebab would yield `team-role`), so we spell each variant\nexplicitly with `#[serde(rename = ...)]`.", + "enum": [ + "product-default", + "tenant", + "role", + "team", + "team+role" + ] + }, + "Subordinate": { + "type": "object", + "description": "Subordinate summary.", + "required": [ + "email", + "display_name", + "job_title" ], - "summary": "Delete a threshold" + "properties": { + "display_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "job_title": { + "type": "string" + } + } }, - "put": { - "operationId": "analytics_api.thresholds.update", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateThresholdRequest" - } - } + "TableColumn": { + "type": "object", + "description": "A column in the `ClickHouse` schema catalog.", + "required": [ + "id", + "clickhouse_table", + "field_name" + ], + "properties": { + "clickhouse_table": { + "type": "string" }, - "description": "Threshold fields to update", - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Threshold" - } - } - }, - "description": "Updated threshold" + "field_description": { + "type": [ + "string", + "null" + ] }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "field_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "insight_tenant_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "Threshold": { + "type": "object", + "description": "A threshold rule — configured per metric, per field.\n\nThe query engine evaluates every result row against the metric's thresholds\nand attaches a `_thresholds` map to the response.", + "required": [ + "id", + "insight_tenant_id", + "metric_id", + "field_name", + "operator", + "value", + "level", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "field_name": { + "type": "string" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "id": { + "type": "string", + "format": "uuid" }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "insight_tenant_id": { + "type": "string", + "format": "uuid" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "level": { + "type": "string" }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" + "metric_id": { + "type": "string", + "format": "uuid" }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" + "operator": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "value": { + "type": "number", + "format": "double" } - }, - "security": [ - { - "bearerAuth": [] + } + }, + "ThresholdListResponse": { + "type": "object", + "description": "Response envelope for `GET /v1/metrics/{id}/thresholds`\n(`{ \"items\": [Threshold] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the list\nhandler emits.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Threshold" + } + } + } + }, + "UpdateMetricRequest": { + "type": "object", + "description": "Request to update a metric.\n\n`description` uses double-Option to distinguish:\n- absent field → leave unchanged\n- explicit `null` → clear to None\n- `\"some text\"` → set to Some(\"some text\")", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "is_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "query_ref": { + "type": [ + "string", + "null" + ] } + } + }, + "UpdateRequest": { + "type": "object", + "description": "`PUT /v1/admin/metric-thresholds/{id}` body — update an existing row.\n\n`scope` / `role_slug` / `team_id` are intentionally accepted here even\nthough they're immutable post-create: when present, the gauntlet\ncompares the value to the row's current value and rejects with\n`failed_precondition` + `type: \"immutable_field\"` if they differ. Re-\nscoping requires DELETE + POST per DESIGN §3.7 line 1034.", + "required": [ + "good", + "warn" ], - "summary": "Update a threshold" - } - }, - "/v1/persons/{email}": { - "get": { - "operationId": "analytics_api.persons.get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Person" - } - } - }, - "description": "Person" + "properties": { + "alert_bad": { + "type": [ + "number", + "null" + ], + "format": "double" }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" + "alert_trigger": { + "type": [ + "number", + "null" + ], + "format": "double" }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" + "good": { + "type": "number", + "format": "double" }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" + "is_locked": { + "type": "boolean" }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" + "lock_reason": { + "type": [ + "string", + "null" + ] }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" + "role_slug": { + "type": [ + "string", + "null" + ] }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } + "scope": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Scope", + "description": "Echoed by the caller as a sanity check; the gauntlet validates it\nagainst the row's current value." } - }, - "description": "Too Many Requests" + ] }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" + "team_id": { + "type": [ + "string", + "null" + ] + }, + "warn": { + "type": "number", + "format": "double" } }, - "security": [ - { - "bearerAuth": [] + "additionalProperties": false + }, + "UpdateThresholdRequest": { + "type": "object", + "description": "Request to update a threshold.", + "properties": { + "field_name": { + "type": [ + "string", + "null" + ] + }, + "level": { + "type": [ + "string", + "null" + ] + }, + "operator": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": [ + "number", + "null" + ], + "format": "double" } - ], - "summary": "Resolve a person by email" + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" } } } diff --git a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs index 5d5bc3835..67cacc9d3 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs @@ -1,12 +1,90 @@ +use crate::domain::metric_definitions::definition::{ + MetricComputation, MetricDirection, MetricFormat, MetricInputRole, ObservationSource, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + ManagedObservation, +} + +impl SourceKind { + pub fn as_db(self) -> &'static str { + match self { + Self::ManagedObservation => "managed_observation", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeasureValueType { + Number, +} + +impl MeasureValueType { + pub fn as_db(self) -> &'static str { + match self { + Self::Number => "number", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntityType { + Person, +} + +impl EntityType { + pub fn as_db(self) -> &'static str { + match self { + Self::Person => "person", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CohortKey { + OrgUnit, +} + +impl CohortKey { + pub fn as_db(self) -> &'static str { + match self { + Self::OrgUnit => "org_unit", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SeedComputation { + Sum, + Ratio { scale: f64 }, +} + +impl SeedComputation { + pub fn computation(self) -> MetricComputation { + match self { + Self::Sum => MetricComputation::Sum, + Self::Ratio { .. } => MetricComputation::Ratio, + } + } + + pub fn scale(self) -> Option { + match self { + Self::Sum => None, + Self::Ratio { scale } => Some(scale), + } + } +} + pub struct SourceSeed { pub key: &'static str, - pub kind: &'static str, - pub ref_name: &'static str, + pub kind: SourceKind, + pub source_ref: ObservationSource, } pub struct MeasureSeed { pub measure_key: &'static str, - pub value_type: &'static str, + pub value_type: MeasureValueType, } pub struct DimensionSeed { @@ -27,69 +105,66 @@ pub struct MetricSeed { pub description: Option<&'static str>, pub explanation: Option<&'static str>, pub unit: Option<&'static str>, - pub format: &'static str, - pub direction: &'static str, - pub entity_type: &'static str, - pub computation_type: &'static str, - pub scale: Option, - pub distribution_statistic: Option<&'static str>, - pub gauge_method: Option<&'static str>, - pub peer_cohort_key: Option<&'static str>, + pub format: MetricFormat, + pub direction: MetricDirection, + pub entity_type: EntityType, + pub computation: SeedComputation, + pub peer_cohort_key: Option, pub inputs: &'static [InputSeed], pub dimensions: &'static [&'static str], } pub struct InputSeed { - pub input_role: &'static str, + pub input_role: MetricInputRole, pub measure_key: &'static str, } pub const BUILTIN_SOURCES: &[BuiltinSource] = &[BuiltinSource { source: SourceSeed { key: "ai_usage", - kind: "managed_observation", - ref_name: "ai_metric_observations", + kind: SourceKind::ManagedObservation, + source_ref: ObservationSource::AiMetricObservations, }, measures: &[ MeasureSeed { measure_key: "accepted_lines", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "removed_lines", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "active_day", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "cost_usd", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "accepted_edit_actions", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "tool_use_offered", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "assistant_messages", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "assistant_actions", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "dev_conversations", - value_type: "number", + value_type: MeasureValueType::Number, }, MeasureSeed { measure_key: "chat_assistant_conversations", - value_type: "number", + value_type: MeasureValueType::Number, }, ], dimensions: &[ @@ -112,16 +187,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ description: Some("Accepted added coding output"), explanation: Some("Accepted AI-generated added lines across coding AI tools."), unit: Some("lines"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "accepted_lines", }], dimensions: &["tool"], @@ -133,16 +205,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ description: Some("Accepted deleted coding output"), explanation: Some("Accepted AI-generated removed lines across coding AI tools."), unit: Some("lines"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "removed_lines", }], dimensions: &["tool"], @@ -156,16 +225,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ "Distinct days with person-attributed AI activity across dev and assistant tools.", ), unit: Some("days"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "active_day", }], dimensions: &[], @@ -179,16 +245,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ "Person-attributed AI spend across dev and assistant tools, where the connector reports cost.", ), unit: None, - format: "currency", - direction: "lower_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Currency, + direction: MetricDirection::LowerIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "cost_usd", }], dimensions: &["tool"], @@ -200,16 +263,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ description: Some("Accepted tool or edit suggestions"), explanation: Some("Accepted AI edit or tool suggestions across supported coding AI tools."), unit: Some("actions"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "accepted_edit_actions", }], dimensions: &["tool"], @@ -221,21 +281,18 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ description: Some("Accepted divided by offered AI edits"), explanation: Some("Accepted AI edit or tool suggestions divided by offered suggestions."), unit: Some("percent"), - format: "percent", - direction: "higher_is_better", - entity_type: "person", - computation_type: "ratio", - scale: Some(100.0), - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Percent, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Ratio { scale: 100.0 }, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[ InputSeed { - input_role: "numerator", + input_role: MetricInputRole::Numerator, measure_key: "accepted_edit_actions", }, InputSeed { - input_role: "denominator", + input_role: MetricInputRole::Denominator, measure_key: "tool_use_offered", }, ], @@ -250,16 +307,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ "Person-attributed assistant messages from supported AI assistant tools.", ), unit: Some("messages"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "assistant_messages", }], dimensions: &["tool", "surface"], @@ -271,16 +325,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ description: Some("Assistant actions"), explanation: Some("Person-attributed assistant actions from supported AI assistant tools."), unit: Some("actions"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "assistant_actions", }], dimensions: &["tool", "surface"], @@ -294,16 +345,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ "Person-attributed coding conversations from dev tools that report them.", ), unit: Some("conversations"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "dev_conversations", }], dimensions: &["tool"], @@ -317,16 +365,13 @@ pub const BUILTIN_METRICS: &[MetricSeed] = &[ "Person-attributed chat assistant conversations from supported AI chat tools.", ), unit: Some("conversations"), - format: "integer", - direction: "higher_is_better", - entity_type: "person", - computation_type: "sum", - scale: None, - distribution_statistic: None, - gauge_method: None, - peer_cohort_key: Some("org_unit"), + format: MetricFormat::Integer, + direction: MetricDirection::HigherIsBetter, + entity_type: EntityType::Person, + computation: SeedComputation::Sum, + peer_cohort_key: Some(CohortKey::OrgUnit), inputs: &[InputSeed { - input_role: "value", + input_role: MetricInputRole::Value, measure_key: "chat_assistant_conversations", }], dimensions: &["tool", "surface"], @@ -448,56 +493,23 @@ mod tests { } } - #[test] - fn computation_fields_satisfy_db_check() { - for metric in BUILTIN_METRICS { - match metric.computation_type { - "sum" | "count" | "count_distinct" | "derived" => { - assert!(metric.scale.is_none(), "{}", metric.metric_key); - assert!( - metric.distribution_statistic.is_none(), - "{}", - metric.metric_key - ); - assert!(metric.gauge_method.is_none(), "{}", metric.metric_key); - } - "ratio" => { - assert!(metric.scale.is_some(), "{}", metric.metric_key); - assert!( - metric.distribution_statistic.is_none(), - "{}", - metric.metric_key - ); - assert!(metric.gauge_method.is_none(), "{}", metric.metric_key); - } - "distribution" => { - assert!( - metric.distribution_statistic.is_some(), - "{}", - metric.metric_key - ); - } - "gauge" => { - assert!(metric.gauge_method.is_some(), "{}", metric.metric_key); - } - other => panic!("unknown computation {other} for {}", metric.metric_key), - } - } - } - #[test] fn ratio_metrics_have_numerator_and_denominator_roles() { for metric in BUILTIN_METRICS { - if metric.computation_type != "ratio" { + let SeedComputation::Ratio { .. } = metric.computation else { continue; - } - let roles = metric - .inputs - .iter() - .map(|input| input.input_role) - .collect::>(); - assert!(roles.contains("numerator"), "{}", metric.metric_key); - assert!(roles.contains("denominator"), "{}", metric.metric_key); + }; + let has_role = |role| metric.inputs.iter().any(|input| input.input_role == role); + assert!( + has_role(MetricInputRole::Numerator), + "{}", + metric.metric_key + ); + assert!( + has_role(MetricInputRole::Denominator), + "{}", + metric.metric_key + ); } } } diff --git a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs index 82e1b0d3c..b8609024a 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs @@ -220,6 +220,12 @@ impl ExecutableMetric { } impl ObservationSource { + pub fn source_ref(self) -> &'static str { + match self { + Self::AiMetricObservations => "ai_metric_observations", + } + } + pub fn from_ref(value: &str) -> Option { match value { "ai_metric_observations" => Some(Self::AiMetricObservations), @@ -243,6 +249,15 @@ impl CohortSource { } impl MetricFormat { + pub fn as_db(self) -> &'static str { + match self { + Self::Integer => "integer", + Self::Decimal => "decimal", + Self::Currency => "currency", + Self::Percent => "percent", + } + } + pub fn from_db(value: &str) -> Option { match value { "integer" => Some(Self::Integer), @@ -255,6 +270,14 @@ impl MetricFormat { } impl MetricDirection { + pub fn as_db(self) -> &'static str { + match self { + Self::HigherIsBetter => "higher_is_better", + Self::LowerIsBetter => "lower_is_better", + Self::Neutral => "neutral", + } + } + pub fn from_db(value: &str) -> Option { match value { "higher_is_better" => Some(Self::HigherIsBetter), @@ -293,6 +316,18 @@ impl MetricComputation { } impl MetricInputRole { + pub fn as_db(self) -> &'static str { + match self { + Self::Value => "value", + Self::Event => "event", + Self::Numerator => "numerator", + Self::Denominator => "denominator", + Self::Sample => "sample", + Self::Snapshot => "snapshot", + Self::Dependency => "dependency", + } + } + pub fn from_db(value: &str) -> Option { match value { "value" => Some(Self::Value), @@ -332,3 +367,44 @@ impl GaugeMethod { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn db_strings_round_trip() { + for format in [ + MetricFormat::Integer, + MetricFormat::Decimal, + MetricFormat::Currency, + MetricFormat::Percent, + ] { + assert_eq!(MetricFormat::from_db(format.as_db()), Some(format)); + } + for direction in [ + MetricDirection::HigherIsBetter, + MetricDirection::LowerIsBetter, + MetricDirection::Neutral, + ] { + assert_eq!(MetricDirection::from_db(direction.as_db()), Some(direction)); + } + for role in [ + MetricInputRole::Value, + MetricInputRole::Event, + MetricInputRole::Numerator, + MetricInputRole::Denominator, + MetricInputRole::Sample, + MetricInputRole::Snapshot, + MetricInputRole::Dependency, + ] { + assert_eq!(MetricInputRole::from_db(role.as_db()), Some(role)); + } + for source in [ObservationSource::AiMetricObservations] { + assert_eq!( + ObservationSource::from_ref(source.source_ref()), + Some(source) + ); + } + } +} diff --git a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs index aaffc6352..283d50fe5 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs @@ -2,7 +2,7 @@ use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, Statement, Value}; use uuid::Uuid; use crate::domain::metric_definitions::builtin::{ - BUILTIN_METRICS, BUILTIN_SOURCES, BuiltinSource, InputSeed, MetricSeed, + BUILTIN_METRICS, BUILTIN_SOURCES, BuiltinSource, CohortKey, InputSeed, MetricSeed, }; pub async fn reconcile_builtin_definitions(db: &DatabaseConnection) -> Result<(), DbErr> { @@ -42,7 +42,7 @@ async fn reconcile_source( uuid_value(Uuid::now_v7()), uuid_value(source_id), Value::from(measure.measure_key), - Value::from(measure.value_type), + Value::from(measure.value_type.as_db()), ], )) .await?; @@ -88,8 +88,8 @@ async fn upsert_source( [ uuid_value(Uuid::now_v7()), Value::from(builtin_source.source.key), - Value::from(builtin_source.source.kind), - Value::from(builtin_source.source.ref_name), + Value::from(builtin_source.source.kind.as_db()), + Value::from(builtin_source.source.source_ref.source_ref()), ], )) .await?; @@ -127,17 +127,17 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( nullable_str(metric.description), nullable_str(metric.explanation), nullable_str(metric.unit), - Value::from(metric.format), - Value::from(metric.direction), - Value::from(metric.entity_type), - Value::from(metric.computation_type), - match metric.scale { + Value::from(metric.format.as_db()), + Value::from(metric.direction.as_db()), + Value::from(metric.entity_type.as_db()), + Value::from(metric.computation.computation().as_db()), + match metric.computation.scale() { Some(scale) => Value::from(scale), None => Value::Double(None), }, - nullable_str(metric.distribution_statistic), - nullable_str(metric.gauge_method), - nullable_str(metric.peer_cohort_key), + Value::String(None), + Value::String(None), + nullable_str(metric.peer_cohort_key.map(CohortKey::as_db)), ], )) .await?; @@ -167,7 +167,7 @@ async fn replace_inputs( [ uuid_value(Uuid::now_v7()), uuid_value(metric_id), - Value::from(input.input_role), + Value::from(input.input_role.as_db()), uuid_value(measure_id), Value::from(order_value(idx)), ], From 5354b456f5fa7c688c7b01d921d26048b7408ff6 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Mon, 6 Jul 2026 14:12:23 +0200 Subject: [PATCH 09/20] refactor(analytics): typed schema statuses and source kinds in the runtime SchemaStatus and SourceKind enums replace string comparisons in definition loading and the schema validator; status writes carry typed values end to end. Definitions referencing stored custom-SQL sources now classify as unavailable (client error) rather than corrupt configuration, matching the custom metric gate. The dimension alias contract between the query compiler and the response builder moves to one shared constructor. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- .../src/domain/metric_definitions/builtin.rs | 14 +--- .../domain/metric_definitions/definition.rs | 29 +++++++++ .../domain/metric_definitions/error_code.rs | 44 ++++++++++++- .../domain/metric_definitions/repository.rs | 65 ++++++++++++++----- .../domain/metric_definitions/validator.rs | 24 ++++--- .../src/domain/metric_results/builder.rs | 5 +- .../src/domain/metric_results/compiler.rs | 7 +- .../m20260625_000001_metric_definitions.rs | 2 +- 8 files changed, 140 insertions(+), 50 deletions(-) diff --git a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs index 67cacc9d3..a569fc2c6 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs @@ -1,20 +1,8 @@ use crate::domain::metric_definitions::definition::{ MetricComputation, MetricDirection, MetricFormat, MetricInputRole, ObservationSource, + SourceKind, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SourceKind { - ManagedObservation, -} - -impl SourceKind { - pub fn as_db(self) -> &'static str { - match self { - Self::ManagedObservation => "managed_observation", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MeasureValueType { Number, diff --git a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs index b8609024a..e606b365b 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs @@ -61,6 +61,29 @@ pub enum GaugeMethod { Avg, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + ManagedObservation, + CustomObservationSql, +} + +impl SourceKind { + pub fn as_db(self) -> &'static str { + match self { + Self::ManagedObservation => "managed_observation", + Self::CustomObservationSql => "custom_observation_sql", + } + } + + pub fn from_db(value: &str) -> Option { + match value { + "managed_observation" => Some(Self::ManagedObservation), + "custom_observation_sql" => Some(Self::CustomObservationSql), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ObservationSource { AiMetricObservations, @@ -406,5 +429,11 @@ mod tests { Some(source) ); } + for kind in [ + SourceKind::ManagedObservation, + SourceKind::CustomObservationSql, + ] { + assert_eq!(SourceKind::from_db(kind.as_db()), Some(kind)); + } } } diff --git a/src/backend/services/analytics/src/domain/metric_definitions/error_code.rs b/src/backend/services/analytics/src/domain/metric_definitions/error_code.rs index 6f882d82c..2726e32ce 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/error_code.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/error_code.rs @@ -18,7 +18,7 @@ pub const ALL_METRIC_SCHEMA_ERROR_CODES: &[MetricSchemaErrorCode] = &[ impl MetricSchemaErrorCode { #[must_use] - pub fn as_db_str(self) -> &'static str { + pub fn as_db(self) -> &'static str { match self { Self::TableNotFound => "table_not_found", Self::ColumnNotFound => "column_not_found", @@ -30,7 +30,33 @@ impl MetricSchemaErrorCode { impl fmt::Display for MetricSchemaErrorCode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_db_str()) + f.write_str(self.as_db()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SchemaStatus { + Ok, + Error, + Unchecked, +} + +impl SchemaStatus { + pub fn as_db(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Error => "error", + Self::Unchecked => "unchecked", + } + } + + pub fn from_db(value: &str) -> Option { + match value { + "ok" => Some(Self::Ok), + "error" => Some(Self::Error), + "unchecked" => Some(Self::Unchecked), + _ => None, + } } } @@ -38,11 +64,23 @@ impl fmt::Display for MetricSchemaErrorCode { mod tests { use super::*; + #[test] + fn schema_status_round_trips() { + for status in [ + SchemaStatus::Ok, + SchemaStatus::Error, + SchemaStatus::Unchecked, + ] { + assert_eq!(SchemaStatus::from_db(status.as_db()), Some(status)); + } + assert_eq!(SchemaStatus::from_db("nope"), None); + } + #[test] fn all_codes_listed_once() { let mut strings = ALL_METRIC_SCHEMA_ERROR_CODES .iter() - .map(|code| code.as_db_str()) + .map(|code| code.as_db()) .collect::>(); strings.sort_unstable(); strings.dedup(); diff --git a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs index dc37174ad..b50697073 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs @@ -5,11 +5,13 @@ use toolkit_canonical_errors::CanonicalError; use uuid::Uuid; use crate::api::error::MetricError; +use crate::domain::metric_definitions::error_code::{MetricSchemaErrorCode, SchemaStatus}; + use crate::domain::metric_definitions::definition::{ CountDistinctMetricDefinition, CountMetricDefinition, DerivedMetricDefinition, DistributionMetricDefinition, DistributionStatistic, GaugeMethod, GaugeMetricDefinition, MetricBase, MetricComputation, MetricDefinition, MetricDirection, MetricFormat, MetricInput, - MetricInputRole, ObservationSource, RatioMetricDefinition, SumMetricDefinition, + MetricInputRole, ObservationSource, RatioMetricDefinition, SourceKind, SumMetricDefinition, }; #[derive(Debug, FromQueryResult)] @@ -269,11 +271,16 @@ fn classify_inputs(rows: Vec) -> HashMap { // Corrupt config must stay loud regardless of row order: it wins // over Unavailable, which wins over Available. let role = MetricInputRole::from_db(&row.input_role); + let kind = SourceKind::from_db(&row.source_kind); let observation_source = ObservationSource::from_ref(&row.source_ref); - let parsed = match (role, observation_source) { - (Some(role), Some(observation_source)) if row.source_kind == "managed_observation" => { + let parsed = match (role, kind, observation_source) { + (Some(role), Some(SourceKind::ManagedObservation), Some(observation_source)) => { Some((role, observation_source)) } + (Some(_), Some(SourceKind::CustomObservationSql), _) => { + *entry = ClassifiedInputs::Unavailable; + continue; + } _ => None, }; let Some((role, observation_source)) = parsed else { @@ -292,8 +299,8 @@ fn classify_inputs(rows: Vec) -> HashMap { if !row.measure_enabled || !row.source_enabled - || row.measure_schema_status == "error" - || row.source_schema_status == "error" + || schema_status_blocks(&row.measure_schema_status) + || schema_status_blocks(&row.source_schema_status) { *entry = ClassifiedInputs::Unavailable; continue; @@ -311,6 +318,13 @@ fn classify_inputs(rows: Vec) -> HashMap { out } +fn schema_status_blocks(status: &str) -> bool { + !matches!( + SchemaStatus::from_db(status), + Some(SchemaStatus::Ok | SchemaStatus::Unchecked) + ) +} + fn group_by_key(rows: Vec) -> BTreeMap> { let mut grouped: BTreeMap> = BTreeMap::new(); for row in rows { @@ -353,7 +367,10 @@ fn select_available_row( inputs.get(&row.definition_id), Some(ClassifiedInputs::Unavailable) ); - if row.definition_enabled && row.definition_schema_status != "error" && inputs_available { + if row.definition_enabled + && !schema_status_blocks(&row.definition_schema_status) + && inputs_available + { return Ok(Some(row)); } } @@ -599,8 +616,8 @@ pub async fn all_managed_sources( pub async fn update_source_status( db: &DatabaseConnection, source_id: Uuid, - status: &str, - error_code: Option<&str>, + status: SchemaStatus, + error_code: Option, ) -> Result<(), sea_orm::DbErr> { db.execute(Statement::from_sql_and_values( db.get_database_backend(), @@ -611,9 +628,9 @@ pub async fn update_source_status( updated_at = updated_at \ WHERE id = ?", [ - Value::from(status), + Value::from(status.as_db()), match error_code { - Some(code) => Value::from(code), + Some(code) => Value::from(code.as_db()), None => Value::String(None), }, Value::Bytes(Some(Box::new(source_id.as_bytes().to_vec()))), @@ -626,8 +643,8 @@ pub async fn update_source_status( pub async fn update_definitions_for_source_status( db: &DatabaseConnection, source_id: Uuid, - status: &str, - error_code: Option<&str>, + status: SchemaStatus, + error_code: Option, ) -> Result<(), sea_orm::DbErr> { db.execute(Statement::from_sql_and_values( db.get_database_backend(), @@ -643,9 +660,9 @@ pub async fn update_definitions_for_source_status( WHERE m.source_id = ? \ )", [ - Value::from(status), + Value::from(status.as_db()), match error_code { - Some(code) => Value::from(code), + Some(code) => Value::from(code.as_db()), None => Value::String(None), }, Value::Bytes(Some(Box::new(source_id.as_bytes().to_vec()))), @@ -658,8 +675,8 @@ pub async fn update_definitions_for_source_status( pub async fn update_definition_status( db: &DatabaseConnection, definition_id: Uuid, - status: &str, - error_code: Option<&str>, + status: SchemaStatus, + error_code: Option, ) -> Result<(), sea_orm::DbErr> { db.execute(Statement::from_sql_and_values( db.get_database_backend(), @@ -670,9 +687,9 @@ pub async fn update_definition_status( updated_at = updated_at \ WHERE id = ?", [ - Value::from(status), + Value::from(status.as_db()), match error_code { - Some(code) => Value::from(code), + Some(code) => Value::from(code.as_db()), None => Value::String(None), }, uuid_value(definition_id), @@ -911,6 +928,18 @@ mod tests { )); } + #[test] + fn classify_marks_custom_sql_source_unavailable_not_corrupt() { + let id = Uuid::now_v7(); + let mut row = input_row(id, "value", true, "ok"); + row.source_kind = "custom_observation_sql".to_owned(); + let classified = classify_inputs(vec![row]); + assert!(matches!( + classified.get(&id), + Some(ClassifiedInputs::Unavailable) + )); + } + #[test] fn classify_marks_disabled_source_unavailable() { let id = Uuid::now_v7(); diff --git a/src/backend/services/analytics/src/domain/metric_definitions/validator.rs b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs index 04e9a3214..78fbbdb01 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/validator.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs @@ -4,8 +4,8 @@ use clickhouse::Row; use sea_orm::DatabaseConnection; use serde::Deserialize; -use crate::domain::metric_definitions::definition::{CohortSource, ObservationSource}; -use crate::domain::metric_definitions::error_code::MetricSchemaErrorCode; +use crate::domain::metric_definitions::definition::{CohortSource, ObservationSource, SourceKind}; +use crate::domain::metric_definitions::error_code::{MetricSchemaErrorCode, SchemaStatus}; use crate::domain::metric_definitions::repository::{ MetricDefinitionValidationSpec, all_managed_sources, managed_definition_validation_specs, update_definition_status, update_definitions_for_source_status, update_source_status, @@ -69,10 +69,14 @@ impl MetricDefinitionValidator { } async fn validate_source(&self, source_kind: &str, source_ref: &str) -> ProbeOutcome { - if source_kind != "managed_observation" { - return ProbeOutcome::Definitive(ValidationState::error( - MetricSchemaErrorCode::Unknown, - )); + match SourceKind::from_db(source_kind) { + Some(SourceKind::ManagedObservation) => {} + Some(SourceKind::CustomObservationSql) => return ProbeOutcome::Inconclusive, + None => { + return ProbeOutcome::Definitive(ValidationState::error( + MetricSchemaErrorCode::Unknown, + )); + } } let Some(source) = ObservationSource::from_ref(source_ref) else { @@ -512,11 +516,11 @@ impl ValidationState { matches!(self, Self::Ok) } - fn as_db(self) -> (&'static str, Option<&'static str>) { + fn as_db(self) -> (SchemaStatus, Option) { match self { - Self::Ok => ("ok", None), - Self::Error(code) => ("error", Some(code.as_db_str())), - Self::Unchecked => ("unchecked", None), + Self::Ok => (SchemaStatus::Ok, None), + Self::Error(code) => (SchemaStatus::Error, Some(code)), + Self::Unchecked => (SchemaStatus::Unchecked, None), } } } diff --git a/src/backend/services/analytics/src/domain/metric_results/builder.rs b/src/backend/services/analytics/src/domain/metric_results/builder.rs index 56d87412f..79168a6ba 100644 --- a/src/backend/services/analytics/src/domain/metric_results/builder.rs +++ b/src/backend/services/analytics/src/domain/metric_results/builder.rs @@ -6,7 +6,7 @@ use crate::domain::metric_definitions::{ExecutableMetric, MetricDefinition}; use super::compiler::{ BreakdownQueryRow, PeerQueryRow, PeriodQueryRow, TimeseriesQueryRow, UNKNOWN_DIMENSION_LABEL, - UNKNOWN_DIMENSION_VALUE, + UNKNOWN_DIMENSION_VALUE, dimension_aliases, }; use super::definition::Bucket; use super::dto::{ @@ -268,8 +268,7 @@ fn row_dimensions( .iter() .enumerate() .map(|(idx, key)| { - let value_alias = format!("dim_{idx}_value"); - let label_alias = format!("dim_{idx}_label"); + let (value_alias, label_alias) = dimension_aliases(idx); let value_field = extra.get(&value_alias).ok_or_else(|| { tracing::error!(alias = %value_alias, "metric result row missing dimension alias"); CanonicalError::internal("metric result shape mismatch").create() diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs index 0d15d9ad8..d512de807 100644 --- a/src/backend/services/analytics/src/domain/metric_results/compiler.rs +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -393,12 +393,15 @@ fn cohort_table(source: CohortSource) -> &'static str { } } +pub(crate) fn dimension_aliases(idx: usize) -> (String, String) { + (format!("dim_{idx}_value"), format!("dim_{idx}_label")) +} + fn dimension_select_group(dimensions: &[String]) -> (String, String) { let mut select = String::new(); let mut groups = Vec::with_capacity(dimensions.len() * 2); for (idx, dimension) in dimensions.iter().enumerate() { - let value_alias = format!("dim_{idx}_value"); - let label_alias = format!("dim_{idx}_label"); + let (value_alias, label_alias) = dimension_aliases(idx); let _ = write!( select, ", {value} AS {value_alias}, {label} AS {label_alias}", diff --git a/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs index 901e81508..dd2a38478 100644 --- a/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs +++ b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs @@ -174,7 +174,7 @@ mod tests { fn schema_error_check_lists_match_error_code_enum() { let expected = ALL_METRIC_SCHEMA_ERROR_CODES .iter() - .map(|code| format!("'{}'", code.as_db_str())) + .map(|code| format!("'{}'", code.as_db())) .collect::>() .join(","); let expected_clause = format!("schema_error_code IN ({expected})"); From 6a763b79488339d8efae63a637cc9f390878007f Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Mon, 6 Jul 2026 14:24:03 +0200 Subject: [PATCH 10/20] fix(analytics): keep corrupt input classification over custom-SQL rows A custom-SQL input row after a corrupt row downgraded the definition from corrupt (config error) to unavailable, silencing the loud failure the precedence lattice promises. The downgrade now respects corrupt precedence; regression test added. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- .../domain/metric_definitions/repository.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs index b50697073..596a4efbd 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs @@ -278,7 +278,9 @@ fn classify_inputs(rows: Vec) -> HashMap { Some((role, observation_source)) } (Some(_), Some(SourceKind::CustomObservationSql), _) => { - *entry = ClassifiedInputs::Unavailable; + if !matches!(entry, ClassifiedInputs::Corrupt) { + *entry = ClassifiedInputs::Unavailable; + } continue; } _ => None, @@ -928,6 +930,22 @@ mod tests { )); } + #[test] + fn classify_corrupt_is_not_downgraded_by_later_custom_sql_row() { + let id = Uuid::now_v7(); + let corrupt = InputRow { + input_role: "nonsense".to_owned(), + ..input_row(id, "value", true, "ok") + }; + let mut custom = input_row(id, "value", true, "ok"); + custom.source_kind = "custom_observation_sql".to_owned(); + let classified = classify_inputs(vec![corrupt, custom]); + assert!(matches!( + classified.get(&id), + Some(ClassifiedInputs::Corrupt) + )); + } + #[test] fn classify_marks_custom_sql_source_unavailable_not_corrupt() { let id = Uuid::now_v7(); From 3a8dbf639cf5aac64d0e22397a1a431bed5cf9b2 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Mon, 6 Jul 2026 15:17:02 +0200 Subject: [PATCH 11/20] refactor(analytics): close computation vocabulary to executable sum/ratio - collapse MetricDefinition to base + ComputationSpec; drop non-executable computation kinds, input roles, and their registry storage columns - flatten the metric result DTO; the computation tag carries only executable fields, pinned by a wire-shape test - emit observation rows only when a value exists (HAVING in sum_measure) and drop gold defensive dimension fallbacks in favor of class-contract guarantees enforced by silver schema tests - align design spec, schema docs, and authoring guide with the closed vocabulary and document the extension path Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/README.md | 2 +- docs/domain/metrics/specs/DESIGN.md | 126 +++++----- .../analytics/src/api/metric_results.rs | 14 +- .../src/domain/metric_definitions/builtin.rs | 112 ++------- .../domain/metric_definitions/definition.rs | 233 +++--------------- .../src/domain/metric_definitions/mod.rs | 4 +- .../domain/metric_definitions/repository.rs | 161 +++--------- .../src/domain/metric_definitions/seeds.rs | 43 ++-- .../domain/metric_definitions/validator.rs | 36 +-- .../src/domain/metric_results/builder.rs | 166 ++++--------- .../src/domain/metric_results/compiler.rs | 108 ++++---- .../src/domain/metric_results/dto.rs | 113 ++------- .../src/domain/metric_results/mod.rs | 2 +- .../src/domain/metric_results/validation.rs | 80 ++---- .../metric_results/{definition.rs => view.rs} | 0 .../m20260625_000001_metric_definitions.rs | 25 +- .../services/analytics/src/migration/mod.rs | 8 - .../macros/metric_observation_measures.sql | 7 +- src/ingestion/gold/ai_metric_observations.sql | 81 +++--- src/ingestion/gold/schema.yml | 17 +- src/ingestion/silver/ai/schema.yml | 22 +- 21 files changed, 402 insertions(+), 958 deletions(-) rename src/backend/services/analytics/src/domain/metric_results/{definition.rs => view.rs} (100%) diff --git a/docs/domain/metrics/README.md b/docs/domain/metrics/README.md index 5d9250234..226d6b815 100644 --- a/docs/domain/metrics/README.md +++ b/docs/domain/metrics/README.md @@ -46,7 +46,7 @@ either side knowing the other exists. | | Observation | Definition | Metric result | |---|---|---|---| | What | a fact | the meaning of facts | the computed answer | -| Lives | ClickHouse rows | registry (MariaDB, seeded from Rust) | nowhere — made per request | +| Lives | ClickHouse views over silver (computed on read, nothing stored) | registry (MariaDB, seeded from Rust) | nowhere — made per request | | Knows | what happened | what it is called, how to compute, how to show | both, combined | | Authored by | connector + gold model | one struct per metric | nobody — the runtime derives it | diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index c69fd060a..f8cda0ccf 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -45,9 +45,12 @@ Rules: - `source_key` identifies the logical source. - `measure_key` identifies the source measure. - `entity_type` and `entity_id` identify the measured entity. -- `observed_at` is available for gauge/latest semantics. -- `subject_key` is available for distinct-count semantics. -- Missing dimensions use value `__unknown__` and label `Unknown`. +- `observed_at` is reserved for future point-in-time semantics. +- `subject_key` is reserved for future distinct-count semantics. +- A row is emitted only when the source provides a value; `value` is never + NULL (the column stays nullable in the contract). +- Dimension values and labels come from class-contract columns declared by + staging models; gold does not synthesize fallbacks. - Observations do not contain chart metadata. - Observations do not contain cohort membership. Peer comparison reads the cohort view directly. @@ -72,35 +75,17 @@ asserts it. ## Computations -Executable computation vocabulary: +The computation vocabulary is closed and fully executable: ```text sum -count -count_distinct ratio -distribution -gauge -derived -``` - -Current execution support: - -```text -sum executable -ratio executable -others typed and stored, rejected with UNSUPPORTED_COMPUTATION when requested ``` Semantics: - `sum`: sum one numeric measure. -- `count`: count source rows for one event measure. -- `count_distinct`: count distinct `subject_key` values. - `ratio`: aggregate numerator and denominator measures first, then divide. -- `distribution`: compute one configured statistic from sample values. -- `gauge`: compute one configured snapshot method. -- `derived`: reserved for expressions over other metrics. Ratios use: @@ -111,8 +96,14 @@ sum(numerator) / nullIf(sum(denominator), 0) * scale They are not averages of row-level ratios. Ratio numerator and denominator inputs must resolve to measures of the same -source. Cross-source ratios are a configuration error; they belong to the -`derived` computation when it becomes executable. +source. Cross-source ratios are a configuration error. + +Extending the vocabulary (anticipated kinds: count-distinct over +`subject_key`, distribution statistics, point-in-time gauges over +`observed_at`, derived expressions over other metrics) is one coordinated +change: a `ComputationSpec` variant, a compiler arm, the `computation_type` +DB enum, a shape macro if the observation shape is new, and the response +`computation` tag. Nothing is stored before it executes. ## Storage Model @@ -148,11 +139,8 @@ direction entity_type computation_type scale -distribution_statistic -gauge_method peer_cohort_key origin -definition_version is_enabled schema_status schema_error_code @@ -162,12 +150,8 @@ schema_error_code ```text value -event numerator denominator -sample -snapshot -dependency ``` `metric_definition_dimensions` maps metrics to source dimensions. @@ -230,27 +214,23 @@ type MetricResultsRequest = { Response: ```ts -type MetricResult = - | { computation: "sum"; views: MetricResultView[] } - | { computation: "count"; views: MetricResultView[] } - | { computation: "count_distinct"; views: MetricResultView[] } - | { computation: "ratio"; scale: number; views: MetricResultView[] } - | { computation: "distribution"; statistic: string; views: MetricResultView[] } - | { computation: "gauge"; method: string; views: MetricResultView[] } - | { computation: "derived"; views: MetricResultView[] } +type MetricResult = { + metric_key: string + label: string + description?: string + explanation?: string + unit: string | null + format: "integer" | "decimal" | "currency" | "percent" + direction: "higher_is_better" | "lower_is_better" | "neutral" + views: MetricResultView[] +} & ( + | { computation: "sum" } + | { computation: "ratio"; scale: number } +) ``` -Every metric result also includes: - -```text -metric_key -label -description -explanation -unit -format -direction -``` +The computation tag and its fields are flattened into the result object; a +serde wire-shape test in `metric_results/builder.rs` pins this layout. View values use `entity_id`, not person-specific fields. @@ -260,12 +240,11 @@ View values use `entity_id`, not person-specific fields. 2. Validate entity, period, metric keys, view specs, and dimensions. 3. Load visible metric definitions from DB. 4. Convert DB rows into Rust discriminated unions. -5. Reject unsupported computations with `UNSUPPORTED_COMPUTATION`. -6. Compile one ClickHouse query per requested metric view. -7. Execute queries with bounded concurrency. -8. Shape rows into typed result views. -9. Enforce final response row cap. -10. Return metrics in request order. +5. Compile one ClickHouse query per requested metric view. +6. Execute queries with bounded concurrency. +7. Shape rows into typed result views. +8. Enforce final response row cap. +9. Return metrics in request order. Execution rules: @@ -273,6 +252,9 @@ Execution rules: - `ratio` missing or zero denominator returns `null`. - Ungrouped timeseries are dense per requested entity and bucket. - Dimensioned timeseries are dense per requested entity, observed dimension group, and bucket. +- Rows missing a requested dimension group under value `__unknown__` with + label `Unknown` (runtime guard; the schema validator's coverage probe makes + this rare). - Breakdown returns observed dimension groups only. - Peer starts from the generic current cohort view so zero-activity peers can be included. - Target entities missing cohort membership are omitted from peer values. @@ -303,7 +285,6 @@ Reject with a client error when: - a breakdown has no dimensions. - a peer view has no requested or default cohort key. - projected or final result size exceeds the row cap. -- computation is typed but not executable. ## Authorization @@ -364,23 +345,27 @@ The source exists but does not emit the measure yet. (staging models declare semantics, see the class `schema.yml`). 2. Add the `measure_key` to the gold model's `schema.yml` `accepted_values` test. -3. Add a `MeasureSeed` to the source in `builtin.rs`. +3. Add the measure key to the source's `measures` list in `builtin.rs`. 4. Add the `MetricSeed` as in case 1. -5. Validate: `dbt parse` (dummy profile) + `cargo test -p analytics`. +5. Validate: `dbt parse` + `cargo test -p analytics` (see Validation + commands). ### Case 3: new observation source The metric family reads data no managed source covers. 1. Create a dbt gold model in `src/ingestion/gold/` emitting the source - measure observation contract, `schema=insight`, `ref()`-ing silver models. + measure observation contract, `schema=insight`, `ref()`-ing silver models + (medallion layering rules: `docs/domain/ingestion-data-flow/specs/DESIGN.md`). Document columns and measure keys in `src/ingestion/gold/schema.yml`. 2. Add an `ObservationSource` enum variant and `from_ref`/`table_ref` mapping - in `src/backend/services/analytics/src/domain/metric_definitions/definition.rs`. + in `src/backend/services/analytics/src/domain/metric_definitions/definition.rs` + (the `db_strings_round_trip` test covers the new pair). 3. Add a `BuiltinSource` (source + measures + dimensions) to `builtin.rs`. 4. Add `MetricSeed`s as in case 1. -5. Validate: `dbt parse` + `cargo test -p analytics`. The runtime schema - validator probes the new relation at startup. +5. Validate: `dbt parse` + `cargo test -p analytics` (see Validation + commands). The runtime schema validator probes the new relation at + startup. ### Rules that hold for every case @@ -394,6 +379,21 @@ The metric family reads data no managed source covers. for metrics. - Do not add runtime formula JSON until generation exists. +### Validation commands + +```sh +# from src/backend — registry invariants, enum round-trips, compiler tests +cargo test -p analytics + +# from src/ingestion/dbt — manifest validation, no warehouse connection. +# CI runs the same gate (build-images.yml, toolbox job). +dbt parse --profiles-dir +``` + +The dummy profile is a `profiles.yml` with profile name `ingestion` and any +unreachable `type: clickhouse` output; `dbt parse` loads the adapter but never +connects. + Future developer-side generation may use source models and formulas to produce the managed observation SQL and seed rows, but runtime execution still consumes typed definitions and source measure observations. diff --git a/src/backend/services/analytics/src/api/metric_results.rs b/src/backend/services/analytics/src/api/metric_results.rs index 303ba2bc1..bdbe7fb86 100644 --- a/src/backend/services/analytics/src/api/metric_results.rs +++ b/src/backend/services/analytics/src/api/metric_results.rs @@ -8,7 +8,7 @@ use serde::de::DeserializeOwned; use toolkit_canonical_errors::CanonicalError; use super::AppState; -use crate::domain::metric_definitions::ExecutableMetric; +use crate::domain::metric_definitions::MetricDefinition; use crate::domain::metric_results::{ BreakdownQueryRow, CompiledQuery, MetricResultViewDto, MetricResultsRequest, MetricResultsResponse, PeerQueryRow, PeriodQueryRow, TimeseriesQueryRow, @@ -81,7 +81,7 @@ fn warehouse_tenant_id(state: &AppState, ctx: &SecurityContext) -> String { struct MetricViewTask { metric_index: usize, view_index: usize, - exec: ExecutableMetric, + def: MetricDefinition, view: ValidatedMetricView, query: CompiledQuery, } @@ -104,9 +104,9 @@ fn compile_tasks(req: &ValidatedMetricResultsRequest, tenant_id: &str) -> Vec { let rows = fetch_rows::(state, query).await?; - build_period_view(&exec, req, rows) + build_period_view(&def, req, rows) } ValidatedMetricView::Peer { .. } => { let rows = fetch_rows::(state, query).await?; @@ -136,7 +136,7 @@ async fn execute_task( } ValidatedMetricView::Timeseries { bucket, dimensions } => { let rows = fetch_rows::(state, query).await?; - build_timeseries_view(&exec, req, bucket, &dimensions, rows)? + build_timeseries_view(&def, req, bucket, &dimensions, rows)? } ValidatedMetricView::Breakdown { dimensions } => { let rows = fetch_rows::(state, query).await?; diff --git a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs index a569fc2c6..db5a98e71 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs @@ -3,19 +3,6 @@ use crate::domain::metric_definitions::definition::{ SourceKind, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MeasureValueType { - Number, -} - -impl MeasureValueType { - pub fn as_db(self) -> &'static str { - match self { - Self::Number => "number", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntityType { Person, @@ -70,20 +57,10 @@ pub struct SourceSeed { pub source_ref: ObservationSource, } -pub struct MeasureSeed { - pub measure_key: &'static str, - pub value_type: MeasureValueType, -} - -pub struct DimensionSeed { - pub dimension_key: &'static str, - pub label: &'static str, -} - pub struct BuiltinSource { pub source: SourceSeed, - pub measures: &'static [MeasureSeed], - pub dimensions: &'static [DimensionSeed], + pub measures: &'static [&'static str], + pub dimensions: &'static [&'static str], } pub struct MetricSeed { @@ -114,57 +91,18 @@ pub const BUILTIN_SOURCES: &[BuiltinSource] = &[BuiltinSource { source_ref: ObservationSource::AiMetricObservations, }, measures: &[ - MeasureSeed { - measure_key: "accepted_lines", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "removed_lines", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "active_day", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "cost_usd", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "accepted_edit_actions", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "tool_use_offered", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "assistant_messages", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "assistant_actions", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "dev_conversations", - value_type: MeasureValueType::Number, - }, - MeasureSeed { - measure_key: "chat_assistant_conversations", - value_type: MeasureValueType::Number, - }, - ], - dimensions: &[ - DimensionSeed { - dimension_key: "tool", - label: "Tool", - }, - DimensionSeed { - dimension_key: "surface", - label: "Surface", - }, + "accepted_lines", + "removed_lines", + "active_day", + "cost_usd", + "accepted_edit_actions", + "tool_use_offered", + "assistant_messages", + "assistant_actions", + "dev_conversations", + "chat_assistant_conversations", ], + dimensions: &["tool", "surface"], }]; pub const BUILTIN_METRICS: &[MetricSeed] = &[ @@ -398,14 +336,14 @@ mod tests { fn measure_and_dimension_keys_are_unique_per_source() { for builtin_source in BUILTIN_SOURCES { let mut measures = BTreeSet::new(); - for measure in builtin_source.measures { - assert!(is_snake_case(measure.measure_key)); - assert!(measures.insert(measure.measure_key)); + for measure_key in builtin_source.measures { + assert!(is_snake_case(measure_key)); + assert!(measures.insert(*measure_key)); } let mut dimensions = BTreeSet::new(); - for dimension in builtin_source.dimensions { - assert!(is_snake_case(dimension.dimension_key)); - assert!(dimensions.insert(dimension.dimension_key)); + for dimension_key in builtin_source.dimensions { + assert!(is_snake_case(dimension_key)); + assert!(dimensions.insert(*dimension_key)); } } } @@ -426,11 +364,7 @@ mod tests { .map(|builtin_source| { ( builtin_source.source.key, - builtin_source - .measures - .iter() - .map(|measure| measure.measure_key) - .collect(), + builtin_source.measures.iter().copied().collect(), ) }) .collect(); @@ -458,11 +392,7 @@ mod tests { .map(|builtin_source| { ( builtin_source.source.key, - builtin_source - .dimensions - .iter() - .map(|dimension| dimension.dimension_key) - .collect(), + builtin_source.dimensions.iter().copied().collect(), ) }) .collect(); diff --git a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs index e606b365b..ac9443930 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/definition.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/definition.rs @@ -21,44 +21,15 @@ pub enum MetricFormat { #[serde(rename_all = "snake_case")] pub enum MetricComputation { Sum, - Count, - CountDistinct, Ratio, - Distribution, - Gauge, - Derived, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum MetricInputRole { Value, - Event, Numerator, Denominator, - Sample, - Snapshot, - Dependency, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum DistributionStatistic { - P50, - P75, - P90, - P95, - P99, - Avg, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GaugeMethod { - Latest, - Min, - Max, - Avg, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -95,14 +66,9 @@ pub enum CohortSource { } #[derive(Debug, Clone, PartialEq)] -pub enum MetricDefinition { - Sum(SumMetricDefinition), - Count(CountMetricDefinition), - CountDistinct(CountDistinctMetricDefinition), - Ratio(RatioMetricDefinition), - Distribution(DistributionMetricDefinition), - Gauge(GaugeMetricDefinition), - Derived(DerivedMetricDefinition), +pub struct MetricDefinition { + pub base: MetricBase, + pub spec: ComputationSpec, } #[derive(Debug, Clone, PartialEq)] @@ -119,6 +85,18 @@ pub struct MetricBase { pub allowed_dimensions: Vec, } +#[derive(Debug, Clone, PartialEq)] +pub enum ComputationSpec { + Sum { + value: MetricInput, + }, + Ratio { + numerator: MetricInput, + denominator: MetricInput, + scale: f64, + }, +} + #[derive(Debug, Clone, PartialEq)] pub struct MetricInput { pub role: MetricInputRole, @@ -127,117 +105,27 @@ pub struct MetricInput { pub measure_key: String, } -#[derive(Debug, Clone, PartialEq)] -pub struct SumMetricDefinition { - pub base: MetricBase, - pub value: MetricInput, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CountMetricDefinition { - pub base: MetricBase, - pub event: MetricInput, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CountDistinctMetricDefinition { - pub base: MetricBase, - pub event: MetricInput, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct RatioMetricDefinition { - pub base: MetricBase, - pub numerator: MetricInput, - pub denominator: MetricInput, - pub scale: f64, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct DistributionMetricDefinition { - pub base: MetricBase, - pub sample: MetricInput, - pub statistic: DistributionStatistic, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct GaugeMetricDefinition { - pub base: MetricBase, - pub snapshot: MetricInput, - pub method: GaugeMethod, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct DerivedMetricDefinition { - pub base: MetricBase, - pub dependencies: Vec, -} - impl MetricDefinition { pub fn key(&self) -> &str { - self.base().key.as_str() - } - - pub fn base(&self) -> &MetricBase { - match self { - Self::Sum(def) => &def.base, - Self::Count(def) => &def.base, - Self::CountDistinct(def) => &def.base, - Self::Ratio(def) => &def.base, - Self::Distribution(def) => &def.base, - Self::Gauge(def) => &def.base, - Self::Derived(def) => &def.base, - } - } - - pub fn computation(&self) -> MetricComputation { - match self { - Self::Sum(_) => MetricComputation::Sum, - Self::Count(_) => MetricComputation::Count, - Self::CountDistinct(_) => MetricComputation::CountDistinct, - Self::Ratio(_) => MetricComputation::Ratio, - Self::Distribution(_) => MetricComputation::Distribution, - Self::Gauge(_) => MetricComputation::Gauge, - Self::Derived(_) => MetricComputation::Derived, - } + self.base.key.as_str() } pub fn allowed_dimension(&self, dimension: &str) -> Option<&str> { - self.base() + self.base .allowed_dimensions .iter() .map(String::as_str) .find(|d| *d == dimension) } - pub fn executable(&self) -> Option { - match self { - Self::Sum(def) => Some(ExecutableMetric::Sum(def.clone())), - Self::Ratio(def) => Some(ExecutableMetric::Ratio(def.clone())), - Self::Count(_) - | Self::CountDistinct(_) - | Self::Distribution(_) - | Self::Gauge(_) - | Self::Derived(_) => None, - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ExecutableMetric { - Sum(SumMetricDefinition), - Ratio(RatioMetricDefinition), -} - -impl ExecutableMetric { pub fn is_zero_filled(&self) -> bool { - matches!(self, Self::Sum(_)) + matches!(self.spec, ComputationSpec::Sum { .. }) } pub fn observation_source(&self) -> ObservationSource { - match self { - Self::Sum(def) => def.value.observation_source, - Self::Ratio(def) => def.numerator.observation_source, + match &self.spec { + ComputationSpec::Sum { value } => value.observation_source, + ComputationSpec::Ratio { numerator, .. } => numerator.observation_source, } } } @@ -312,80 +200,36 @@ impl MetricDirection { } impl MetricComputation { + pub fn as_db(self) -> &'static str { + match self { + Self::Sum => "sum", + Self::Ratio => "ratio", + } + } + pub fn from_db(value: &str) -> Option { match value { "sum" => Some(Self::Sum), - "count" => Some(Self::Count), - "count_distinct" => Some(Self::CountDistinct), "ratio" => Some(Self::Ratio), - "distribution" => Some(Self::Distribution), - "gauge" => Some(Self::Gauge), - "derived" => Some(Self::Derived), _ => None, } } - - pub fn as_db(self) -> &'static str { - match self { - Self::Sum => "sum", - Self::Count => "count", - Self::CountDistinct => "count_distinct", - Self::Ratio => "ratio", - Self::Distribution => "distribution", - Self::Gauge => "gauge", - Self::Derived => "derived", - } - } } impl MetricInputRole { pub fn as_db(self) -> &'static str { match self { Self::Value => "value", - Self::Event => "event", Self::Numerator => "numerator", Self::Denominator => "denominator", - Self::Sample => "sample", - Self::Snapshot => "snapshot", - Self::Dependency => "dependency", } } pub fn from_db(value: &str) -> Option { match value { "value" => Some(Self::Value), - "event" => Some(Self::Event), "numerator" => Some(Self::Numerator), "denominator" => Some(Self::Denominator), - "sample" => Some(Self::Sample), - "snapshot" => Some(Self::Snapshot), - "dependency" => Some(Self::Dependency), - _ => None, - } - } -} - -impl DistributionStatistic { - pub fn from_db(value: &str) -> Option { - match value { - "p50" => Some(Self::P50), - "p75" => Some(Self::P75), - "p90" => Some(Self::P90), - "p95" => Some(Self::P95), - "p99" => Some(Self::P99), - "avg" => Some(Self::Avg), - _ => None, - } - } -} - -impl GaugeMethod { - pub fn from_db(value: &str) -> Option { - match value { - "latest" => Some(Self::Latest), - "min" => Some(Self::Min), - "max" => Some(Self::Max), - "avg" => Some(Self::Avg), _ => None, } } @@ -412,23 +256,24 @@ mod tests { ] { assert_eq!(MetricDirection::from_db(direction.as_db()), Some(direction)); } + for computation in [MetricComputation::Sum, MetricComputation::Ratio] { + assert_eq!( + MetricComputation::from_db(computation.as_db()), + Some(computation) + ); + } for role in [ MetricInputRole::Value, - MetricInputRole::Event, MetricInputRole::Numerator, MetricInputRole::Denominator, - MetricInputRole::Sample, - MetricInputRole::Snapshot, - MetricInputRole::Dependency, ] { assert_eq!(MetricInputRole::from_db(role.as_db()), Some(role)); } - for source in [ObservationSource::AiMetricObservations] { - assert_eq!( - ObservationSource::from_ref(source.source_ref()), - Some(source) - ); - } + let source = ObservationSource::AiMetricObservations; + assert_eq!( + ObservationSource::from_ref(source.source_ref()), + Some(source) + ); for kind in [ SourceKind::ManagedObservation, SourceKind::CustomObservationSql, diff --git a/src/backend/services/analytics/src/domain/metric_definitions/mod.rs b/src/backend/services/analytics/src/domain/metric_definitions/mod.rs index 592f61a44..14bdc8f92 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/mod.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/mod.rs @@ -6,8 +6,8 @@ mod seeds; pub mod validator; pub use definition::{ - CohortSource, DistributionStatistic, ExecutableMetric, GaugeMethod, MetricDefinition, - MetricDirection, MetricFormat, ObservationSource, + CohortSource, ComputationSpec, MetricDefinition, MetricDirection, MetricFormat, + ObservationSource, }; pub use repository::load_definitions; pub use seeds::reconcile_builtin_definitions; diff --git a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs index 596a4efbd..1765488d9 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs @@ -8,10 +8,8 @@ use crate::api::error::MetricError; use crate::domain::metric_definitions::error_code::{MetricSchemaErrorCode, SchemaStatus}; use crate::domain::metric_definitions::definition::{ - CountDistinctMetricDefinition, CountMetricDefinition, DerivedMetricDefinition, - DistributionMetricDefinition, DistributionStatistic, GaugeMethod, GaugeMetricDefinition, - MetricBase, MetricComputation, MetricDefinition, MetricDirection, MetricFormat, MetricInput, - MetricInputRole, ObservationSource, RatioMetricDefinition, SourceKind, SumMetricDefinition, + ComputationSpec, MetricBase, MetricComputation, MetricDefinition, MetricDirection, + MetricFormat, MetricInput, MetricInputRole, ObservationSource, SourceKind, }; #[derive(Debug, FromQueryResult)] @@ -28,8 +26,6 @@ struct DefinitionRow { entity_type: String, computation_type: String, scale: Option, - distribution_statistic: Option, - gauge_method: Option, peer_cohort_key: Option, definition_enabled: bool, definition_schema_status: String, @@ -105,9 +101,9 @@ pub async fn load_definitions( continue; }; let definition_id = row.definition_id; - let row_inputs = match inputs.get(&definition_id) { - Some(ClassifiedInputs::Available(row_inputs)) => row_inputs.clone(), - Some(ClassifiedInputs::Unavailable | ClassifiedInputs::Corrupt) | None => Vec::new(), + let row_inputs: &[MetricInput] = match inputs.get(&definition_id) { + Some(ClassifiedInputs::Available(row_inputs)) => row_inputs, + Some(ClassifiedInputs::Unavailable | ClassifiedInputs::Corrupt) | None => &[], }; let definition = build_definition( &row, @@ -198,8 +194,6 @@ async fn fetch_definition_rows( d.entity_type AS entity_type, \ d.computation_type AS computation_type, \ CAST(d.scale AS DOUBLE) AS scale, \ - d.distribution_statistic AS distribution_statistic, \ - d.gauge_method AS gauge_method, \ d.peer_cohort_key AS peer_cohort_key, \ d.is_enabled AS definition_enabled, \ d.schema_status AS definition_schema_status \ @@ -422,7 +416,7 @@ async fn fetch_dimensions( fn build_definition( row: &DefinitionRow, - inputs: Vec, + inputs: &[MetricInput], allowed_dimensions: Vec, ) -> Result { let computation = MetricComputation::from_db(&row.computation_type).ok_or_else(|| { @@ -433,26 +427,33 @@ fn build_definition( })?; let base = build_base(row, allowed_dimensions)?; - match computation { - MetricComputation::Sum => Ok(MetricDefinition::Sum(SumMetricDefinition { - base, - value: one_input(&row.metric_key, &inputs, MetricInputRole::Value)?, - })), - MetricComputation::Count => Ok(MetricDefinition::Count(CountMetricDefinition { - base, - event: one_input(&row.metric_key, &inputs, MetricInputRole::Event)?, - })), - MetricComputation::CountDistinct => Ok(MetricDefinition::CountDistinct( - CountDistinctMetricDefinition { - base, - event: one_input(&row.metric_key, &inputs, MetricInputRole::Event)?, - }, - )), - MetricComputation::Ratio => build_ratio_definition(base, row, &inputs), - MetricComputation::Distribution => build_distribution_definition(base, row, &inputs), - MetricComputation::Gauge => build_gauge_definition(base, row, &inputs), - MetricComputation::Derived => build_derived_definition(base, &row.metric_key, inputs), - } + let spec = match computation { + MetricComputation::Sum => ComputationSpec::Sum { + value: one_input(&row.metric_key, inputs, MetricInputRole::Value)?, + }, + MetricComputation::Ratio => { + let numerator = one_input(&row.metric_key, inputs, MetricInputRole::Numerator)?; + let denominator = one_input(&row.metric_key, inputs, MetricInputRole::Denominator)?; + if numerator.observation_source != denominator.observation_source + || numerator.source_key != denominator.source_key + { + return Err(config_error(&format!( + "ratio inputs must share one source for {}", + row.metric_key + ))); + } + let scale = row.scale.ok_or_else(|| { + config_error(&format!("missing ratio scale for {}", row.metric_key)) + })?; + ComputationSpec::Ratio { + numerator, + denominator, + scale, + } + } + }; + + Ok(MetricDefinition { base, spec }) } fn build_base( @@ -478,97 +479,6 @@ fn build_base( }) } -fn build_ratio_definition( - base: MetricBase, - row: &DefinitionRow, - inputs: &[MetricInput], -) -> Result { - let numerator = one_input(&row.metric_key, inputs, MetricInputRole::Numerator)?; - let denominator = one_input(&row.metric_key, inputs, MetricInputRole::Denominator)?; - if numerator.observation_source != denominator.observation_source - || numerator.source_key != denominator.source_key - { - return Err(config_error(&format!( - "ratio inputs must share one source for {}", - row.metric_key - ))); - } - let scale = row - .scale - .ok_or_else(|| config_error(&format!("missing ratio scale for {}", row.metric_key)))?; - - Ok(MetricDefinition::Ratio(RatioMetricDefinition { - base, - numerator, - denominator, - scale, - })) -} - -fn build_distribution_definition( - base: MetricBase, - row: &DefinitionRow, - inputs: &[MetricInput], -) -> Result { - let statistic = row - .distribution_statistic - .as_deref() - .and_then(DistributionStatistic::from_db) - .ok_or_else(|| { - config_error(&format!( - "missing distribution statistic for {}", - row.metric_key - )) - })?; - - Ok(MetricDefinition::Distribution( - DistributionMetricDefinition { - base, - sample: one_input(&row.metric_key, inputs, MetricInputRole::Sample)?, - statistic, - }, - )) -} - -fn build_gauge_definition( - base: MetricBase, - row: &DefinitionRow, - inputs: &[MetricInput], -) -> Result { - let method = row - .gauge_method - .as_deref() - .and_then(GaugeMethod::from_db) - .ok_or_else(|| config_error(&format!("missing gauge method for {}", row.metric_key)))?; - - Ok(MetricDefinition::Gauge(GaugeMetricDefinition { - base, - snapshot: one_input(&row.metric_key, inputs, MetricInputRole::Snapshot)?, - method, - })) -} - -fn build_derived_definition( - base: MetricBase, - metric_key: &str, - inputs: Vec, -) -> Result { - let dependencies = inputs - .into_iter() - .filter(|input| input.role == MetricInputRole::Dependency) - .collect::>(); - if dependencies.is_empty() { - return Err(config_error(&format!( - "missing derived dependencies for {metric_key}" - ))); - } - - Ok(MetricDefinition::Derived(DerivedMetricDefinition { - base, - dependencies, - })) -} - fn one_input( metric_key: &str, inputs: &[MetricInput], @@ -615,6 +525,9 @@ pub async fn all_managed_sources( }) } +// `updated_at = updated_at` in the status writers below pins the column so +// ON UPDATE CURRENT_TIMESTAMP(3) does not fire: updated_at tracks config +// edits, not validator sweeps. pub async fn update_source_status( db: &DatabaseConnection, source_id: Uuid, @@ -748,8 +661,6 @@ mod tests { entity_type: "person".to_owned(), computation_type: "sum".to_owned(), scale: None, - distribution_statistic: None, - gauge_method: None, peer_cohort_key: Some("org_unit".to_owned()), definition_enabled: enabled, definition_schema_status: schema_status.to_owned(), diff --git a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs index 283d50fe5..55e3e6505 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs @@ -29,39 +29,35 @@ async fn reconcile_source( upsert_source(db, builtin_source).await?; let source_id = fetch_source_id(db, builtin_source.source.key).await?; - for measure in builtin_source.measures { + for measure_key in builtin_source.measures { db.execute(Statement::from_sql_and_values( db.get_database_backend(), "INSERT INTO metric_source_measures \ - (id, source_id, measure_key, value_type, is_enabled) \ - VALUES (?, ?, ?, ?, TRUE) \ + (id, source_id, measure_key, is_enabled) \ + VALUES (?, ?, ?, TRUE) \ ON DUPLICATE KEY UPDATE \ - value_type = VALUES(value_type), \ is_enabled = VALUES(is_enabled)", [ uuid_value(Uuid::now_v7()), uuid_value(source_id), - Value::from(measure.measure_key), - Value::from(measure.value_type.as_db()), + Value::from(*measure_key), ], )) .await?; } - for (idx, dimension) in builtin_source.dimensions.iter().enumerate() { + for (idx, dimension_key) in builtin_source.dimensions.iter().enumerate() { db.execute(Statement::from_sql_and_values( db.get_database_backend(), "INSERT INTO metric_source_dimensions \ - (id, source_id, dimension_key, label, display_order) \ - VALUES (?, ?, ?, ?, ?) \ + (id, source_id, dimension_key, display_order) \ + VALUES (?, ?, ?, ?) \ ON DUPLICATE KEY UPDATE \ - label = VALUES(label), \ display_order = VALUES(display_order)", [ uuid_value(Uuid::now_v7()), uuid_value(source_id), - Value::from(dimension.dimension_key), - Value::from(dimension.label), + Value::from(*dimension_key), Value::from(order_value(idx)), ], )) @@ -101,9 +97,8 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( db.get_database_backend(), "INSERT INTO metric_definitions \ (id, tenant_id, metric_key, label, description, explanation, unit, format, direction, entity_type, \ - computation_type, scale, distribution_statistic, gauge_method, peer_cohort_key, \ - origin, definition_version, is_enabled) \ - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'builtin', 1, TRUE) \ + computation_type, scale, peer_cohort_key, origin, is_enabled) \ + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'builtin', TRUE) \ ON DUPLICATE KEY UPDATE \ label = VALUES(label), \ description = VALUES(description), \ @@ -114,11 +109,8 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( entity_type = VALUES(entity_type), \ computation_type = VALUES(computation_type), \ scale = VALUES(scale), \ - distribution_statistic = VALUES(distribution_statistic), \ - gauge_method = VALUES(gauge_method), \ peer_cohort_key = VALUES(peer_cohort_key), \ origin = VALUES(origin), \ - definition_version = VALUES(definition_version), \ is_enabled = VALUES(is_enabled)", [ uuid_value(Uuid::now_v7()), @@ -135,8 +127,6 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( Some(scale) => Value::from(scale), None => Value::Double(None), }, - Value::String(None), - Value::String(None), nullable_str(metric.peer_cohort_key.map(CohortKey::as_db)), ], )) @@ -157,19 +147,18 @@ async fn replace_inputs( )) .await?; - for (idx, input) in inputs.iter().enumerate() { + for input in inputs { let measure_id = fetch_measure_id(db, source_id, input.measure_key).await?; db.execute(Statement::from_sql_and_values( db.get_database_backend(), "INSERT INTO metric_definition_inputs \ - (id, metric_definition_id, input_role, source_measure_id, display_order) \ - VALUES (?, ?, ?, ?, ?)", + (id, metric_definition_id, input_role, source_measure_id) \ + VALUES (?, ?, ?, ?)", [ uuid_value(Uuid::now_v7()), uuid_value(metric_id), Value::from(input.input_role.as_db()), uuid_value(measure_id), - Value::from(order_value(idx)), ], )) .await?; @@ -238,11 +227,7 @@ async fn disable_missing_builtin_rows(db: &DatabaseConnection) -> Result<(), DbE for builtin_source in BUILTIN_SOURCES { let source_id = fetch_source_id(db, builtin_source.source.key).await?; - let measure_keys = builtin_source - .measures - .iter() - .map(|measure| measure.measure_key) - .collect::>(); + let measure_keys = builtin_source.measures.to_vec(); let placeholders = vec!["?"; measure_keys.len()].join(", "); let sql = format!( "UPDATE metric_source_measures SET is_enabled = FALSE \ diff --git a/src/backend/services/analytics/src/domain/metric_definitions/validator.rs b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs index 78fbbdb01..823cf1dfe 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/validator.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs @@ -73,14 +73,14 @@ impl MetricDefinitionValidator { Some(SourceKind::ManagedObservation) => {} Some(SourceKind::CustomObservationSql) => return ProbeOutcome::Inconclusive, None => { - return ProbeOutcome::Definitive(ValidationState::error( + return ProbeOutcome::Definitive(ValidationState::Error( MetricSchemaErrorCode::Unknown, )); } } let Some(source) = ObservationSource::from_ref(source_ref) else { - return ProbeOutcome::Definitive(ValidationState::error( + return ProbeOutcome::Definitive(ValidationState::Error( MetricSchemaErrorCode::Unknown, )); }; @@ -92,7 +92,7 @@ impl MetricDefinitionValidator { { Ok(ColumnCheck::Present) => {} Ok(missing) => { - return ProbeOutcome::Definitive(ValidationState::error(missing.error_code())); + return ProbeOutcome::Definitive(ValidationState::Error(missing.error_code())); } Err(error) => { tracing::warn!(error = %error, "metric observation source validation failed"); @@ -101,8 +101,8 @@ impl MetricDefinitionValidator { } match self.has_columns(cohort.table_ref(), COHORT_COLUMNS).await { - Ok(ColumnCheck::Present) => ProbeOutcome::Definitive(ValidationState::ok()), - Ok(missing) => ProbeOutcome::Definitive(ValidationState::error(missing.error_code())), + Ok(ColumnCheck::Present) => ProbeOutcome::Definitive(ValidationState::Ok), + Ok(missing) => ProbeOutcome::Definitive(ValidationState::Error(missing.error_code())), Err(error) => { tracing::warn!(error = %error, "metric cohort source validation failed"); ProbeOutcome::Inconclusive @@ -158,7 +158,7 @@ impl MetricDefinitionValidator { .filter(|input| input.observation_source == source) .collect::>(); if inputs.is_empty() { - return ProbeOutcome::Definitive(ValidationState::error( + return ProbeOutcome::Definitive(ValidationState::Error( MetricSchemaErrorCode::Unknown, )); } @@ -170,12 +170,12 @@ impl MetricDefinitionValidator { .into_iter() .collect::>(); let Some(source_key) = source_keys.first().copied() else { - return ProbeOutcome::Definitive(ValidationState::error( + return ProbeOutcome::Definitive(ValidationState::Error( MetricSchemaErrorCode::Unknown, )); }; if source_keys.len() != 1 { - return ProbeOutcome::Definitive(ValidationState::error( + return ProbeOutcome::Definitive(ValidationState::Error( MetricSchemaErrorCode::Unknown, )); } @@ -190,7 +190,7 @@ impl MetricDefinitionValidator { .await { Ok(true) => {} - Ok(false) => return ProbeOutcome::Definitive(ValidationState::unchecked()), + Ok(false) => return ProbeOutcome::Definitive(ValidationState::Unchecked), Err(error) => { tracing::warn!(error = %error, "metric observation row probe failed"); return ProbeOutcome::Inconclusive; @@ -241,10 +241,10 @@ impl MetricDefinitionValidator { unobserved = ?unobserved, "declared measures without recent observations; definition stays unchecked" ); - return ProbeOutcome::Definitive(ValidationState::unchecked()); + return ProbeOutcome::Definitive(ValidationState::Unchecked); } - ProbeOutcome::Definitive(ValidationState::ok()) + ProbeOutcome::Definitive(ValidationState::Ok) } async fn check_dimension_coverage( @@ -270,7 +270,7 @@ impl MetricDefinitionValidator { { Ok(true) => {} Ok(false) => { - return Some(ProbeOutcome::Definitive(ValidationState::error( + return Some(ProbeOutcome::Definitive(ValidationState::Error( MetricSchemaErrorCode::DimensionNotCovered, ))); } @@ -500,18 +500,6 @@ enum ValidationState { } impl ValidationState { - fn ok() -> Self { - Self::Ok - } - - fn error(code: MetricSchemaErrorCode) -> Self { - Self::Error(code) - } - - fn unchecked() -> Self { - Self::Unchecked - } - fn is_ok(self) -> bool { matches!(self, Self::Ok) } diff --git a/src/backend/services/analytics/src/domain/metric_results/builder.rs b/src/backend/services/analytics/src/domain/metric_results/builder.rs index 79168a6ba..a59eb9626 100644 --- a/src/backend/services/analytics/src/domain/metric_results/builder.rs +++ b/src/backend/services/analytics/src/domain/metric_results/builder.rs @@ -2,27 +2,27 @@ use std::collections::{BTreeMap, HashMap}; use toolkit_canonical_errors::CanonicalError; -use crate::domain::metric_definitions::{ExecutableMetric, MetricDefinition}; +use crate::domain::metric_definitions::{ComputationSpec, MetricDefinition}; use super::compiler::{ BreakdownQueryRow, PeerQueryRow, PeriodQueryRow, TimeseriesQueryRow, UNKNOWN_DIMENSION_LABEL, UNKNOWN_DIMENSION_VALUE, dimension_aliases, }; -use super::definition::Bucket; use super::dto::{ - BreakdownValueDto, MetricDimensionDto, MetricResultDto, MetricResultViewDto, + BreakdownValueDto, ComputationDto, MetricDimensionDto, MetricResultDto, MetricResultViewDto, MetricResultsResponse, PeerValueDto, PeriodValueDto, TimeseriesDto, TimeseriesPointDto, }; use super::validation::{ ValidatedMetricResultsRequest, enumerate_buckets, metric_result_too_large, row_limit, }; +use super::view::Bucket; type DimensionKey = Vec<(String, String, Option)>; type SeriesKey = (String, DimensionKey); type PointsByBucket = HashMap>; pub fn build_period_view( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, rows: Vec, ) -> MetricResultViewDto { @@ -52,7 +52,7 @@ pub fn build_period_view( } pub fn build_timeseries_view( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, bucket: Bucket, dimensions: &[String], @@ -72,7 +72,7 @@ pub fn build_timeseries_view( for row in rows { let dims = row_dimensions(&row.extra, dimensions)?; by_series - .entry((row.entity_id, dimension_key(&dims))) + .entry((row.entity_id, dims.clone())) .or_default() .insert(row.bucket_start, row.value); } @@ -152,80 +152,20 @@ pub fn build_metric_result( def: &MetricDefinition, views: Vec, ) -> MetricResultDto { - match def { - MetricDefinition::Sum(sum) => MetricResultDto::Sum { - metric_key: sum.base.key.clone(), - label: sum.base.label.clone(), - description: sum.base.description.clone(), - explanation: sum.base.explanation.clone(), - unit: sum.base.unit.clone(), - format: sum.base.format, - direction: sum.base.direction, - views, - }, - MetricDefinition::Count(count) => MetricResultDto::Count { - metric_key: count.base.key.clone(), - label: count.base.label.clone(), - description: count.base.description.clone(), - explanation: count.base.explanation.clone(), - unit: count.base.unit.clone(), - format: count.base.format, - direction: count.base.direction, - views, - }, - MetricDefinition::CountDistinct(count) => MetricResultDto::CountDistinct { - metric_key: count.base.key.clone(), - label: count.base.label.clone(), - description: count.base.description.clone(), - explanation: count.base.explanation.clone(), - unit: count.base.unit.clone(), - format: count.base.format, - direction: count.base.direction, - views, - }, - MetricDefinition::Ratio(ratio) => MetricResultDto::Ratio { - metric_key: ratio.base.key.clone(), - label: ratio.base.label.clone(), - description: ratio.base.description.clone(), - explanation: ratio.base.explanation.clone(), - unit: ratio.base.unit.clone(), - format: ratio.base.format, - direction: ratio.base.direction, - scale: ratio.scale, - views, - }, - MetricDefinition::Distribution(distribution) => MetricResultDto::Distribution { - metric_key: distribution.base.key.clone(), - label: distribution.base.label.clone(), - description: distribution.base.description.clone(), - explanation: distribution.base.explanation.clone(), - unit: distribution.base.unit.clone(), - format: distribution.base.format, - direction: distribution.base.direction, - statistic: distribution.statistic, - views, - }, - MetricDefinition::Gauge(gauge) => MetricResultDto::Gauge { - metric_key: gauge.base.key.clone(), - label: gauge.base.label.clone(), - description: gauge.base.description.clone(), - explanation: gauge.base.explanation.clone(), - unit: gauge.base.unit.clone(), - format: gauge.base.format, - direction: gauge.base.direction, - method: gauge.method, - views, - }, - MetricDefinition::Derived(derived) => MetricResultDto::Derived { - metric_key: derived.base.key.clone(), - label: derived.base.label.clone(), - description: derived.base.description.clone(), - explanation: derived.base.explanation.clone(), - unit: derived.base.unit.clone(), - format: derived.base.format, - direction: derived.base.direction, - views, - }, + let computation = match &def.spec { + ComputationSpec::Sum { .. } => ComputationDto::Sum, + ComputationSpec::Ratio { scale, .. } => ComputationDto::Ratio { scale: *scale }, + }; + MetricResultDto { + metric_key: def.base.key.clone(), + label: def.base.label.clone(), + description: def.base.description.clone(), + explanation: def.base.explanation.clone(), + unit: def.base.unit.clone(), + format: def.base.format, + direction: def.base.direction, + computation, + views, } } @@ -240,15 +180,7 @@ fn response_size(response: &MetricResultsResponse) -> usize { response .metrics .iter() - .flat_map(|metric| match metric { - MetricResultDto::Sum { views, .. } - | MetricResultDto::Count { views, .. } - | MetricResultDto::CountDistinct { views, .. } - | MetricResultDto::Ratio { views, .. } - | MetricResultDto::Distribution { views, .. } - | MetricResultDto::Gauge { views, .. } - | MetricResultDto::Derived { views, .. } => views, - }) + .flat_map(|metric| &metric.views) .map(|view| match view { MetricResultViewDto::Period { values } => values.len(), MetricResultViewDto::Timeseries { series, .. } => { @@ -294,14 +226,6 @@ fn json_string(value: Option<&serde_json::Value>) -> Option { } } -fn dimension_key( - dims: &[(String, String, Option)], -) -> Vec<(String, String, Option)> { - dims.iter() - .map(|d| (d.0.clone(), d.1.clone(), d.2.clone())) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -310,9 +234,8 @@ mod tests { use crate::domain::metric_definitions::definition::{ MetricBase, MetricDirection, MetricFormat, MetricInput, MetricInputRole, ObservationSource, - RatioMetricDefinition, SumMetricDefinition, }; - use crate::domain::metric_results::definition::Bucket; + use crate::domain::metric_results::view::Bucket; fn base() -> MetricBase { MetricBase { @@ -338,20 +261,24 @@ mod tests { } } - fn sum_metric() -> ExecutableMetric { - ExecutableMetric::Sum(SumMetricDefinition { + fn sum_metric() -> MetricDefinition { + MetricDefinition { base: base(), - value: input(MetricInputRole::Value, "accepted_lines"), - }) + spec: ComputationSpec::Sum { + value: input(MetricInputRole::Value, "accepted_lines"), + }, + } } - fn ratio_metric() -> ExecutableMetric { - ExecutableMetric::Ratio(RatioMetricDefinition { + fn ratio_metric() -> MetricDefinition { + MetricDefinition { base: base(), - numerator: input(MetricInputRole::Numerator, "accepted_edit_actions"), - denominator: input(MetricInputRole::Denominator, "tool_use_offered"), - scale: 100.0, - }) + spec: ComputationSpec::Ratio { + numerator: input(MetricInputRole::Numerator, "accepted_edit_actions"), + denominator: input(MetricInputRole::Denominator, "tool_use_offered"), + scale: 100.0, + }, + } } fn request(entity_ids: Vec<&str>, from: &str, to: &str) -> ValidatedMetricResultsRequest { @@ -500,6 +427,21 @@ mod tests { ); } + #[test] + fn metric_result_wire_shape_is_flat_with_computation_tag() { + let sum = build_metric_result(&sum_metric(), Vec::new()); + let sum_json = serde_json::to_value(&sum).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(sum_json["computation"], "sum"); + assert_eq!(sum_json["metric_key"], "ai.accepted_lines"); + assert_eq!(sum_json["format"], "integer"); + assert!(sum_json.get("scale").is_none()); + + let ratio = build_metric_result(&ratio_metric(), Vec::new()); + let ratio_json = serde_json::to_value(&ratio).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(ratio_json["computation"], "ratio"); + assert_eq!(ratio_json["scale"], 100.0); + } + #[test] fn response_size_counts_densified_points() { let req = request(vec!["a@x.io"], "2026-01-01", "2026-01-10"); @@ -507,12 +449,8 @@ mod tests { else { panic!("expected timeseries view"); }; - let def = MetricDefinition::Sum(SumMetricDefinition { - base: base(), - value: input(MetricInputRole::Value, "accepted_lines"), - }); let response = MetricResultsResponse { - metrics: vec![build_metric_result(&def, vec![view])], + metrics: vec![build_metric_result(&sum_metric(), vec![view])], }; assert_eq!(response_size(&response), 10); } diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs index d512de807..da5c3d95c 100644 --- a/src/backend/services/analytics/src/domain/metric_results/compiler.rs +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -3,9 +3,11 @@ use std::fmt::Write; use serde::Deserialize; -use super::definition::Bucket; use super::validation::{ValidatedMetricResultsRequest, ValidatedMetricView, query_row_limit}; -use crate::domain::metric_definitions::{CohortSource, ExecutableMetric, ObservationSource}; +use super::view::Bucket; +use crate::domain::metric_definitions::{ + CohortSource, ComputationSpec, MetricDefinition, ObservationSource, +}; pub(crate) const UNKNOWN_DIMENSION_VALUE: &str = "__unknown__"; pub(crate) const UNKNOWN_DIMENSION_LABEL: &str = "Unknown"; @@ -53,7 +55,7 @@ pub struct BreakdownQueryRow { } pub fn compile_view_query( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, tenant_id: &str, view: &ValidatedMetricView, @@ -73,7 +75,7 @@ pub fn compile_view_query( } fn compile_period_query( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, tenant_id: &str, ) -> CompiledQuery { @@ -82,8 +84,8 @@ fn compile_period_query( let entities = placeholders(req.entity_ids.len()); let observation_table = observation_table(def.observation_source()); let limit = query_row_limit(); - let sql = match def { - ExecutableMetric::Sum(_) => format!( + let sql = match &def.spec { + ComputationSpec::Sum { .. } => format!( r" SELECT entity_id, @@ -96,7 +98,7 @@ fn compile_period_query( ", metric_where = metric_where(def), ), - ExecutableMetric::Ratio(ratio) => format!( + ComputationSpec::Ratio { scale, .. } => format!( r" SELECT entity_id, @@ -108,7 +110,7 @@ fn compile_period_query( GROUP BY entity_id LIMIT {limit} ", - scale = ratio.scale, + scale = scale, metric_where = metric_where(def), ), }; @@ -116,7 +118,7 @@ fn compile_period_query( } fn compile_timeseries_query( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, tenant_id: &str, bucket: Bucket, @@ -134,8 +136,8 @@ fn compile_timeseries_query( }; let observation_table = observation_table(def.observation_source()); let limit = query_row_limit(); - let sql = match def { - ExecutableMetric::Sum(_) => format!( + let sql = match &def.spec { + ComputationSpec::Sum { .. } => format!( r" SELECT entity_id, @@ -150,7 +152,7 @@ fn compile_timeseries_query( ", metric_where = metric_where(def), ), - ExecutableMetric::Ratio(ratio) => format!( + ComputationSpec::Ratio { scale, .. } => format!( r" SELECT entity_id, @@ -165,14 +167,14 @@ fn compile_timeseries_query( LIMIT {limit} ", metric_where = metric_where(def), - scale = ratio.scale, + scale = scale, ), }; CompiledQuery { sql, params } } fn compile_breakdown_query( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, tenant_id: &str, dimensions: &[String], @@ -188,8 +190,8 @@ fn compile_breakdown_query( }; let observation_table = observation_table(def.observation_source()); let limit = query_row_limit(); - let sql = match def { - ExecutableMetric::Sum(_) => format!( + let sql = match &def.spec { + ComputationSpec::Sum { .. } => format!( r" SELECT entity_id{dim_select}, @@ -203,7 +205,7 @@ fn compile_breakdown_query( ", metric_where = metric_where(def), ), - ExecutableMetric::Ratio(ratio) => format!( + ComputationSpec::Ratio { scale, .. } => format!( r" SELECT entity_id{dim_select}, @@ -217,14 +219,14 @@ fn compile_breakdown_query( LIMIT {limit} ", metric_where = metric_where(def), - scale = ratio.scale, + scale = scale, ), }; CompiledQuery { sql, params } } fn compile_peer_query( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, tenant_id: &str, cohort_key: &str, @@ -242,11 +244,10 @@ fn compile_peer_query( let entities = placeholders(req.entity_ids.len()); let observation_table = observation_table(def.observation_source()); let cohort_table = cohort_table(CohortSource::MetricEntityCohortsCurrent); - let metric_value = match def { - ExecutableMetric::Sum(_) => "sumIf(value, value IS NOT NULL)".to_owned(), - ExecutableMetric::Ratio(ratio) => format!( - "{} * sumIf(value, measure_key = ? AND value IS NOT NULL) / nullIf(sumIf(value, measure_key = ? AND value IS NOT NULL), 0)", - ratio.scale + let metric_value = match &def.spec { + ComputationSpec::Sum { .. } => "sumIf(value, value IS NOT NULL)".to_owned(), + ComputationSpec::Ratio { scale, .. } => format!( + "{scale} * sumIf(value, measure_key = ? AND value IS NOT NULL) / nullIf(sumIf(value, measure_key = ? AND value IS NOT NULL), 0)" ), }; let peer_value = if def.is_zero_filled() { @@ -325,44 +326,48 @@ fn compile_peer_query( CompiledQuery { sql, params } } -fn metric_where(def: &ExecutableMetric) -> &'static str { - match def { - ExecutableMetric::Sum(_) => { +fn metric_where(def: &MetricDefinition) -> &'static str { + match &def.spec { + ComputationSpec::Sum { .. } => { "tenant_id = ? AND source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key = ?" } - ExecutableMetric::Ratio(_) => { + ComputationSpec::Ratio { .. } => { "tenant_id = ? AND source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key IN (?, ?)" } } } fn metric_params( - def: &ExecutableMetric, + def: &MetricDefinition, req: &ValidatedMetricResultsRequest, tenant_id: &str, ) -> Vec { - match def { - ExecutableMetric::Sum(sum) => vec![ + match &def.spec { + ComputationSpec::Sum { value } => vec![ tenant_id.to_owned(), - sum.value.source_key.clone(), + value.source_key.clone(), req.entity_type.clone(), req.from.to_string(), req.to.to_string(), - sum.value.measure_key.clone(), + value.measure_key.clone(), ], - ExecutableMetric::Ratio(ratio) => { + ComputationSpec::Ratio { + numerator, + denominator, + .. + } => { let mut params = vec![ - ratio.numerator.measure_key.clone(), - ratio.denominator.measure_key.clone(), + numerator.measure_key.clone(), + denominator.measure_key.clone(), ]; params.extend([ tenant_id.to_owned(), - ratio.numerator.source_key.clone(), + numerator.source_key.clone(), req.entity_type.clone(), req.from.to_string(), req.to.to_string(), - ratio.numerator.measure_key.clone(), - ratio.denominator.measure_key.clone(), + numerator.measure_key.clone(), + denominator.measure_key.clone(), ]); params } @@ -470,7 +475,6 @@ mod tests { use crate::domain::metric_definitions::definition::{ MetricBase, MetricDirection, MetricFormat, MetricInput, MetricInputRole, - RatioMetricDefinition, SumMetricDefinition, }; fn base(dimensions: Vec<&str>) -> MetricBase { @@ -497,20 +501,24 @@ mod tests { } } - fn sum_metric() -> ExecutableMetric { - ExecutableMetric::Sum(SumMetricDefinition { + fn sum_metric() -> MetricDefinition { + MetricDefinition { base: base(vec!["tool"]), - value: input(MetricInputRole::Value, "accepted_lines"), - }) + spec: ComputationSpec::Sum { + value: input(MetricInputRole::Value, "accepted_lines"), + }, + } } - fn ratio_metric() -> ExecutableMetric { - ExecutableMetric::Ratio(RatioMetricDefinition { + fn ratio_metric() -> MetricDefinition { + MetricDefinition { base: base(vec!["tool"]), - numerator: input(MetricInputRole::Numerator, "accepted_edit_actions"), - denominator: input(MetricInputRole::Denominator, "tool_use_offered"), - scale: 100.0, - }) + spec: ComputationSpec::Ratio { + numerator: input(MetricInputRole::Numerator, "accepted_edit_actions"), + denominator: input(MetricInputRole::Denominator, "tool_use_offered"), + scale: 100.0, + }, + } } fn request() -> ValidatedMetricResultsRequest { diff --git a/src/backend/services/analytics/src/domain/metric_results/dto.rs b/src/backend/services/analytics/src/domain/metric_results/dto.rs index dd40413ca..f7905341a 100644 --- a/src/backend/services/analytics/src/domain/metric_results/dto.rs +++ b/src/backend/services/analytics/src/domain/metric_results/dto.rs @@ -1,9 +1,7 @@ use serde::{Deserialize, Serialize}; -use super::definition::{Bucket, MetricResultViewKind}; -use crate::domain::metric_definitions::{ - DistributionStatistic, GaugeMethod, MetricDirection, MetricFormat, -}; +use super::view::{Bucket, MetricResultViewKind}; +use crate::domain::metric_definitions::{MetricDirection, MetricFormat}; #[derive(Debug, Deserialize)] pub struct MetricResultsRequest { @@ -63,96 +61,27 @@ pub struct MetricResultsResponse { pub metrics: Vec, } +#[derive(Debug, Serialize)] +pub struct MetricResultDto { + pub metric_key: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub explanation: Option, + pub unit: Option, + pub format: MetricFormat, + pub direction: MetricDirection, + #[serde(flatten)] + pub computation: ComputationDto, + pub views: Vec, +} + #[derive(Debug, Serialize)] #[serde(tag = "computation", rename_all = "snake_case")] -pub enum MetricResultDto { - Sum { - metric_key: String, - label: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - explanation: Option, - unit: Option, - format: MetricFormat, - direction: MetricDirection, - views: Vec, - }, - Count { - metric_key: String, - label: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - explanation: Option, - unit: Option, - format: MetricFormat, - direction: MetricDirection, - views: Vec, - }, - CountDistinct { - metric_key: String, - label: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - explanation: Option, - unit: Option, - format: MetricFormat, - direction: MetricDirection, - views: Vec, - }, - Ratio { - metric_key: String, - label: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - explanation: Option, - unit: Option, - format: MetricFormat, - direction: MetricDirection, - scale: f64, - views: Vec, - }, - Distribution { - metric_key: String, - label: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - explanation: Option, - unit: Option, - format: MetricFormat, - direction: MetricDirection, - statistic: DistributionStatistic, - views: Vec, - }, - Gauge { - metric_key: String, - label: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - explanation: Option, - unit: Option, - format: MetricFormat, - direction: MetricDirection, - method: GaugeMethod, - views: Vec, - }, - Derived { - metric_key: String, - label: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - explanation: Option, - unit: Option, - format: MetricFormat, - direction: MetricDirection, - views: Vec, - }, +pub enum ComputationDto { + Sum, + Ratio { scale: f64 }, } #[derive(Debug, Serialize)] diff --git a/src/backend/services/analytics/src/domain/metric_results/mod.rs b/src/backend/services/analytics/src/domain/metric_results/mod.rs index 7bd160d27..6bcaedc20 100644 --- a/src/backend/services/analytics/src/domain/metric_results/mod.rs +++ b/src/backend/services/analytics/src/domain/metric_results/mod.rs @@ -1,8 +1,8 @@ mod builder; mod compiler; -mod definition; mod dto; mod validation; +mod view; pub use builder::{ build_breakdown_view, build_metric_result, build_peer_view, build_period_view, diff --git a/src/backend/services/analytics/src/domain/metric_results/validation.rs b/src/backend/services/analytics/src/domain/metric_results/validation.rs index 2559f5baf..4570d479d 100644 --- a/src/backend/services/analytics/src/domain/metric_results/validation.rs +++ b/src/backend/services/analytics/src/domain/metric_results/validation.rs @@ -6,10 +6,10 @@ use toolkit_canonical_errors::CanonicalError; use uuid::Uuid; use crate::api::error::MetricError; -use crate::domain::metric_definitions::{ExecutableMetric, MetricDefinition, load_definitions}; +use crate::domain::metric_definitions::{MetricDefinition, load_definitions}; -use super::definition::Bucket; use super::dto::{MetricResultsRequest, MetricViewRequest}; +use super::view::Bucket; const ROW_LIMIT: usize = 5000; const MAX_METRICS: usize = 50; @@ -28,7 +28,6 @@ pub struct ValidatedMetricResultsRequest { #[derive(Debug)] pub struct ValidatedMetricRequest { pub def: MetricDefinition, - pub exec: ExecutableMetric, pub views: Vec, } @@ -75,27 +74,19 @@ pub async fn validate_request( for metric in req.metrics { let metric_key = metric.metric_key.trim(); let def = definitions.remove(metric_key).ok_or_else(|| { - MetricError::invalid_argument() - .with_field_violation( - "metrics.metric_key", - format!("unknown or unavailable metric key: {metric_key}"), - "UNAVAILABLE", - ) - .create() + tracing::error!(metric_key = %metric_key, "definition missing after successful load"); + CanonicalError::internal("metric definition lookup failed").create() })?; - if def.base().entity_type != entity_type { + if def.base.entity_type != entity_type { return invalid( "entity.type", format!( "metric {} is defined for entity type {}", def.key(), - def.base().entity_type + def.base.entity_type ), ); } - let Some(exec) = def.executable() else { - return unsupported_computation(&def); - }; if metric.views.is_empty() { return invalid( "metrics.views", @@ -116,7 +107,7 @@ pub async fn validate_request( views.push(validate_view(&def, view)?); } - metrics.push(ValidatedMetricRequest { def, exec, views }); + metrics.push(ValidatedMetricRequest { def, views }); } let validated = ValidatedMetricResultsRequest { @@ -186,11 +177,14 @@ fn validate_request_shape(req: &MetricResultsRequest) -> Result usize { +pub const fn row_limit() -> usize { ROW_LIMIT } -pub fn query_row_limit() -> usize { +// One past the response cap: a query returning ROW_LIMIT + 1 rows proves +// truncation, which the final enforce_row_limit pass then converts into a +// whole-request failure instead of a silently clipped result. +pub const fn query_row_limit() -> usize { ROW_LIMIT + 1 } @@ -213,7 +207,7 @@ fn validate_view( MetricViewRequest::Peer { cohort_key } => { let cohort_key = match cohort_key { Some(key) => normalize_key("metrics.views.cohort_key", &key)?, - None => def.base().peer_cohort_key.clone().ok_or_else(|| { + None => def.base.peer_cohort_key.clone().ok_or_else(|| { MetricError::invalid_argument() .with_field_violation( "metrics.views.cohort_key", @@ -381,20 +375,6 @@ fn parse_date(field: &'static str, value: &str) -> Result(def: &MetricDefinition) -> Result { - Err(MetricError::invalid_argument() - .with_field_violation( - "metrics.computation", - format!( - "metric {} uses unsupported computation {}", - def.key(), - def.computation().as_db() - ), - "UNSUPPORTED_COMPUTATION", - ) - .create()) -} - fn invalid(field: &'static str, message: impl Into) -> Result { Err(MetricError::invalid_argument() .with_field_violation(field, message.into(), "INVALID") @@ -406,8 +386,8 @@ mod tests { use super::super::dto::MetricResultsEntity; use super::*; use crate::domain::metric_definitions::definition::{ - ExecutableMetric, MetricBase, MetricDefinition, MetricDirection, MetricFormat, MetricInput, - MetricInputRole, ObservationSource, SumMetricDefinition, + ComputationSpec, MetricBase, MetricDefinition, MetricDirection, MetricFormat, MetricInput, + MetricInputRole, ObservationSource, }; fn shape_request( @@ -436,7 +416,7 @@ mod tests { } fn sum_definition(dimensions: Vec<&str>) -> MetricDefinition { - MetricDefinition::Sum(SumMetricDefinition { + MetricDefinition { base: MetricBase { key: "ai.accepted_lines".to_owned(), label: "AI-added lines".to_owned(), @@ -449,13 +429,15 @@ mod tests { peer_cohort_key: Some("org_unit".to_owned()), allowed_dimensions: dimensions.into_iter().map(str::to_owned).collect(), }, - value: MetricInput { - role: MetricInputRole::Value, - observation_source: ObservationSource::AiMetricObservations, - source_key: "ai_usage".to_owned(), - measure_key: "accepted_lines".to_owned(), + spec: ComputationSpec::Sum { + value: MetricInput { + role: MetricInputRole::Value, + observation_source: ObservationSource::AiMetricObservations, + source_key: "ai_usage".to_owned(), + measure_key: "accepted_lines".to_owned(), + }, }, - }) + } } fn day(value: &str) -> NaiveDate { @@ -631,9 +613,6 @@ mod tests { #[test] fn projected_row_limit_counts_timeseries_buckets() { let def = sum_definition(vec![]); - let Some(exec) = def.executable() else { - panic!("sum must be executable"); - }; let validated = ValidatedMetricResultsRequest { entity_type: "person".to_owned(), entity_ids: (0..100).map(|i| format!("p{i}@x.io")).collect(), @@ -641,7 +620,6 @@ mod tests { to: day("2026-03-31"), metrics: vec![ValidatedMetricRequest { def, - exec, views: vec![ValidatedMetricView::Timeseries { bucket: Bucket::Day, dimensions: vec![], @@ -654,9 +632,6 @@ mod tests { #[test] fn projected_row_limit_allows_small_requests() { let def = sum_definition(vec![]); - let Some(exec) = def.executable() else { - panic!("sum must be executable"); - }; let validated = ValidatedMetricResultsRequest { entity_type: "person".to_owned(), entity_ids: vec!["a@x.io".to_owned()], @@ -664,7 +639,6 @@ mod tests { to: day("2026-01-31"), metrics: vec![ValidatedMetricRequest { def, - exec, views: vec![ ValidatedMetricView::Period, ValidatedMetricView::Peer { @@ -675,10 +649,4 @@ mod tests { }; assert!(validate_projected_row_limit(&validated).is_ok()); } - - #[test] - fn executable_projection_covers_sum() { - let def = sum_definition(vec![]); - assert!(matches!(def.executable(), Some(ExecutableMetric::Sum(_)))); - } } diff --git a/src/backend/services/analytics/src/domain/metric_results/definition.rs b/src/backend/services/analytics/src/domain/metric_results/view.rs similarity index 100% rename from src/backend/services/analytics/src/domain/metric_results/definition.rs rename to src/backend/services/analytics/src/domain/metric_results/view.rs diff --git a/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs index dd2a38478..f1753bbff 100644 --- a/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs +++ b/src/backend/services/analytics/src/migration/m20260625_000001_metric_definitions.rs @@ -25,14 +25,10 @@ pub const REQUIRED_DEFINITION_CHECKS: &[&str] = &[ "chk_metric_definitions_entity_type_shape", "chk_metric_definitions_peer_cohort_key_shape", "chk_metric_definitions_computation_fields", - "chk_metric_definitions_version_positive", "chk_metric_definitions_schema_error_biconditional", "chk_metric_definitions_schema_error_enum", ]; -pub const REQUIRED_INPUT_CHECKS: &[&str] = - &["chk_metric_definition_inputs_display_order_nonnegative"]; - pub const REQUIRED_DIMENSION_CHECKS: &[&str] = &["chk_metric_definition_dimensions_display_order_nonnegative"]; @@ -75,7 +71,6 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id BINARY(16) NOT NULL PRIMARY KEY, source_id BINARY(16) NOT NULL, measure_key VARCHAR(128) NOT NULL, - value_type ENUM('number','event','identifier') NOT NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, schema_status ENUM('ok','error','unchecked') NOT NULL DEFAULT 'unchecked', schema_checked_at DATETIME(3) NULL, @@ -92,7 +87,6 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id BINARY(16) NOT NULL PRIMARY KEY, source_id BINARY(16) NOT NULL, dimension_key VARCHAR(64) NOT NULL, - label VARCHAR(128) NOT NULL, display_order INT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uq_metric_source_dimensions_key (source_id, dimension_key), @@ -112,13 +106,10 @@ const SCHEMA_STATEMENTS: &[&str] = &[ format ENUM('integer','decimal','currency','percent') NOT NULL, direction ENUM('higher_is_better','lower_is_better','neutral') NOT NULL, entity_type VARCHAR(64) NOT NULL, - computation_type ENUM('sum','count','count_distinct','ratio','distribution','gauge','derived') NOT NULL, + computation_type ENUM('sum','ratio') NOT NULL, scale DOUBLE NULL, - distribution_statistic ENUM('p50','p75','p90','p95','p99','avg') NULL, - gauge_method ENUM('latest','min','max','avg') NULL, peer_cohort_key VARCHAR(64) NULL, origin ENUM('builtin','custom') NOT NULL, - definition_version INT NOT NULL DEFAULT 1, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, schema_status ENUM('ok','error','unchecked') NOT NULL DEFAULT 'unchecked', schema_checked_at DATETIME(3) NULL, @@ -131,26 +122,21 @@ const SCHEMA_STATEMENTS: &[&str] = &[ CONSTRAINT chk_metric_definitions_entity_type_shape CHECK (entity_type REGEXP BINARY '^[a-z][a-z0-9_]*$'), CONSTRAINT chk_metric_definitions_peer_cohort_key_shape CHECK (peer_cohort_key IS NULL OR peer_cohort_key REGEXP BINARY '^[a-z][a-z0-9_]*$'), CONSTRAINT chk_metric_definitions_computation_fields CHECK ( - (computation_type IN ('sum','count','count_distinct','derived') AND scale IS NULL AND distribution_statistic IS NULL AND gauge_method IS NULL) - OR (computation_type = 'ratio' AND scale IS NOT NULL AND distribution_statistic IS NULL AND gauge_method IS NULL) - OR (computation_type = 'distribution' AND scale IS NULL AND distribution_statistic IS NOT NULL AND gauge_method IS NULL) - OR (computation_type = 'gauge' AND scale IS NULL AND distribution_statistic IS NULL AND gauge_method IS NOT NULL) + (computation_type = 'sum' AND scale IS NULL) + OR (computation_type = 'ratio' AND scale IS NOT NULL) ), - CONSTRAINT chk_metric_definitions_version_positive CHECK (definition_version > 0), CONSTRAINT chk_metric_definitions_schema_error_biconditional CHECK ((schema_status = 'error') = (schema_error_code IS NOT NULL)), CONSTRAINT chk_metric_definitions_schema_error_enum CHECK (schema_error_code IS NULL OR schema_error_code IN ('table_not_found','column_not_found','dimension_not_covered','unknown')) )", "CREATE TABLE IF NOT EXISTS metric_definition_inputs ( id BINARY(16) NOT NULL PRIMARY KEY, metric_definition_id BINARY(16) NOT NULL, - input_role ENUM('value','event','numerator','denominator','sample','snapshot','dependency') NOT NULL, + input_role ENUM('value','numerator','denominator') NOT NULL, source_measure_id BINARY(16) NOT NULL, - display_order INT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uq_metric_definition_inputs_role_measure (metric_definition_id, input_role, source_measure_id), CONSTRAINT fk_metric_definition_inputs_definition FOREIGN KEY (metric_definition_id) REFERENCES metric_definitions(id) ON DELETE CASCADE, - CONSTRAINT fk_metric_definition_inputs_measure FOREIGN KEY (source_measure_id) REFERENCES metric_source_measures(id) ON DELETE RESTRICT, - CONSTRAINT chk_metric_definition_inputs_display_order_nonnegative CHECK (display_order >= 0) + CONSTRAINT fk_metric_definition_inputs_measure FOREIGN KEY (source_measure_id) REFERENCES metric_source_measures(id) ON DELETE RESTRICT )", "CREATE TABLE IF NOT EXISTS metric_definition_dimensions ( id BINARY(16) NOT NULL PRIMARY KEY, @@ -200,7 +186,6 @@ mod tests { .chain(REQUIRED_SOURCE_MEASURE_CHECKS) .chain(REQUIRED_SOURCE_DIMENSION_CHECKS) .chain(REQUIRED_DEFINITION_CHECKS) - .chain(REQUIRED_INPUT_CHECKS) .chain(REQUIRED_DIMENSION_CHECKS) { assert!(all_sql.contains(check), "missing CHECK {check}"); diff --git a/src/backend/services/analytics/src/migration/mod.rs b/src/backend/services/analytics/src/migration/mod.rs index f2f5a7414..128f41827 100644 --- a/src/backend/services/analytics/src/migration/mod.rs +++ b/src/backend/services/analytics/src/migration/mod.rs @@ -139,10 +139,6 @@ pub const REQUIRED_CHECKS_BY_TABLE: &[(&str, &[&str])] = &[ "metric_definitions", m20260625_000001_metric_definitions::REQUIRED_DEFINITION_CHECKS, ), - ( - "metric_definition_inputs", - m20260625_000001_metric_definitions::REQUIRED_INPUT_CHECKS, - ), ( "metric_definition_dimensions", m20260625_000001_metric_definitions::REQUIRED_DIMENSION_CHECKS, @@ -199,10 +195,6 @@ mod tests { "metric_definitions", m20260625_000001_metric_definitions::REQUIRED_DEFINITION_CHECKS, ), - ( - "metric_definition_inputs", - m20260625_000001_metric_definitions::REQUIRED_INPUT_CHECKS, - ), ( "metric_definition_dimensions", m20260625_000001_metric_definitions::REQUIRED_DIMENSION_CHECKS, diff --git a/src/ingestion/dbt/macros/metric_observation_measures.sql b/src/ingestion/dbt/macros/metric_observation_measures.sql index 3da4c4129..f831b6466 100644 --- a/src/ingestion/dbt/macros/metric_observation_measures.sql +++ b/src/ingestion/dbt/macros/metric_observation_measures.sql @@ -4,15 +4,12 @@ entity_id, metric_date, '{{ measure_key }}' AS measure_key, - if( - countIf(({{ value_expr }}) IS NOT NULL) > 0, - sumIf(toFloat64({{ value_expr }}), ({{ value_expr }}) IS NOT NULL), - CAST(NULL AS Nullable(Float64)) - ) AS value, + toNullable(sumIf(toFloat64({{ value_expr }}), ({{ value_expr }}) IS NOT NULL)) AS value, {{ dimensions_col }} AS dimensions FROM {{ relation }} {% if where %}WHERE {{ where }} {% endif %}GROUP BY tenant_id, entity_id, metric_date, {{ dimensions_col }} + HAVING countIf(({{ value_expr }}) IS NOT NULL) > 0 {% endmacro %} {% macro presence_measure(measure_key, relations) %} diff --git a/src/ingestion/gold/ai_metric_observations.sql b/src/ingestion/gold/ai_metric_observations.sql index 9b60c15da..b97dd1e79 100644 --- a/src/ingestion/gold/ai_metric_observations.sql +++ b/src/ingestion/gold/ai_metric_observations.sql @@ -10,7 +10,9 @@ -- guarantees rows exist only for real activity — see silver/ai/schema.yml), -- display labels come from tool_label / surface_label, and conversation -- semantics come from data presence (conversation_count is NULL for sources --- without a conversation concept). No vendor-specific columns, tool names, or +-- without a conversation concept). Discriminator and label columns are +-- non-null non-empty by the class contract (enforced by silver schema tests); +-- this model consumes them as-is. No vendor-specific columns, tool names, or -- label mappings may appear in this model. Every measure is emitted through -- the shape macros in macros/metric_observation_measures.sql; filter -- predicates may reference only class-contract dimension values. @@ -21,12 +23,10 @@ ai_dev_usage_source AS ( insight_tenant_id AS tenant_id, lower(email) AS entity_id, day AS metric_date, - coalesce(nullIf(tool, ''), '__unknown__') AS tool_value, - if( - coalesce(nullIf(tool, ''), '__unknown__') = '__unknown__', - 'Unknown', - coalesce(nullIf(tool_label, ''), tool) - ) AS tool_label_value, + CAST( + [tuple('tool', tool, tool_label)] + AS Array(Tuple(key String, value String, label Nullable(String))) + ) AS tool_dimensions, conversation_count, lines_added, lines_removed, @@ -42,18 +42,17 @@ ai_assistant_usage_source AS ( insight_tenant_id AS tenant_id, lower(email) AS entity_id, day AS metric_date, - coalesce(nullIf(tool, ''), '__unknown__') AS tool_value, - if( - coalesce(nullIf(tool, ''), '__unknown__') = '__unknown__', - 'Unknown', - coalesce(nullIf(tool_label, ''), tool) - ) AS tool_label_value, - coalesce(nullIf(surface, ''), '__unknown__') AS surface_value, - if( - coalesce(nullIf(surface, ''), '__unknown__') = '__unknown__', - 'Unknown', - coalesce(nullIf(surface_label, ''), surface) - ) AS surface_label_value, + surface, + CAST( + [tuple('tool', tool, tool_label)] + AS Array(Tuple(key String, value String, label Nullable(String))) + ) AS tool_dimensions, + CAST( + [ + tuple('tool', tool, tool_label), + tuple('surface', surface, surface_label) + ] AS Array(Tuple(key String, value String, label Nullable(String))) + ) AS tool_surface_dimensions, conversation_count, message_count, action_count, @@ -62,36 +61,12 @@ ai_assistant_usage_source AS ( WHERE email IS NOT NULL AND email != '' ), -ai_dev_usage_dimensions AS ( - SELECT - *, - CAST( - [tuple('tool', tool_value, tool_label_value)] - AS Array(Tuple(key String, value String, label Nullable(String))) - ) AS tool_dimensions - FROM ai_dev_usage_source -), -ai_assistant_usage_dimensions AS ( - SELECT - *, - CAST( - [tuple('tool', tool_value, tool_label_value)] - AS Array(Tuple(key String, value String, label Nullable(String))) - ) AS tool_dimensions, - CAST( - [ - tuple('tool', tool_value, tool_label_value), - tuple('surface', surface_value, surface_label_value) - ] AS Array(Tuple(key String, value String, label Nullable(String))) - ) AS tool_surface_dimensions - FROM ai_assistant_usage_source -), measure_observations AS ( - {{ sum_measure('accepted_lines', 'ai_dev_usage_dimensions', 'lines_added', 'tool_dimensions') }} + {{ sum_measure('accepted_lines', 'ai_dev_usage_source', 'lines_added', 'tool_dimensions') }} UNION ALL - {{ sum_measure('removed_lines', 'ai_dev_usage_dimensions', 'lines_removed', 'tool_dimensions') }} + {{ sum_measure('removed_lines', 'ai_dev_usage_source', 'lines_removed', 'tool_dimensions') }} UNION ALL @@ -99,35 +74,35 @@ measure_observations AS ( UNION ALL - {{ sum_measure('cost_usd', 'ai_dev_usage_dimensions', 'cost_cents / 100', 'tool_dimensions') }} + {{ sum_measure('cost_usd', 'ai_dev_usage_source', 'cost_cents / 100', 'tool_dimensions') }} UNION ALL - {{ sum_measure('cost_usd', 'ai_assistant_usage_dimensions', 'cost_cents / 100', 'tool_dimensions') }} + {{ sum_measure('cost_usd', 'ai_assistant_usage_source', 'cost_cents / 100', 'tool_dimensions') }} UNION ALL - {{ sum_measure('accepted_edit_actions', 'ai_dev_usage_dimensions', 'tool_use_accepted', 'tool_dimensions') }} + {{ sum_measure('accepted_edit_actions', 'ai_dev_usage_source', 'tool_use_accepted', 'tool_dimensions') }} UNION ALL - {{ sum_measure('tool_use_offered', 'ai_dev_usage_dimensions', 'tool_use_offered', 'tool_dimensions') }} + {{ sum_measure('tool_use_offered', 'ai_dev_usage_source', 'tool_use_offered', 'tool_dimensions') }} UNION ALL - {{ sum_measure('dev_conversations', 'ai_dev_usage_dimensions', 'conversation_count', 'tool_dimensions') }} + {{ sum_measure('dev_conversations', 'ai_dev_usage_source', 'conversation_count', 'tool_dimensions') }} UNION ALL - {{ sum_measure('assistant_messages', 'ai_assistant_usage_dimensions', 'message_count', 'tool_surface_dimensions') }} + {{ sum_measure('assistant_messages', 'ai_assistant_usage_source', 'message_count', 'tool_surface_dimensions') }} UNION ALL - {{ sum_measure('assistant_actions', 'ai_assistant_usage_dimensions', 'action_count', 'tool_surface_dimensions') }} + {{ sum_measure('assistant_actions', 'ai_assistant_usage_source', 'action_count', 'tool_surface_dimensions') }} UNION ALL - {{ sum_measure('chat_assistant_conversations', 'ai_assistant_usage_dimensions', 'conversation_count', 'tool_surface_dimensions', where="surface_value = 'chat'") }} + {{ sum_measure('chat_assistant_conversations', 'ai_assistant_usage_source', 'conversation_count', 'tool_surface_dimensions', where="surface = 'chat'") }} ) SELECT assumeNotNull(tenant_id) AS tenant_id, diff --git a/src/ingestion/gold/schema.yml b/src/ingestion/gold/schema.yml index c6b43fc65..79dbd2987 100644 --- a/src/ingestion/gold/schema.yml +++ b/src/ingestion/gold/schema.yml @@ -39,8 +39,8 @@ models: - not_null - name: observed_at description: > - Observation timestamp for gauge/latest semantics. NULL for day-grain - aggregate measures. + Intra-day observation timestamp for future point-in-time semantics. + NULL for day-grain aggregate measures. - name: measure_key description: "Source measure key registered in the metric registry" tests: @@ -60,17 +60,20 @@ models: - chat_assistant_conversations - name: value description: > - Measure value. NULL only when the source cannot provide a value for - an otherwise-present observation row. + Measure value. Rows are emitted only when the source provides a + value, so value is never NULL; the column stays Nullable(Float64) + in the published contract. + tests: + - not_null - name: subject_key description: > - Distinct-count subject for count_distinct semantics. NULL for + Distinct-count subject for future distinct-count semantics. NULL for measures without distinct-count support. - name: dimensions description: > Array(Tuple(key, value, label)) of all dimensions the source emits - for the measure. Missing dimension values use value '__unknown__' - and label 'Unknown'. + for the measure. Values and labels come straight from class-contract + columns, which the silver contract guarantees non-null. - name: metric_entity_cohorts_current description: > diff --git a/src/ingestion/silver/ai/schema.yml b/src/ingestion/silver/ai/schema.yml index bb7de0c15..46348f282 100644 --- a/src/ingestion/silver/ai/schema.yml +++ b/src/ingestion/silver/ai/schema.yml @@ -14,11 +14,6 @@ models: claude-enterprise/copilot/chatgpt-team: activity-counter filters). Downstream consumers (insight.ai_metric_observations active_day) rely on row existence and must NOT re-derive activity from counters. - Feeds Gold metrics: - cursor_active / cc_active / active_ai_members / ai_tools / ai_sessions / - cursor_acceptance / cc_tool_acceptance / cursor_completions / - cursor_agents / cursor_lines / cc_sessions / cc_lines / ai_loc_share_pct - (the last joined with class_git_commits in Gold). columns: - name: insight_tenant_id description: "Tenant isolation field" @@ -87,8 +82,8 @@ models: - name: lines_added description: > Lines of code added and accepted from AI. For Cursor: acceptedLinesAdded. - For Claude Code: lines_added from claude_admin_code_usage. Used in - ai_loc_share_pct / team_ai_loc Gold metrics (joined with class_git_commits). + For Claude Code: lines_added from claude_admin_code_usage. Summed by + insight.ai_metric_observations as the accepted_lines measure. tests: - not_null - name: lines_removed @@ -101,7 +96,6 @@ models: Total lines added by the user that day (AI-accepted + manual keystrokes). Populated for Cursor only (totalLinesAdded). NULL for Claude Code — Enterprise only sees AI-accepted lines, not total keystrokes. - Used as the denominator for ai_loc_share_pct (cursor-only metric). - name: total_lines_removed description: > Total lines deleted by the user that day (all edits, not just AI-assisted). @@ -114,8 +108,8 @@ models: - name: tool_use_accepted description: > Tool invocations accepted. For Cursor: totalTabsAccepted. - For Claude Code: tool_use_accepted (Edit/Write/MultiEdit/NotebookEdit). Used in - cursor_acceptance / cc_tool_acceptance ratios (accepted / offered). + For Claude Code: tool_use_accepted (Edit/Write/MultiEdit/NotebookEdit). + Numerator for accepted/offered acceptance ratios downstream. - name: agent_sessions description: > Cursor agent sessions (agentRequests). NULL for other tools. @@ -148,8 +142,8 @@ models: - name: prs_total_count description: > Total PRs in the measurement window. Populated for Claude Team only - (total_prs from the web API). Denominator for prs_with_cc_percentage - Gold metric. ⚠️ May be a period-aggregate (cumulative across the + (total_prs from the web API). Denominator for a PRs-with-assistant + share downstream. ⚠️ May be a period-aggregate (cumulative across the billing window), not a daily count — verify against a tenant with the Anthropic GitHub-app connected. NULL for all other sources. - name: tool_action_breakdown_json @@ -281,10 +275,6 @@ models: chat counter filter). Downstream consumers (insight.ai_metric_observations active_day) rely on row existence and must NOT re-derive activity from counters. - Feeds Gold metrics for - assistant adoption: chat_active / excel_active / powerpoint_active / - cowork_active / cross_active / ai_adoption_pct (email IS NOT NULL - AND conversation_count > 0). columns: - name: insight_tenant_id description: "Tenant isolation field" From 50a03b87a04e94b230f0be63a30640895144decd Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 12:39:17 +0200 Subject: [PATCH 12/20] refactor(analytics): drop warehouse tenant filtering from metric results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warehouse tenant isolation is not implemented platform-wide (the legacy query engine skips it), and the control-plane tenant id has no defined mapping to the warehouse tenant_id strings stamped at ingestion — a predicate on an unmapped identifier reads as isolation without providing it. Remove the tenant_id predicate from compiled queries and the single_tenant_warehouse_id config bridge with its boot guard. The observation and cohort contracts keep the tenant_id column, so enabling isolation later is a one-place compiler change once the platform defines the identifier mapping. Posture documented in the design spec. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 8 ++ .../analytics/src/api/metric_results.rs | 18 +---- src/backend/services/analytics/src/config.rs | 20 ----- .../src/domain/metric_results/compiler.rs | 80 ++++++------------- src/backend/services/analytics/src/gear.rs | 22 ----- 5 files changed, 34 insertions(+), 114 deletions(-) diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index f8cda0ccf..b15385f1f 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -294,6 +294,14 @@ entity ids); period, timeseries, and breakdown views expose per-entity values. Entity-level scoping (self, reports, role-based) is deferred to the real authorization system; this endpoint must adopt it when it lands. +Warehouse tenant isolation is not implemented platform-wide: compiled queries +do not filter on the warehouse `tenant_id` column, matching the rest of the +platform's single-tenant posture. The control-plane tenant id has no defined +mapping to the warehouse `tenant_id` strings stamped at ingestion; defining +that mapping and adding the predicate (one place: the compiler's shared WHERE +clause) is the multi-tenant unlock. The observation and cohort contracts keep +the column so that change needs no contract migration. + Schema validation checks: - managed source refs map to backend source enums. diff --git a/src/backend/services/analytics/src/api/metric_results.rs b/src/backend/services/analytics/src/api/metric_results.rs index bdbe7fb86..0ee6b7985 100644 --- a/src/backend/services/analytics/src/api/metric_results.rs +++ b/src/backend/services/analytics/src/api/metric_results.rs @@ -31,8 +31,7 @@ pub async fn query_metric_results( Json(req): Json, ) -> Result, CanonicalError> { let req = validate_request(&state.db, ctx.subject_tenant_id(), req).await?; - let tenant_id = warehouse_tenant_id(&state, &ctx); - let tasks = compile_tasks(&req, &tenant_id); + let tasks = compile_tasks(&req); let results = stream::iter(tasks) .map(|task| execute_task(&state, &req, task)) .buffer_unordered(QUERY_CONCURRENCY) @@ -67,17 +66,6 @@ pub async fn query_metric_results( Ok(Json(response)) } -fn warehouse_tenant_id(state: &AppState, ctx: &SecurityContext) -> String { - state - .config - .metric_results - .single_tenant_warehouse_id - .as_deref() - .map(str::trim) - .filter(|tenant_id| !tenant_id.is_empty()) - .map_or_else(|| ctx.subject_tenant_id().to_string(), ToOwned::to_owned) -} - struct MetricViewTask { metric_index: usize, view_index: usize, @@ -92,7 +80,7 @@ struct MetricViewTaskResult { view: MetricResultViewDto, } -fn compile_tasks(req: &ValidatedMetricResultsRequest, tenant_id: &str) -> Vec { +fn compile_tasks(req: &ValidatedMetricResultsRequest) -> Vec { req.metrics .iter() .enumerate() @@ -106,7 +94,7 @@ fn compile_tasks(req: &ValidatedMetricResultsRequest, tenant_id: &str) -> Vec, } impl Default for GearConfig { @@ -84,7 +65,6 @@ impl Default for GearConfig { identity_url: String::new(), redis_url: String::new(), metric_catalog: MetricCatalogConfig::default(), - metric_results: MetricResultsConfig::default(), } } } diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs index da5c3d95c..da9313421 100644 --- a/src/backend/services/analytics/src/domain/metric_results/compiler.rs +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -57,19 +57,16 @@ pub struct BreakdownQueryRow { pub fn compile_view_query( def: &MetricDefinition, req: &ValidatedMetricResultsRequest, - tenant_id: &str, view: &ValidatedMetricView, ) -> CompiledQuery { match view { - ValidatedMetricView::Period => compile_period_query(def, req, tenant_id), - ValidatedMetricView::Peer { cohort_key } => { - compile_peer_query(def, req, tenant_id, cohort_key) - } + ValidatedMetricView::Period => compile_period_query(def, req), + ValidatedMetricView::Peer { cohort_key } => compile_peer_query(def, req, cohort_key), ValidatedMetricView::Timeseries { bucket, dimensions } => { - compile_timeseries_query(def, req, tenant_id, *bucket, dimensions) + compile_timeseries_query(def, req, *bucket, dimensions) } ValidatedMetricView::Breakdown { dimensions } => { - compile_breakdown_query(def, req, tenant_id, dimensions) + compile_breakdown_query(def, req, dimensions) } } } @@ -77,9 +74,8 @@ pub fn compile_view_query( fn compile_period_query( def: &MetricDefinition, req: &ValidatedMetricResultsRequest, - tenant_id: &str, ) -> CompiledQuery { - let mut params = metric_params(def, req, tenant_id); + let mut params = metric_params(def, req); params.extend(req.entity_ids.iter().cloned()); let entities = placeholders(req.entity_ids.len()); let observation_table = observation_table(def.observation_source()); @@ -120,11 +116,10 @@ fn compile_period_query( fn compile_timeseries_query( def: &MetricDefinition, req: &ValidatedMetricResultsRequest, - tenant_id: &str, bucket: Bucket, dimensions: &[String], ) -> CompiledQuery { - let mut params = metric_params(def, req, tenant_id); + let mut params = metric_params(def, req); params.extend(req.entity_ids.iter().cloned()); let entities = placeholders(req.entity_ids.len()); let bucket = bucket_expr(bucket); @@ -176,10 +171,9 @@ fn compile_timeseries_query( fn compile_breakdown_query( def: &MetricDefinition, req: &ValidatedMetricResultsRequest, - tenant_id: &str, dimensions: &[String], ) -> CompiledQuery { - let mut params = metric_params(def, req, tenant_id); + let mut params = metric_params(def, req); params.extend(req.entity_ids.iter().cloned()); let entities = placeholders(req.entity_ids.len()); let (dim_select, dim_group) = dimension_select_group(dimensions); @@ -228,18 +222,15 @@ fn compile_breakdown_query( fn compile_peer_query( def: &MetricDefinition, req: &ValidatedMetricResultsRequest, - tenant_id: &str, cohort_key: &str, ) -> CompiledQuery { let mut params = Vec::new(); - params.push(tenant_id.to_owned()); params.push(req.entity_type.clone()); params.push(cohort_key.to_owned()); params.extend(req.entity_ids.iter().cloned()); - params.push(tenant_id.to_owned()); params.push(req.entity_type.clone()); params.push(cohort_key.to_owned()); - params.extend(metric_params(def, req, tenant_id)); + params.extend(metric_params(def, req)); let entities = placeholders(req.entity_ids.len()); let observation_table = observation_table(def.observation_source()); @@ -264,8 +255,7 @@ fn compile_peer_query( entity_id, cohort_id FROM {cohort_table} - WHERE tenant_id = ? - AND entity_type = ? + WHERE entity_type = ? AND cohort_key = ? AND entity_id IN ({entities}) AND cohort_id IS NOT NULL @@ -275,8 +265,7 @@ fn compile_peer_query( entity_id, cohort_id FROM {cohort_table} - WHERE tenant_id = ? - AND entity_type = ? + WHERE entity_type = ? AND cohort_key = ? AND cohort_id IN (SELECT cohort_id FROM targets) ), @@ -326,25 +315,26 @@ fn compile_peer_query( CompiledQuery { sql, params } } +// No tenant_id predicate: warehouse tenant isolation is not implemented +// platform-wide (the legacy query engine also queries without it), and the +// control-plane tenant UUID has no defined mapping to the warehouse +// tenant_id strings stamped at ingestion. The observation and cohort +// contracts keep the tenant_id column so isolation can be added here in one +// place once the platform defines that mapping. fn metric_where(def: &MetricDefinition) -> &'static str { match &def.spec { ComputationSpec::Sum { .. } => { - "tenant_id = ? AND source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key = ?" + "source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key = ?" } ComputationSpec::Ratio { .. } => { - "tenant_id = ? AND source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key IN (?, ?)" + "source_key = ? AND entity_type = ? AND metric_date >= toDate(?) AND metric_date <= toDate(?) AND measure_key IN (?, ?)" } } } -fn metric_params( - def: &MetricDefinition, - req: &ValidatedMetricResultsRequest, - tenant_id: &str, -) -> Vec { +fn metric_params(def: &MetricDefinition, req: &ValidatedMetricResultsRequest) -> Vec { match &def.spec { ComputationSpec::Sum { value } => vec![ - tenant_id.to_owned(), value.source_key.clone(), req.entity_type.clone(), req.from.to_string(), @@ -361,7 +351,6 @@ fn metric_params( denominator.measure_key.clone(), ]; params.extend([ - tenant_id.to_owned(), numerator.source_key.clone(), req.entity_type.clone(), req.from.to_string(), @@ -533,19 +522,14 @@ mod tests { #[test] fn sum_period_query_binds_scope_then_entities() { - let query = compile_view_query( - &sum_metric(), - &request(), - "tenant-1", - &ValidatedMetricView::Period, - ); + let query = compile_view_query(&sum_metric(), &request(), &ValidatedMetricView::Period); assert!(query.sql.contains("FROM insight.ai_metric_observations")); + assert!(!query.sql.contains("tenant_id")); assert!(query.sql.contains("measure_key = ?")); assert!(query.sql.contains("GROUP BY entity_id")); assert_eq!( query.params, vec![ - "tenant-1", "ai_usage", "person", "2026-01-01", @@ -559,12 +543,7 @@ mod tests { #[test] fn ratio_period_query_binds_select_measures_first() { - let query = compile_view_query( - &ratio_metric(), - &request(), - "tenant-1", - &ValidatedMetricView::Period, - ); + let query = compile_view_query(&ratio_metric(), &request(), &ValidatedMetricView::Period); assert!(query.sql.contains("nullIf")); assert!(query.sql.contains("100 *")); assert!(query.sql.contains("measure_key IN (?, ?)")); @@ -573,7 +552,6 @@ mod tests { vec![ "accepted_edit_actions", "tool_use_offered", - "tenant-1", "ai_usage", "person", "2026-01-01", @@ -596,7 +574,6 @@ mod tests { let query = compile_view_query( &sum_metric(), &request(), - "tenant-1", &ValidatedMetricView::Timeseries { bucket, dimensions: vec![], @@ -616,7 +593,6 @@ mod tests { let query = compile_view_query( &sum_metric(), &request(), - "tenant-1", &ValidatedMetricView::Breakdown { dimensions: vec!["tool".to_owned()], }, @@ -636,7 +612,6 @@ mod tests { let query = compile_view_query( &sum_metric(), &request(), - "tenant-1", &ValidatedMetricView::Peer { cohort_key: "org_unit".to_owned(), }, @@ -652,15 +627,12 @@ mod tests { assert_eq!( query.params, vec![ - "tenant-1", "person", "org_unit", "a@x.io", "b@x.io", - "tenant-1", "person", "org_unit", - "tenant-1", "ai_usage", "person", "2026-01-01", @@ -675,7 +647,6 @@ mod tests { let query = compile_view_query( &ratio_metric(), &request(), - "tenant-1", &ValidatedMetricView::Peer { cohort_key: "org_unit".to_owned(), }, @@ -686,12 +657,7 @@ mod tests { #[test] fn queries_carry_row_limit() { - let query = compile_view_query( - &sum_metric(), - &request(), - "tenant-1", - &ValidatedMetricView::Period, - ); + let query = compile_view_query(&sum_metric(), &request(), &ValidatedMetricView::Period); assert!(query.sql.contains(&format!("LIMIT {}", query_row_limit()))); } } diff --git a/src/backend/services/analytics/src/gear.rs b/src/backend/services/analytics/src/gear.rs index 133d20670..aa312b76a 100644 --- a/src/backend/services/analytics/src/gear.rs +++ b/src/backend/services/analytics/src/gear.rs @@ -136,28 +136,6 @@ impl Gear for AnalyticsApiGear { db.clone(), ch.clone(), ); - if let Some(warehouse_tenant) = cfg - .metric_results - .single_tenant_warehouse_id - .as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - { - // The override maps EVERY tenant to one warehouse tenant, which is - // cross-tenant data exposure on a multi-tenant install. Require the - // install to declare itself single-tenant via the same signal the - // catalog stack uses (`metric_catalog.tenant_default_id`). - anyhow::ensure!( - cfg.metric_catalog.tenant_default_id.is_some(), - "metric_results.single_tenant_warehouse_id is set but metric_catalog.tenant_default_id is not; \ - this override is only valid on single-tenant installs — refusing to start" - ); - tracing::warn!( - warehouse_tenant = %warehouse_tenant, - "metric_results.single_tenant_warehouse_id is set: all tenants' metric-results queries read this warehouse tenant; valid only for single-tenant installs" - ); - } - // Catalog auth-trait (Refs #522 / #525). v1 stub — see `domain::auth`. let tenant_auth: Arc = Arc::new( ConfigTenantAuthorization::new(cfg.metric_catalog.tenant_default_id), From fbf0b253347560e4480d16556fad821527cd1157 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 13:13:12 +0200 Subject: [PATCH 13/20] feat(analytics): suppress peer percentiles below minimum pool size Quartiles over a handful of people are noise presented as signal, and a two-person pool discloses the colleague's value through the median. The peer view now returns null percentiles and min/max when fewer than 5 members contribute, while n keeps reporting the true pool size. Enforced in the compiled query so every consumer inherits the floor. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 4 ++ .../src/domain/metric_results/compiler.rs | 39 ++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index b15385f1f..04a372468 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -259,6 +259,10 @@ Execution rules: - Peer starts from the generic current cohort view so zero-activity peers can be included. - Target entities missing cohort membership are omitted from peer values. - Null values are excluded from peer percentiles and `n`. +- Peer percentiles and min/max are suppressed (returned as null) when the + peer pool has fewer than 5 members; `n` still reports the pool size. + Quartiles over a handful of people are noise, and tiny pools disclose + individual values. Enforced server-side so every consumer inherits it. ## Validation diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs index da9313421..b6701b935 100644 --- a/src/backend/services/analytics/src/domain/metric_results/compiler.rs +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -12,6 +12,14 @@ use crate::domain::metric_definitions::{ pub(crate) const UNKNOWN_DIMENSION_VALUE: &str = "__unknown__"; pub(crate) const UNKNOWN_DIMENSION_LABEL: &str = "Unknown"; +/// Minimum peer-pool size for percentile disclosure. Below this, quartiles +/// over a handful of people are noise presented as signal (someone is always +/// "bottom 25%" of three), and with n=2 the "median" discloses the one +/// colleague's value. Enforced here, server-side, so every consumer inherits +/// it: the peer view still reports `n`, but p25/median/p75/min/max come back +/// NULL and clients render "no peer data". +pub(crate) const MIN_PEER_N: u32 = 5; + #[derive(Debug)] pub struct CompiledQuery { pub sql: String, @@ -296,11 +304,11 @@ fn compile_peer_query( SELECT targets.entity_id AS entity_id, target_values.value AS target_value, - quantileExact(0.25)(peers.value) AS p25, - quantileExact(0.5)(peers.value) AS median, - quantileExact(0.75)(peers.value) AS p75, - min(peers.value) AS min, - max(peers.value) AS max, + if(count(peers.value) >= {min_peer_n}, toNullable(quantileExact(0.25)(peers.value)), NULL) AS p25, + if(count(peers.value) >= {min_peer_n}, toNullable(quantileExact(0.5)(peers.value)), NULL) AS median, + if(count(peers.value) >= {min_peer_n}, toNullable(quantileExact(0.75)(peers.value)), NULL) AS p75, + if(count(peers.value) >= {min_peer_n}, toNullable(min(peers.value)), NULL) AS min, + if(count(peers.value) >= {min_peer_n}, toNullable(max(peers.value)), NULL) AS max, toUInt64(count(peers.value)) AS n FROM targets LEFT JOIN entity_values AS target_values @@ -311,6 +319,7 @@ fn compile_peer_query( LIMIT {limit} ", metric_where = metric_where(def), + min_peer_n = MIN_PEER_N, ); CompiledQuery { sql, params } } @@ -655,6 +664,26 @@ mod tests { assert!(!query.sql.contains("coalesce(metric_values.value, 0)")); } + #[test] + fn peer_queries_suppress_percentiles_below_min_pool_size() { + for def in [sum_metric(), ratio_metric()] { + let query = compile_view_query( + &def, + &request(), + &ValidatedMetricView::Peer { + cohort_key: "org_unit".to_owned(), + }, + ); + let guard = format!("count(peers.value) >= {MIN_PEER_N}"); + assert_eq!( + query.sql.matches(&guard).count(), + 5, + "every percentile/min/max must carry the disclosure guard" + ); + assert!(query.sql.contains("toUInt64(count(peers.value)) AS n")); + } + } + #[test] fn queries_carry_row_limit() { let query = compile_view_query(&sum_metric(), &request(), &ValidatedMetricView::Period); From 340e94a6a497d5cb0b13df98e0c24880fefcfb64 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 13:42:16 +0200 Subject: [PATCH 14/20] feat(analytics): peer pools include only observed values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The peer query zero-filled every cohort member without observations, asserting a value the data never determined: absence of rows cannot be distinguished from lack of source coverage (no seat, no account), so the fabricated zeros ranked unmeasured people and pinned percentiles to zero under partial adoption. Cohort membership now scopes who counts as a peer; only members with observed values contribute to percentiles, for every computation alike. Targets without observations report a null target_value. Sources where covered-but-inactive genuinely means zero can emit explicit zero observations instead — coverage knowledge lives in the connector, not the runtime. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 9 ++++- .../src/domain/metric_results/compiler.rs | 34 +++++++++---------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index 04a372468..a80d979a5 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -256,8 +256,15 @@ Execution rules: label `Unknown` (runtime guard; the schema validator's coverage probe makes this rare). - Breakdown returns observed dimension groups only. -- Peer starts from the generic current cohort view so zero-activity peers can be included. +- The cohort view scopes who counts as a peer; only members with observed + values contribute to the percentiles. The peer query never fabricates zero + observations: absence of rows is indistinguishable from "not covered by the + source" (no seat, no account), so inventing zeros would rank people the + data never measured. A source for which covered-but-inactive genuinely + means zero can emit explicit zero observations — the coverage knowledge + lives in the connector, not the runtime. - Target entities missing cohort membership are omitted from peer values. +- Target entities without observed values get a null `target_value`. - Null values are excluded from peer percentiles and `n`. - Peer percentiles and min/max are suppressed (returned as null) when the peer pool has fewer than 5 members; `n` still reports the pool size. diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs index b6701b935..3378f9dcc 100644 --- a/src/backend/services/analytics/src/domain/metric_results/compiler.rs +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -249,11 +249,6 @@ fn compile_peer_query( "{scale} * sumIf(value, measure_key = ? AND value IS NOT NULL) / nullIf(sumIf(value, measure_key = ? AND value IS NOT NULL), 0)" ), }; - let peer_value = if def.is_zero_filled() { - "coalesce(metric_values.value, 0)" - } else { - "metric_values.value" - }; let limit = query_row_limit(); let sql = format!( r" @@ -289,7 +284,7 @@ fn compile_peer_query( SELECT cohort.entity_id AS entity_id, cohort.cohort_id AS cohort_id, - {peer_value} AS value + metric_values.value AS value FROM cohort LEFT JOIN metric_values ON metric_values.entity_id = cohort.entity_id @@ -632,7 +627,6 @@ mod tests { ); assert!(query.sql.contains("WHERE value IS NOT NULL")); assert!(!query.sql.contains("AND peer.value IS NOT NULL")); - assert!(query.sql.contains("coalesce(metric_values.value, 0)")); assert_eq!( query.params, vec![ @@ -652,16 +646,22 @@ mod tests { } #[test] - fn ratio_peer_query_keeps_null_peer_values() { - let query = compile_view_query( - &ratio_metric(), - &request(), - &ValidatedMetricView::Peer { - cohort_key: "org_unit".to_owned(), - }, - ); - assert!(query.sql.contains("metric_values.value AS value")); - assert!(!query.sql.contains("coalesce(metric_values.value, 0)")); + fn peer_queries_never_fabricate_zero_observations() { + // Honest-null through the runtime: cohort members without observed + // values stay NULL and drop out of the peer pool — absence of rows + // cannot be distinguished from "not covered by the source", so the + // peer query must not invent zeros for them. + for def in [sum_metric(), ratio_metric()] { + let query = compile_view_query( + &def, + &request(), + &ValidatedMetricView::Peer { + cohort_key: "org_unit".to_owned(), + }, + ); + assert!(query.sql.contains("metric_values.value AS value")); + assert!(!query.sql.contains("coalesce(metric_values.value, 0)")); + } } #[test] From 119a6caca9c58c544539461bc5803abc8f5de23f Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 15:28:02 +0200 Subject: [PATCH 15/20] fix(analytics): validate peer cohort keys, backfill class labels, fail fast on view errors - reject explicit peer cohort_key values not declared by the metric instead of silently compiling a query that matches nothing - consume view query results as they complete so the first failure cancels in-flight and queued ClickHouse work - backfill connector-declared label columns on the AI class tables: rows ingested before the columns existed read them as empty strings, which Gold now consumes verbatim; frozen label constants repair history idempotently, and a data-quality test locks the non-empty contract Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- .../analytics/src/api/metric_results.rs | 12 ++--- .../src/domain/metric_results/validation.rs | 35 +++++++++++- .../ai/assert_ai_class_labels_nonempty.sql | 30 +++++++++++ ...20260707000000_ai_class_label_backfill.sql | 54 +++++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/ingestion/dbt/tests/ai/assert_ai_class_labels_nonempty.sql create mode 100644 src/ingestion/scripts/migrations/20260707000000_ai_class_label_backfill.sql diff --git a/src/backend/services/analytics/src/api/metric_results.rs b/src/backend/services/analytics/src/api/metric_results.rs index 0ee6b7985..f318943e6 100644 --- a/src/backend/services/analytics/src/api/metric_results.rs +++ b/src/backend/services/analytics/src/api/metric_results.rs @@ -32,11 +32,6 @@ pub async fn query_metric_results( ) -> Result, CanonicalError> { let req = validate_request(&state.db, ctx.subject_tenant_id(), req).await?; let tasks = compile_tasks(&req); - let results = stream::iter(tasks) - .map(|task| execute_task(&state, &req, task)) - .buffer_unordered(QUERY_CONCURRENCY) - .collect::>() - .await; let mut views_by_metric: Vec>> = req .metrics @@ -44,7 +39,12 @@ pub async fn query_metric_results( .map(|metric| (0..metric.views.len()).map(|_| None).collect()) .collect(); - for result in results { + // Consuming results as they complete bails on the first error; dropping + // the stream cancels the in-flight and queued view queries. + let mut results = stream::iter(tasks) + .map(|task| execute_task(&state, &req, task)) + .buffer_unordered(QUERY_CONCURRENCY); + while let Some(result) = results.next().await { let result = result?; views_by_metric[result.metric_index][result.view_index] = Some(result.view); } diff --git a/src/backend/services/analytics/src/domain/metric_results/validation.rs b/src/backend/services/analytics/src/domain/metric_results/validation.rs index 4570d479d..573dec8cc 100644 --- a/src/backend/services/analytics/src/domain/metric_results/validation.rs +++ b/src/backend/services/analytics/src/domain/metric_results/validation.rs @@ -206,7 +206,19 @@ fn validate_view( MetricViewRequest::Period => Ok(ValidatedMetricView::Period), MetricViewRequest::Peer { cohort_key } => { let cohort_key = match cohort_key { - Some(key) => normalize_key("metrics.views.cohort_key", &key)?, + Some(key) => { + let key = normalize_key("metrics.views.cohort_key", &key)?; + if def.base.peer_cohort_key.as_deref() != Some(key.as_str()) { + return Err(MetricError::invalid_argument() + .with_field_violation( + "metrics.views.cohort_key", + format!("cohort {key} is not declared for metric {}", def.key()), + "INVALID", + ) + .create()); + } + key + } None => def.base.peer_cohort_key.clone().ok_or_else(|| { MetricError::invalid_argument() .with_field_violation( @@ -576,6 +588,27 @@ mod tests { } } + #[test] + fn validate_view_peer_accepts_explicit_declared_cohort() { + let def = sum_definition(vec![]); + let view = MetricViewRequest::Peer { + cohort_key: Some("org_unit".to_owned()), + }; + match validate_view(&def, view) { + Ok(ValidatedMetricView::Peer { cohort_key }) => assert_eq!(cohort_key, "org_unit"), + other => panic!("expected peer, got {other:?}"), + } + } + + #[test] + fn validate_view_peer_rejects_undeclared_cohort() { + let def = sum_definition(vec![]); + let view = MetricViewRequest::Peer { + cohort_key: Some("team".to_owned()), + }; + assert!(validate_view(&def, view).is_err()); + } + #[test] fn validate_view_rejects_empty_breakdown_dimensions() { let def = sum_definition(vec!["tool"]); diff --git a/src/ingestion/dbt/tests/ai/assert_ai_class_labels_nonempty.sql b/src/ingestion/dbt/tests/ai/assert_ai_class_labels_nonempty.sql new file mode 100644 index 000000000..6f605f777 --- /dev/null +++ b/src/ingestion/dbt/tests/ai/assert_ai_class_labels_nonempty.sql @@ -0,0 +1,30 @@ +{{ config( + tags=['data_quality'], + severity='warn', + store_failures=true, + meta={ + 'title': 'AI class label columns are non-empty', + 'domain': 'ai', + 'category': 'contract', + 'tier': 'error', + 'remediation': 'The class contract guarantees connector-declared non-empty tool_label / surface_label — insight.ai_metric_observations builds dimension tuples from them verbatim, with no downstream fallback. An empty label means either a staging model stopped declaring it (fix the staging model) or historical rows predate the column and the label backfill migration (20260707000000_ai_class_label_backfill.sql) has not been applied.' + } +) }} + +SELECT + 'class_ai_dev_usage' AS relation, + insight_tenant_id, + tool AS discriminator, + day +FROM {{ ref('class_ai_dev_usage') }} +WHERE tool_label = '' + +UNION ALL + +SELECT + 'class_ai_assistant_usage' AS relation, + insight_tenant_id, + concat(tool, '/', surface) AS discriminator, + day +FROM {{ ref('class_ai_assistant_usage') }} +WHERE tool_label = '' OR surface_label = '' diff --git a/src/ingestion/scripts/migrations/20260707000000_ai_class_label_backfill.sql b/src/ingestion/scripts/migrations/20260707000000_ai_class_label_backfill.sql new file mode 100644 index 000000000..334abbc8f --- /dev/null +++ b/src/ingestion/scripts/migrations/20260707000000_ai_class_label_backfill.sql @@ -0,0 +1,54 @@ +-- Backfill connector-declared label columns on the AI class tables. +-- +-- Rows ingested before the label columns existed read them as '' (String +-- DEFAULT materialized by on_schema_change='append_new_columns'), while the +-- class contract (silver/ai/schema.yml) requires non-empty labels and Gold +-- consumes them verbatim. The mappings below freeze the labels the staging +-- models declare; new rows are labeled at staging and never match the WHERE. +-- Unknown discriminator values fall back to the value itself so the +-- non-empty contract holds for every row. +-- +-- Idempotent: re-runs match zero rows. +-- +-- Historical STAGING rows keep '' labels (staging tables may not exist when +-- this runs, and the incremental class models never re-read old staging +-- rows). A manual class rebuild must therefore full-refresh the staging +-- models together with the class tables so labels re-derive from the +-- staging model literals. + +ALTER TABLE silver.class_ai_dev_usage ADD COLUMN IF NOT EXISTS tool_label String DEFAULT ''; + +ALTER TABLE silver.class_ai_dev_usage + UPDATE tool_label = multiIf( + tool = 'cursor', 'Cursor', + tool = 'claude_code', 'Claude Code', + tool = 'copilot', 'GitHub Copilot', + tool = 'codex', 'Codex', + tool + ) + WHERE tool_label = '' + SETTINGS mutations_sync = 2; + +ALTER TABLE silver.class_ai_assistant_usage ADD COLUMN IF NOT EXISTS tool_label String DEFAULT ''; +ALTER TABLE silver.class_ai_assistant_usage ADD COLUMN IF NOT EXISTS surface_label String DEFAULT ''; + +ALTER TABLE silver.class_ai_assistant_usage + UPDATE tool_label = multiIf( + tool = 'claude', 'Claude', + tool = 'chatgpt', 'ChatGPT', + tool + ) + WHERE tool_label = '' + SETTINGS mutations_sync = 2; + +ALTER TABLE silver.class_ai_assistant_usage + UPDATE surface_label = multiIf( + surface = 'chat', 'Chat', + surface = 'excel', 'Excel', + surface = 'powerpoint', 'PowerPoint', + surface = 'cowork', 'Cowork', + surface = 'cross', 'Cross', + surface + ) + WHERE surface_label = '' + SETTINGS mutations_sync = 2; From 2866dc62cc918a1fe40ac60e10311bd97dada166 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 17:35:15 +0200 Subject: [PATCH 16/20] fix(analytics): duplicate-proof peer disclosure and pinned join semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review findings: cohort membership rows are unique per warehouse tenant, so an entity ingested under two workspace ids fanned out the peer join — inflating n, merging pools, and letting duplicates satisfy the disclosure floor. Membership CTEs now dedup and the floor and n count distinct entities via uniqExact. The peer query also pins join_use_nulls=1 so honest-null absence cannot be reverted by server configuration or a future non-Nullable measure column (ClickHouse default-fills unmatched LEFT JOIN rows otherwise). Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 8 +++-- .../src/domain/metric_results/compiler.rs | 30 ++++++++++++------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index a80d979a5..3c0220b41 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -267,9 +267,11 @@ Execution rules: - Target entities without observed values get a null `target_value`. - Null values are excluded from peer percentiles and `n`. - Peer percentiles and min/max are suppressed (returned as null) when the - peer pool has fewer than 5 members; `n` still reports the pool size. - Quartiles over a handful of people are noise, and tiny pools disclose - individual values. Enforced server-side so every consumer inherits it. + peer pool has fewer than 5 distinct observed members; `n` reports that + distinct count. Quartiles over a handful of people are noise, and tiny + pools disclose individual values. Enforced server-side so every consumer + inherits it, and counted with `uniqExact` so duplicate cohort membership + rows can neither inflate the pool nor defeat the floor. ## Validation diff --git a/src/backend/services/analytics/src/domain/metric_results/compiler.rs b/src/backend/services/analytics/src/domain/metric_results/compiler.rs index 3378f9dcc..571172f6d 100644 --- a/src/backend/services/analytics/src/domain/metric_results/compiler.rs +++ b/src/backend/services/analytics/src/domain/metric_results/compiler.rs @@ -254,7 +254,7 @@ fn compile_peer_query( r" WITH targets AS ( - SELECT + SELECT DISTINCT entity_id, cohort_id FROM {cohort_table} @@ -264,7 +264,7 @@ fn compile_peer_query( AND cohort_id IS NOT NULL ), cohort AS ( - SELECT + SELECT DISTINCT entity_id, cohort_id FROM {cohort_table} @@ -292,6 +292,7 @@ fn compile_peer_query( peers AS ( SELECT cohort_id, + entity_id, value FROM entity_values WHERE value IS NOT NULL @@ -299,12 +300,12 @@ fn compile_peer_query( SELECT targets.entity_id AS entity_id, target_values.value AS target_value, - if(count(peers.value) >= {min_peer_n}, toNullable(quantileExact(0.25)(peers.value)), NULL) AS p25, - if(count(peers.value) >= {min_peer_n}, toNullable(quantileExact(0.5)(peers.value)), NULL) AS median, - if(count(peers.value) >= {min_peer_n}, toNullable(quantileExact(0.75)(peers.value)), NULL) AS p75, - if(count(peers.value) >= {min_peer_n}, toNullable(min(peers.value)), NULL) AS min, - if(count(peers.value) >= {min_peer_n}, toNullable(max(peers.value)), NULL) AS max, - toUInt64(count(peers.value)) AS n + if(uniqExact(peers.entity_id) >= {min_peer_n}, toNullable(quantileExact(0.25)(peers.value)), NULL) AS p25, + if(uniqExact(peers.entity_id) >= {min_peer_n}, toNullable(quantileExact(0.5)(peers.value)), NULL) AS median, + if(uniqExact(peers.entity_id) >= {min_peer_n}, toNullable(quantileExact(0.75)(peers.value)), NULL) AS p75, + if(uniqExact(peers.entity_id) >= {min_peer_n}, toNullable(min(peers.value)), NULL) AS min, + if(uniqExact(peers.entity_id) >= {min_peer_n}, toNullable(max(peers.value)), NULL) AS max, + toUInt64(uniqExact(peers.entity_id)) AS n FROM targets LEFT JOIN entity_values AS target_values ON target_values.entity_id = targets.entity_id @@ -312,6 +313,7 @@ fn compile_peer_query( ON peers.cohort_id = targets.cohort_id GROUP BY targets.entity_id, target_values.value LIMIT {limit} + SETTINGS join_use_nulls = 1 ", metric_where = metric_where(def), min_peer_n = MIN_PEER_N, @@ -674,13 +676,21 @@ mod tests { cohort_key: "org_unit".to_owned(), }, ); - let guard = format!("count(peers.value) >= {MIN_PEER_N}"); + let guard = format!("uniqExact(peers.entity_id) >= {MIN_PEER_N}"); assert_eq!( query.sql.matches(&guard).count(), 5, "every percentile/min/max must carry the disclosure guard" ); - assert!(query.sql.contains("toUInt64(count(peers.value)) AS n")); + assert!( + query + .sql + .contains("toUInt64(uniqExact(peers.entity_id)) AS n") + ); + // Duplicate cohort membership must not fan out the pool. + assert_eq!(query.sql.matches("SELECT DISTINCT").count(), 2); + // Honest-null must not depend on server config or column typing. + assert!(query.sql.contains("SETTINGS join_use_nulls = 1")); } } From 53611664af39289623ac0c71e36bba70b54fb701 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 18:27:30 +0200 Subject: [PATCH 17/20] feat(analytics): periodic metric definition validation sweep Managed observation relations are dbt-created and can appear after the service boots (fresh deploys) or regress later; a one-shot startup scan pinned table_not_found until the next pod restart, and the registry has no write path that would re-trigger probing. The validator now sweeps every five minutes; probes are idempotent and transient failures never overwrite an established status. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 4 ++++ .../src/domain/metric_definitions/validator.rs | 17 +++++++++++++++++ src/backend/services/analytics/src/gear.rs | 7 ++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index 3c0220b41..66f458f07 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -326,6 +326,10 @@ Schema validation checks: `unchecked`, never `error`: filtered measures legitimately go quiet, and absence of data is indistinguishable from an unemitted measure. - probe failures never overwrite a previously established status. +- the validator sweeps periodically, not once at startup: managed relations + are dbt-created and may appear after the service boots (fresh deploys) or + regress later (a bad model change); both converge within one sweep with no + restart. - warehouse diagnostics stay server-side. ## Adding a Metric diff --git a/src/backend/services/analytics/src/domain/metric_definitions/validator.rs b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs index 823cf1dfe..4077e878a 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/validator.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/validator.rs @@ -12,6 +12,12 @@ use crate::domain::metric_definitions::repository::{ }; const PROBE_WINDOW_DAYS: u32 = 35; +// Managed observation relations are dbt-created and can appear (or regress) +// while the service is running — a one-shot startup scan would pin +// `table_not_found` until the next pod restart. Sweeps are idempotent: +// transient probe failures never overwrite an established status, and +// status writes pin `updated_at`. +const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); #[derive(Clone)] pub struct MetricDefinitionValidator { @@ -24,6 +30,17 @@ impl MetricDefinitionValidator { Self { db, ch } } + /// Periodic sweep: validates immediately, then every [`SWEEP_INTERVAL`]. + /// Never returns; run it on a spawned task. + pub async fn run(self) { + let mut ticks = tokio::time::interval(SWEEP_INTERVAL); + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + ticks.tick().await; + self.validate_all().await; + } + } + pub async fn validate_all(&self) { let sources = match all_managed_sources(&self.db).await { Ok(sources) => sources, diff --git a/src/backend/services/analytics/src/gear.rs b/src/backend/services/analytics/src/gear.rs index aa312b76a..904d84963 100644 --- a/src/backend/services/analytics/src/gear.rs +++ b/src/backend/services/analytics/src/gear.rs @@ -170,9 +170,10 @@ impl Gear for AnalyticsApiGear { tokio::spawn(async move { validator.validate_all().await; }); - tokio::spawn(async move { - metric_definition_validator.validate_all().await; - }); + // Periodic, not one-shot: the managed observation views are + // dbt-created after boot on a fresh deploy, and the registry has no + // write path that would re-trigger probing. + tokio::spawn(metric_definition_validator.run()); Ok(()) } From 70b4ebdc4e298527a07d2d4f5a35b0235ee79d5a Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 18:56:31 +0200 Subject: [PATCH 18/20] feat(ingestion): build gold models at deploy in the ClickHouse migrate hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbt-owned gold views previously appeared only after the first connector sync following a deploy — hours on a scheduled instance — during which the analytics service reported every dependent metric unavailable. The migrate hook Job already runs the toolbox image with the dbt project bundled, and the bronze/silver placeholders guarantee the view DDL type-checks on a fresh cluster, so a final 'dbt run --select tag:gold' step makes the views exist right after every deploy. Profile generation mirrors the dbt-run WorkflowTemplate (env-only, no text interpolation). Also map ClickHouse unknown-table errors on the metric-results path to a typed precondition failure instead of a 500: a missing relation is a known transient state the validation sweep converges on. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- .../templates/clickhouse-migrate-job.yaml | 7 +++ docs/domain/metrics/specs/DESIGN.md | 7 +++ .../analytics/src/api/metric_results.rs | 47 +++++++++++++++++- src/ingestion/scripts/apply-ch-migrations.sh | 48 +++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/charts/insight/templates/clickhouse-migrate-job.yaml b/charts/insight/templates/clickhouse-migrate-job.yaml index 39bf65613..c333bfee1 100644 --- a/charts/insight/templates/clickhouse-migrate-job.yaml +++ b/charts/insight/templates/clickhouse-migrate-job.yaml @@ -36,6 +36,13 @@ Idempotent: migrations are re-run on every upgrade and are CREATE OR REPLACE / IF NOT EXISTS; placeholders are guarded by `ch_table_exists`. No migration ledger — same contract as the legacy init.sh path. +The script's final step builds the dbt gold models (`dbt run --select +tag:gold`) so dbt-owned views exist right after deploy instead of after +the first connector sync — the analytics service reports metrics as +unavailable while an observation view is missing. The toolbox image +already bundles the dbt project; placeholders guarantee the view DDL +type-checks on a fresh cluster. + Gated on `clickhouse.runMigrations` (default true) AND the availability of `ingestion.toolboxImage` (the image that ships the migration SQL). */}} diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index 66f458f07..cda8bdfaa 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -69,6 +69,13 @@ compilation, and runtime schema validation against these relations. Column changes are coordinated changes: dbt model + `schema.yml` + backend `OBSERVATION_COLUMNS`/`COHORT_COLUMNS` + this document. +Gold models are built at deploy time by the ClickHouse migrate hook +(`dbt run --select tag:gold`, final step of +`src/ingestion/scripts/apply-ch-migrations.sh`), so the views exist before +any connector sync — bronze/silver placeholders guarantee the DDL +type-checks on a fresh cluster. Per-connector scoped dbt runs keep them +current afterwards. + The cohort view is unique per `(tenant_id, entity_type, entity_id, cohort_key)`. The peer query relies on this; a dbt build-integrity test asserts it. diff --git a/src/backend/services/analytics/src/api/metric_results.rs b/src/backend/services/analytics/src/api/metric_results.rs index f318943e6..0bb48a7fb 100644 --- a/src/backend/services/analytics/src/api/metric_results.rs +++ b/src/backend/services/analytics/src/api/metric_results.rs @@ -8,6 +8,7 @@ use serde::de::DeserializeOwned; use toolkit_canonical_errors::CanonicalError; use super::AppState; +use super::error::MetricError; use crate::domain::metric_definitions::MetricDefinition; use crate::domain::metric_results::{ BreakdownQueryRow, CompiledQuery, MetricResultViewDto, MetricResultsRequest, @@ -153,7 +154,7 @@ where let mut cursor = ch_query.fetch_bytes("JSONEachRow").map_err(|e| { tracing::error!(error = %e, sql = %query.sql, "ClickHouse metric-results query failed"); - CanonicalError::internal("query execution failed").create() + map_query_error(&e.to_string()) })?; let raw_bytes = tokio::time::timeout(QUERY_FETCH_TIMEOUT, cursor.collect()) @@ -164,7 +165,7 @@ where })? .map_err(|e| { tracing::error!(error = %e, sql = %query.sql, "ClickHouse metric-results fetch failed"); - CanonicalError::internal("query execution failed").create() + map_query_error(&e.to_string()) })?; if raw_bytes.is_empty() { @@ -181,3 +182,45 @@ where CanonicalError::internal("failed to parse query results").create() }) } + +// A missing observation/cohort relation is a known transient state (dbt has +// not built the view yet, or a model regressed) that the validator sweep +// converges on — surface it as a typed precondition failure instead of a +// 500. UNKNOWN_TABLE is ClickHouse error code 60. +fn map_query_error(message: &str) -> CanonicalError { + if message.contains("UNKNOWN_TABLE") || message.contains("Code: 60") { + return MetricError::failed_precondition() + .with_precondition_violation( + "metric source relation", + "The observation or cohort view backing this metric has not been built yet; it converges on the next validation sweep.", + "SOURCE_RELATION_MISSING", + ) + .create(); + } + CanonicalError::internal("query execution failed").create() +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use axum::response::IntoResponse; + + use super::map_query_error; + + #[test] + fn missing_relation_maps_to_precondition_failure_not_500() { + let err = map_query_error( + "bad response: Code: 60. DB::Exception: Table insight.ai_metric_observations does not exist. (UNKNOWN_TABLE)", + ); + let status = err.into_response().status(); + assert_ne!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(status.is_client_error()); + } + + #[test] + fn other_query_errors_stay_internal() { + let err = map_query_error("Code: 241. DB::Exception: Memory limit exceeded"); + let status = err.into_response().status(); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/src/ingestion/scripts/apply-ch-migrations.sh b/src/ingestion/scripts/apply-ch-migrations.sh index 1fe60a678..737229063 100755 --- a/src/ingestion/scripts/apply-ch-migrations.sh +++ b/src/ingestion/scripts/apply-ch-migrations.sh @@ -14,6 +14,8 @@ # stubs so gold-view CREATE VIEW type-checks on a fresh cluster # (CH validates referenced tables at parse time). See ADR-0007. # 3. Apply migrations/*.sql in lexicographic order. +# 4. Build the dbt gold models (tag:gold) so dbt-owned views exist at +# deploy time instead of after the first connector sync. # # Bookkeeping: none — every migration is re-run on every invocation and # MUST stay idempotent/re-runnable (CREATE OR REPLACE / IF NOT EXISTS). @@ -49,4 +51,50 @@ for migration in "$SCRIPT_DIR/migrations"/*.sql; do run_ch < "$migration" done +echo "=== Building gold models (dbt run --select tag:gold) ===" +# Gold views are dbt-owned but must exist at DEPLOY time, not first-sync +# time: the analytics service marks metric definitions schema-error while +# an observation view is missing, which blanks those metrics for every +# frontend request until the first connector sync builds the view (hours +# on a scheduled instance). The placeholders created above guarantee every +# relation the views reference exists, so this run type-checks on a fresh +# cluster — the same guarantee the scoped per-connector dbt runs rely on +# for sideways refs. Idempotent: view materialization is create-or-replace. +# +# Profile generation mirrors the dbt-run WorkflowTemplate: python3 writes +# profiles.yml from env vars, never interpolating values into YAML text. +DBT_PROFILES_DIR="$(mktemp -d)" +export DBT_PROFILES_DIR +python3 - <<'PY' +import os +from urllib.parse import urlparse + +import yaml + +url = urlparse(os.environ["CLICKHOUSE_URL"]) +profile = { + "ingestion": { + "target": "migrate", + "outputs": { + "migrate": { + "type": "clickhouse", + "host": url.hostname, + "port": url.port or (8443 if url.scheme == "https" else 8123), + "schema": "silver", + "user": os.environ["CLICKHOUSE_USER"], + "password": os.environ["CLICKHOUSE_PASSWORD"], + "secure": url.scheme == "https", + "send_receive_timeout": 1500, + "query_limit": 0, + "connect_timeout": 30, + } + }, + } +} +with open(os.path.join(os.environ["DBT_PROFILES_DIR"], "profiles.yml"), "w") as f: + yaml.safe_dump(profile, f) +PY +(cd "$SCRIPT_DIR/../dbt" && dbt run --profiles-dir "$DBT_PROFILES_DIR" --log-format json --select tag:gold) +rm -rf "$DBT_PROFILES_DIR" + echo "=== ClickHouse migrations complete ===" From ba479fd3f08033fd01ef4f89b9d8b3e7f0672a39 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 7 Jul 2026 19:31:57 +0200 Subject: [PATCH 19/20] feat(ingestion): backfill AI class-contract history via semver bumps and label repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Historical staging rows predate the class-contract columns and are never re-read by the incremental models. Split by what each lever can deliver: - conversation_count is source data only a re-materialization can backfill: major-bump claude-enterprise, claude-team and chatgpt-team so reconcile dispatches the ADR-0015 one-shot scoped full refresh on the next deploy - labels are constants declared by the staging models: a guarded repair step in the migrate hook writes the same values a rebuild would produce, covering every connector on every instance at deploy time — including connectors the bump mechanism cannot reach Document the backfill rule in the metrics design spec for future migration waves. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- docs/domain/metrics/specs/DESIGN.md | 7 +++ .../ai/chatgpt-team/descriptor.yaml | 6 ++- .../ai/claude-enterprise/descriptor.yaml | 7 ++- .../connectors/ai/claude-team/descriptor.yaml | 6 ++- src/ingestion/scripts/apply-ch-migrations.sh | 48 +++++++++++++++++++ ...20260707000000_ai_class_label_backfill.sql | 10 ++-- 6 files changed, 76 insertions(+), 8 deletions(-) diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index cda8bdfaa..62ad4323b 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -407,6 +407,13 @@ The metric family reads data no managed source covers. - Measure filter predicates (`where=` on shape macros) may reference only class-contract dimension columns and their normalized values — never vendor columns, tool names, or label text. +- Adding a class-contract column to existing staging models needs a history + backfill plan: columns derived from source data require re-materialization + — major-bump every affected connector (ADR-0015 dispatches a scoped + one-shot full refresh; CDK connectors need an explicit invocation until + the toolkit closes its semver-storage gap). Declared-constant columns + (labels) are repaired in place by the migrate hook; any rebuild + independently converges to the same values. - No new `metric_catalog` seed migrations and no new ad-hoc `insight.*` views for metrics. - Do not add runtime formula JSON until generation exists. diff --git a/src/ingestion/connectors/ai/chatgpt-team/descriptor.yaml b/src/ingestion/connectors/ai/chatgpt-team/descriptor.yaml index 351bd6e22..4a82eec5a 100644 --- a/src/ingestion/connectors/ai/chatgpt-team/descriptor.yaml +++ b/src/ingestion/connectors/ai/chatgpt-team/descriptor.yaml @@ -12,7 +12,11 @@ name: chatgpt-team # leaderboard (daily range only), remove the hardcoded min_datetime floor, # add lookback_window P2D, dedup on lower(trim(email)) and add a Codex # activity filter. Behaviour + dbt output change → reconcile must re-run. -version: "1.1.1" +# 2.0.0: dbt staging maps Codex threads (n_threads) into class-contract +# conversation_count and declares tool_label / surface_label — MAJOR per +# ADR-0015: pre-existing rows must re-materialize from Bronze to populate +# the new columns (one-shot scoped full refresh). +version: "2.0.0" # Daily at 04:00 UTC — off-peak. The customer's proxy runs a single browser # instance, so we avoid scheduling chatgpt-team next to anything else that # may also hit it (same rationale as claude-team). diff --git a/src/ingestion/connectors/ai/claude-enterprise/descriptor.yaml b/src/ingestion/connectors/ai/claude-enterprise/descriptor.yaml index 8ac027906..5d31f1668 100644 --- a/src/ingestion/connectors/ai/claude-enterprise/descriptor.yaml +++ b/src/ingestion/connectors/ai/claude-enterprise/descriptor.yaml @@ -1,5 +1,10 @@ name: claude-enterprise -version: "2.0.0" +# 3.0.0: dbt staging maps code_session_count / chat_conversation_count into +# class-contract conversation_count and declares tool_label / +# surface_label — MAJOR per ADR-0015: pre-existing rows must +# re-materialize from Bronze to populate the new columns (one-shot scoped +# full refresh). +version: "3.0.0" schedule: "0 11 * * *" # daily at 11:00 UTC — accommodates Anthropic's 10:00 UTC aggregation workflow: sync diff --git a/src/ingestion/connectors/ai/claude-team/descriptor.yaml b/src/ingestion/connectors/ai/claude-team/descriptor.yaml index ff1dcf85f..4013a873a 100644 --- a/src/ingestion/connectors/ai/claude-team/descriptor.yaml +++ b/src/ingestion/connectors/ai/claude-team/descriptor.yaml @@ -11,7 +11,11 @@ name: claude-team # limit_type) and new dbt models (claude_team__ai_overage → class_ai_overage). # Populates only once the proxy sessionKey holds billing:view / Owner; # otherwise the stream stays empty and the Silver rows are zero (no failure). -version: "1.3.0" +# 2.0.0: dbt staging maps total_sessions into class-contract +# conversation_count and declares tool_label — MAJOR per ADR-0015: +# pre-existing rows must re-materialize from Bronze to populate the new +# columns (one-shot scoped full refresh). +version: "2.0.0" # Daily at 04:00 UTC — off-peak vs other connectors. The customer's # proxy runs a single browser instance, so we avoid scheduling # claude-team next to anything else that may also hit it. diff --git a/src/ingestion/scripts/apply-ch-migrations.sh b/src/ingestion/scripts/apply-ch-migrations.sh index 737229063..727a4d52c 100755 --- a/src/ingestion/scripts/apply-ch-migrations.sh +++ b/src/ingestion/scripts/apply-ch-migrations.sh @@ -51,6 +51,54 @@ for migration in "$SCRIPT_DIR/migrations"/*.sql; do run_ch < "$migration" done +echo "=== Repairing class-contract labels on AI staging history ===" +# Staging rows ingested before the label columns existed read them as '' +# (String DEFAULT materialized by append_new_columns), and incremental +# models never re-read old rows. Labels are DECLARED CONSTANTS in the +# staging models, so writing the same constants here is byte-identical to +# what a full re-materialization would produce — any later full refresh +# independently converges to the same values and these updates become +# permanent no-ops. Data-bearing contract columns are NOT repairable this +# way; those go through the ADR-0015 major-bump full refresh instead. +# Guarded per table: staging tables do not exist before the connector's +# first dbt run. Idempotent: re-runs match zero rows. +repair_staging_tool_label() { + local table="$1" label="$2" + ch_table_exists staging "${table}" || return 0 + echo " staging.${table}" + run_ch < Date: Tue, 7 Jul 2026 19:34:53 +0200 Subject: [PATCH 20/20] fix(analytics): restore canonical form of committed OpenAPI doc The drift gate compares the committed doc against the normalized live spec, so the committed file must be written through scripts/ci/openapi_spec.py update, not a raw emitter dump. Co-Authored-By: Claude Fable 5 Signed-off-by: Aleksandr Barkhatov --- .../components/backend/analytics/openapi.json | 4694 ++++++++--------- 1 file changed, 2347 insertions(+), 2347 deletions(-) diff --git a/docs/components/backend/analytics/openapi.json b/docs/components/backend/analytics/openapi.json index c55fe2666..447fcac82 100644 --- a/docs/components/backend/analytics/openapi.json +++ b/docs/components/backend/analytics/openapi.json @@ -1,3109 +1,3109 @@ { - "openapi": "3.1.0", - "info": { - "title": "Analytics API", - "description": "Read-only query service over predefined ClickHouse metrics. Admins define metrics (named SQL queries) in MariaDB; the frontend queries them by UUID with OData-style filtering. The API Gateway mounts this service at /api/analytics.", - "version": "1.0.0" - }, - "paths": { - "/v1/admin/metric-thresholds": { - "get": { - "summary": "List admin metric thresholds", - "operationId": "analytics_api.admin.thresholds.list", - "responses": { - "200": { - "description": "List of metric thresholds", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListResponse" - } - } - } + "components": { + "schemas": { + "AdminMetricThresholdView": { + "description": "On-wire shape of one `metric_threshold` row in list / get responses.\n\n`metric_key` is NOT serialized — same backend-internal opacity rule the\nread endpoint follows (`domain/catalog/response.rs::MetricView`).\nConsumers identify a metric by `metric_id`.\n\nThe OpenAPI component is named `AdminMetricThresholdView` (via\n`#[schema(as)]`) to disambiguate from the catalog read path's\n`ThresholdView` (`domain::catalog::response::ThresholdView`, registered as\n`CatalogThresholdView`), which is a different wire shape. `#[schema(as)]`\nrenames only the OpenAPI component — it does NOT affect serde / the wire\nformat.", + "properties": { + "alert_bad": { + "format": "double", + "type": [ + "number", + "null" + ] }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "alert_trigger": { + "format": "double", + "type": [ + "number", + "null" + ] }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "good": { + "format": "double", + "type": "number" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "id": { + "format": "uuid", + "type": "string" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "is_locked": { + "type": "boolean" }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "lock_reason": { + "type": [ + "string", + "null" + ] }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "locked_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "summary": "Create an admin metric threshold", - "operationId": "analytics_api.admin.thresholds.create", - "requestBody": { - "description": "Metric threshold to create", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateRequest" - } - } + "locked_by": { + "type": [ + "string", + "null" + ] }, - "required": true - }, - "responses": { - "201": { - "description": "Created metric threshold", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminMetricThresholdView" - } - } - } + "metric_id": { + "description": "UUIDv7 of the corresponding `metric_catalog` row.", + "format": "uuid", + "type": "string" }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "role_slug": { + "description": "Empty-string sentinel collapsed to `None` on the wire so the JSON\nshape is `null` instead of `\"\"` (the latter would confuse FE\n\"is this set?\" predicates).", + "type": [ + "string", + "null" + ] }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "schema_error_code": { + "description": "Canonical error code (`table_not_found | column_not_found |\nclickhouse_unreachable | unknown`) when `schema_status = \"error\"`,\notherwise omitted.", + "type": [ + "string", + "null" + ] }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "schema_status": { + "description": "One of `ok | error | unchecked`, joined from `metric_catalog.schema_status`\n(DESIGN §3.3 \"Schema status surface\"). Lets the admin UI flag a\nbroken metric before the operator submits a write.", + "type": "string" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "scope": { + "$ref": "#/components/schemas/Scope" }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "team_id": { + "type": [ + "string", + "null" + ] }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "tenant_id": { + "description": "`Some(_)` for tenant-scoped rows, `None` for `product-default`.", + "format": "uuid", + "type": [ + "string", + "null" + ] }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "warn": { + "format": "double", + "type": "number" } }, - "security": [ + "required": [ + "id", + "metric_id", + "scope", + "good", + "warn", + "is_locked", + "schema_status" + ], + "type": "object" + }, + "BatchQueryItem": { + "allOf": [ { - "bearerAuth": [] + "$ref": "#/components/schemas/QueryRequest" + }, + { + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "metric_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "metric_id" + ], + "type": "object" } ] - } - }, - "/v1/admin/metric-thresholds/{id}": { - "get": { - "summary": "Get an admin metric threshold by id", - "operationId": "analytics_api.admin.thresholds.get", - "responses": { - "200": { - "description": "Metric threshold", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminMetricThresholdView" - } + }, + "BatchQueryRequest": { + "properties": { + "queries": { + "items": { + "$ref": "#/components/schemas/BatchQueryItem" + }, + "type": "array" + } + }, + "required": [ + "queries" + ], + "type": "object" + }, + "BatchQueryResponse": { + "properties": { + "results": { + "items": { + "$ref": "#/components/schemas/BatchQueryResult" + }, + "type": "array" + } + }, + "required": [ + "results" + ], + "type": "object" + }, + "BatchQueryResult": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/QueryResponse" + }, + { + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "metric_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "metric_id" + ], + "type": "object" + }, + { + "properties": { + "status": { + "enum": [ + "ok" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" } - } + ] }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } + { + "properties": { + "error": { + "$ref": "#/components/schemas/Problem" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "metric_id": { + "format": "uuid", + "type": "string" + }, + "status": { + "enum": [ + "error" + ], + "type": "string" } - } + }, + "required": [ + "metric_id", + "error", + "status" + ], + "type": "object" + } + ] + }, + "CatalogResponse": { + "description": "Top-level response body. `tenant_id` is echoed for client-side cache\nreasoning AND re-asserted on cache hydrate as defense in depth against a\nmisconfigured cache backend serving a sibling tenant's payload.\n\n`links` carries the `metric_query_catalog` M:N mapping per ADR-003. The\nmapping is time/filter-invariant, so consumers cache it for the same TTL as\nthe catalog itself; see [`MetricQueryLink`].", + "properties": { + "generated_at": { + "format": "date-time", + "type": "string" }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "links": { + "items": { + "$ref": "#/components/schemas/MetricQueryLink" + }, + "type": "array" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "metrics": { + "items": { + "$ref": "#/components/schemas/MetricView" + }, + "type": "array" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "tenant_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "tenant_id", + "generated_at", + "metrics", + "links" + ], + "type": "object" + }, + "CatalogThresholdView": { + "description": "Resolved threshold for one metric.\n\n`good` / `warn` are `f64` on the wire — DECIMAL(20,6) in the DB rounds-trips\nthrough DOUBLE for every seed value (integers and one-decimal floats). If\nfuture seed entries need full-precision decimals, this is the place to switch\nto a string serializer; the FE byte-for-byte comparison gate (PRD §12) is\nthe regression detector.\n\nThe OpenAPI component is named `CatalogThresholdView` (via `#[schema(as)]`)\nto disambiguate from the admin-CRUD `ThresholdView`\n(`domain::admin_threshold::dto::ThresholdView`), which is a different wire\nshape registered under `AdminMetricThresholdView`. `#[schema(as)]` renames\nonly the OpenAPI component — it does NOT affect serde / the wire format.", + "properties": { + "alert_bad": { + "format": "double", + "type": [ + "number", + "null" + ] }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "alert_trigger": { + "format": "double", + "type": [ + "number", + "null" + ] }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "bounded_by_lock": { + "description": "`true` iff the walk halted on a locked broader-scope row before reaching\nthe most-specific candidate. Separate signal from `resolved_from`, which\nalways names the row that won.", + "type": "boolean" }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "good": { + "format": "double", + "type": "number" + }, + "resolved_from": { + "description": "One of `\"team+role\" | \"team\" | \"role\" | \"tenant\" | \"product-default\"`.\nNames the row that won the walk.", + "type": "string" + }, + "warn": { + "format": "double", + "type": "number" } }, - "security": [ - { - "bearerAuth": [] + "required": [ + "good", + "warn", + "resolved_from", + "bounded_by_lock" + ], + "type": "object" + }, + "ColumnListResponse": { + "description": "Response envelope for `GET /v1/columns` and `GET /v1/columns/{table}`\n(`{ \"items\": [TableColumn] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the\nhandlers emit — gives the column-list endpoints a real OpenAPI schema.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TableColumn" + }, + "type": "array" } - ] + }, + "required": [ + "items" + ], + "type": "object" }, - "put": { - "summary": "Update an admin metric threshold", - "operationId": "analytics_api.admin.thresholds.update", - "requestBody": { - "description": "Metric threshold fields to update", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRequest" - } - } + "CreateMetricRequest": { + "description": "Request to create a new metric.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] }, - "required": true + "name": { + "type": "string" + }, + "query_ref": { + "type": "string" + } }, - "responses": { - "200": { - "description": "Updated metric threshold", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminMetricThresholdView" - } - } - } + "required": [ + "name", + "query_ref" + ], + "type": "object" + }, + "CreateRequest": { + "additionalProperties": false, + "description": "`POST /v1/admin/metric-thresholds` body — create a new threshold row.\n\n`tenant_id` / `id` / `locked_by` / `locked_at` / `created_at` /\n`updated_at` are NOT accepted from the body. `deny_unknown_fields`\nenforces that at the serde layer.\n\n`role_slug` / `team_id` use `Option` — `None` is the canonical\nempty-string sentinel (DESIGN §3.7 + `infra/cache/catalog_cache.rs::cache_field`).", + "properties": { + "alert_bad": { + "format": "double", + "type": [ + "number", + "null" + ] }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "alert_trigger": { + "format": "double", + "type": [ + "number", + "null" + ] }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "good": { + "format": "double", + "type": "number" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "is_locked": { + "type": "boolean" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "lock_reason": { + "type": [ + "string", + "null" + ] }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "metric_id": { + "format": "uuid", + "type": "string" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "role_slug": { + "type": [ + "string", + "null" + ] }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "scope": { + "$ref": "#/components/schemas/Scope" + }, + "team_id": { + "type": [ + "string", + "null" + ] + }, + "warn": { + "format": "double", + "type": "number" } }, - "security": [ - { - "bearerAuth": [] + "required": [ + "metric_id", + "scope", + "good", + "warn" + ], + "type": "object" + }, + "CreateThresholdRequest": { + "description": "Request to create a threshold.", + "properties": { + "field_name": { + "type": "string" + }, + "level": { + "description": "Result level: `good`, `warning`, `critical`.", + "type": "string" + }, + "operator": { + "description": "Comparison operator: `gt`, `ge`, `lt`, `le`, `eq`.", + "type": "string" + }, + "value": { + "format": "double", + "type": "number" } - ] + }, + "required": [ + "field_name", + "operator", + "value", + "level" + ], + "type": "object" }, - "delete": { - "summary": "Delete an admin metric threshold", - "operationId": "analytics_api.admin.thresholds.delete", - "responses": { - "204": { - "description": "Metric threshold deleted" + "GetMetricsRequest": { + "additionalProperties": false, + "description": "Request body for `POST /v1/catalog/get_metrics`.\n\n`tenant_id` is intentionally NOT accepted here — it is resolved server-side\nfrom the session by `tenant_middleware` (Refs #522 auth-trait). Allowing a\nbody-supplied `tenant_id` would open a cross-tenant disclosure surface.\n`deny_unknown_fields` enforces that defensively at the parser layer: a\ncaller that smuggles `\"tenant_id\": \"...\"` into the body gets a 400 instead\nof a silent ignore.", + "properties": { + "role_slug": { + "description": "Role slug for `role` / `team+role` resolution chains. `None` and `Some(\"\")`\nare semantically identical and produce the same cache key (canonical\nempty-string sentinel — see `cache_key` in the cache layer).", + "type": [ + "string", + "null" + ] }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "team_id": { + "description": "Team id for `team` / `team+role` resolution chains. Same `None` vs `Some(\"\")`\nequivalence as `role_slug`.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ListResponse": { + "description": "`GET /v1/admin/metric-thresholds` response envelope.\n\nWraps `items` in an object (instead of a bare array) so future\nadditions (pagination cursor, count, generated-at) are additive and\nnon-breaking. Mirrors the catalog read endpoint's envelope shape.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AdminMetricThresholdView" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "Metric": { + "description": "A metric definition — an admin-configured SQL query against `ClickHouse`.\n\nThe `query_ref` field holds raw `ClickHouse` SQL. The query engine wraps it\nas a subquery, appending security filters + `OData` filters as parameterized\nWHERE clauses.", + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "id": { + "format": "uuid", + "type": "string" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "insight_tenant_id": { + "format": "uuid", + "type": "string" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "is_enabled": { + "type": "boolean" }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "name": { + "type": "string" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "query_ref": { + "type": "string" }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "updated_at": { + "format": "date-time", + "type": "string" } }, - "security": [ - { - "bearerAuth": [] + "required": [ + "id", + "insight_tenant_id", + "name", + "query_ref", + "is_enabled", + "created_at", + "updated_at" + ], + "type": "object" + }, + "MetricListResponse": { + "description": "Response envelope for `GET /v1/metrics` (`{ \"items\": [MetricSummary] }`).\n\nDocs-only wrapper: the handler emits the same object shape via an inline\n`serde_json::json!` literal. Existing on the wire; this type just gives the\nlist endpoint a real OpenAPI schema instead of a generic object.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MetricSummary" + }, + "type": "array" } - ] - } - }, - "/v1/catalog/get_metrics": { - "post": { - "summary": "Read the metric catalog for the request context", - "operationId": "analytics_api.catalog.get_metrics", - "requestBody": { - "description": "Catalog read request context (role, team)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetMetricsRequest" - } - } - }, - "required": true }, - "responses": { - "200": { - "description": "Resolved metric catalog", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CatalogResponse" - } - } - } + "required": [ + "items" + ], + "type": "object" + }, + "MetricQueryLink": { + "description": "One link row from `metric_query_catalog`. Tells a consumer which catalog\nrows a `metrics.query_ref` emits when executed — the M:N answer ADR-001\nadded at the DB layer, surfaced here so consumers don't have to derive it\nby joining on backend-internal `metric_key` strings.\n\n`catalog_metric_ids` is the set of `metric_catalog.id` UUIDs the query\nproduces. The set is empty only when the linked catalog rows are all\n`is_enabled = false` (filtered out of the `metrics` array) — consumers\ndegrade gracefully on empty.", + "properties": { + "catalog_metric_ids": { + "description": "`metric_catalog.id` UUIDs this query emits. Sorted ascending so the\nwire payload is byte-stable for cache + diff tooling.", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "query_id": { + "description": "`metrics.id` — the ClickHouse `query_ref` row this link is FROM.", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "query_id", + "catalog_metric_ids" + ], + "type": "object" + }, + "MetricSummary": { + "description": "Summary returned in list endpoints (no `query_ref`).", + "properties": { + "description": { + "type": [ + "string", + "null" + ] }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "id": { + "format": "uuid", + "type": "string" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "MetricView": { + "description": "One catalog metric on the wire. `metric_key` is surfaced per ADR-002 as the\ntransitional FE-bridge identifier; consumers MUST still key lookups by `id`.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "format": { + "type": [ + "string", + "null" + ] }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "higher_is_better": { + "type": "boolean" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "id": { + "format": "uuid", + "type": "string" }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/v1/columns": { - "get": { - "summary": "List queryable columns", - "operationId": "analytics_api.columns.list", - "responses": { - "200": { - "description": "List of columns", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ColumnListResponse" - } - } - } + "is_member_scale": { + "type": "boolean" }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "label": { + "type": "string" }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "metric_key": { + "description": "Backend's `.` identifier. Surfaced per ADR-002\nso the FE can align compiled-in `BULLET_DEFS` constants to wire rows\nduring the catalog-hydration transitional release; the stable lookup\nkey remains `id`.", + "type": "string" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "schema_error_code": { + "description": "Canonical code from `{ table_not_found, column_not_found,\nclickhouse_unreachable, unknown }`, only present when `schema_status = \"error\"`.\nRaw ClickHouse error text NEVER reaches consumers per DESIGN §3.3.", + "type": [ + "string", + "null" + ] }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "schema_status": { + "description": "`\"ok\" | \"error\" | \"unchecked\"` — sourced from `metric_catalog.schema_status`.\nConsumers render `\"unchecked\"` the same as `\"ok\"` (validator hasn't run\nyet); only `\"error\"` triggers the broken-metric indicator.", + "type": "string" }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "source_tags": { + "items": { + "type": "string" + }, + "type": "array" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "sublabel": { + "type": [ + "string", + "null" + ] }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "thresholds": { + "$ref": "#/components/schemas/CatalogThresholdView" + }, + "unit": { + "type": [ + "string", + "null" + ] } }, - "security": [ - { - "bearerAuth": [] + "required": [ + "id", + "metric_key", + "label", + "higher_is_better", + "is_member_scale", + "source_tags", + "schema_status", + "thresholds" + ], + "type": "object" + }, + "PageInfo": { + "description": "Pagination info.", + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "has_next": { + "type": "boolean" } - ] - } - }, - "/v1/columns/{table}": { - "get": { - "summary": "List queryable columns for a table", - "operationId": "analytics_api.columns.list_for_table", - "responses": { - "200": { - "description": "List of columns", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ColumnListResponse" - } - } - } + }, + "required": [ + "has_next" + ], + "type": "object" + }, + "Person": { + "description": "Person info returned by the Identity service.", + "properties": { + "department": { + "type": "string" }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "display_name": { + "type": "string" }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "division": { + "type": "string" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "email": { + "type": "string" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "first_name": { + "type": "string" }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "job_title": { + "type": "string" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "last_name": { + "type": "string" }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "status": { + "type": "string" + }, + "subordinates": { + "items": { + "$ref": "#/components/schemas/Subordinate" + }, + "type": "array" + }, + "supervisor_email": { + "type": [ + "string", + "null" + ] + }, + "supervisor_name": { + "type": [ + "string", + "null" + ] } }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/v1/metric-results": { - "post": { - "summary": "Compute metric results", - "operationId": "analytics_api.metric_results.create", - "responses": { - "200": { - "description": "Metric results", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "required": [ + "email", + "display_name", + "first_name", + "last_name", + "department", + "division", + "job_title", + "status", + "subordinates" + ], + "type": "object" + }, + "Problem": { + "description": "RFC 9457 problem+json. `context` varies by error category.", + "properties": { + "context": { + "type": "object" }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "detail": { + "type": "string" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "instance": { + "type": "string" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "status": { + "format": "int32", + "type": "integer" }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "title": { + "type": "string" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "trace_id": { + "type": "string" }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "type": { + "type": "string" } }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/v1/metrics": { - "get": { - "summary": "List metrics", - "operationId": "analytics_api.metrics.list", - "responses": { - "200": { - "description": "List of metrics", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricListResponse" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "required": [ + "type", + "title", + "status", + "detail", + "context" + ], + "type": "object" + }, + "QueryRequest": { + "description": "Query request body for `POST /v1/metrics/{id}/query`.\n\nUses `OData`-style parameters: `$filter`, `$orderby`, `$select`, `$top`, `$skip`.", + "properties": { + "$filter": { + "description": "`OData` filter expression.\ne.g. `\"metric_date ge '2026-03-01' and metric_date lt '2026-04-01'\"`.", + "type": [ + "string", + "null" + ] }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "$orderby": { + "description": "`OData` ordering expression.\ne.g. `\"metric_date desc\"`.", + "type": [ + "string", + "null" + ] }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "$select": { + "description": "Comma-separated list of columns to return.\ne.g. `\"person_id, avg_hours, metric_date\"`.", + "type": [ + "string", + "null" + ] }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "$skip": { + "description": "Opaque cursor for keyset pagination (from previous `page_info.cursor`).", + "type": [ + "string", + "null" + ] }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "$top": { + "description": "Maximum number of rows (default 25, max 200).", + "format": "int64", + "minimum": 0, + "type": "integer" } }, - "security": [ - { - "bearerAuth": [] - } - ] + "type": "object" }, - "post": { - "summary": "Create a metric", - "operationId": "analytics_api.metrics.create", - "requestBody": { - "description": "Metric to create", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMetricRequest" - } - } + "QueryResponse": { + "description": "Query response with cursor-based pagination.\n\n`items` rows carry a per-metric dynamic schema (the `SELECT` columns vary by\nmetric), so each row is an untyped JSON object.", + "properties": { + "items": { + "items": {}, + "type": "array" }, - "required": true + "page_info": { + "$ref": "#/components/schemas/PageInfo" + } }, - "responses": { - "201": { - "description": "Created metric", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Metric" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "required": [ + "items", + "page_info" + ], + "type": "object" + }, + "Scope": { + "description": "Canonical scope values for `metric_threshold.scope`. Mirrors the DB-side\nENUM declared in `migration/m20260522_000002_metric_threshold.rs` line\n102–106 and the resolver's `Scope` (kept as a separate type because the\nresolver's enum is private to that module).\n\nWire form is the dash-keyed string the DB stores — deserializing via\n`serde(rename_all = \"kebab-case\")` would NOT produce the right value for\n`team+role` (kebab would yield `team-role`), so we spell each variant\nexplicitly with `#[serde(rename = ...)]`.", + "enum": [ + "product-default", + "tenant", + "role", + "team", + "team+role" + ], + "type": "string" + }, + "Subordinate": { + "description": "Subordinate summary.", + "properties": { + "display_name": { + "type": "string" }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "email": { + "type": "string" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "job_title": { + "type": "string" + } + }, + "required": [ + "email", + "display_name", + "job_title" + ], + "type": "object" + }, + "TableColumn": { + "description": "A column in the `ClickHouse` schema catalog.", + "properties": { + "clickhouse_table": { + "type": "string" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "field_description": { + "type": [ + "string", + "null" + ] }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "field_name": { + "type": "string" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "id": { + "format": "uuid", + "type": "string" }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "insight_tenant_id": { + "format": "uuid", + "type": [ + "string", + "null" + ] } }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/v1/metrics/queries": { - "post": { - "summary": "Query metrics in batch", - "operationId": "analytics_api.metrics.query_batch", - "requestBody": { - "description": "Batch of per-metric queries", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchQueryRequest" - } - } + "required": [ + "id", + "clickhouse_table", + "field_name" + ], + "type": "object" + }, + "Threshold": { + "description": "A threshold rule — configured per metric, per field.\n\nThe query engine evaluates every result row against the metric's thresholds\nand attaches a `_thresholds` map to the response.", + "properties": { + "created_at": { + "format": "date-time", + "type": "string" }, - "required": true - }, - "responses": { - "200": { - "description": "Batch query result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchQueryResponse" - } - } - } + "field_name": { + "type": "string" }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "id": { + "format": "uuid", + "type": "string" }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "insight_tenant_id": { + "format": "uuid", + "type": "string" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "level": { + "type": "string" }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "metric_id": { + "format": "uuid", + "type": "string" }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "operator": { + "type": "string" }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "updated_at": { + "format": "date-time", + "type": "string" }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "value": { + "format": "double", + "type": "number" } }, - "security": [ - { - "bearerAuth": [] + "required": [ + "id", + "insight_tenant_id", + "metric_id", + "field_name", + "operator", + "value", + "level", + "created_at", + "updated_at" + ], + "type": "object" + }, + "ThresholdListResponse": { + "description": "Response envelope for `GET /v1/metrics/{id}/thresholds`\n(`{ \"items\": [Threshold] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the list\nhandler emits.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Threshold" + }, + "type": "array" } - ] - } - }, - "/v1/metrics/{id}": { - "get": { - "summary": "Get a metric by id", - "operationId": "analytics_api.metrics.get", - "responses": { - "200": { - "description": "Metric", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Metric" - } - } - } + }, + "required": [ + "items" + ], + "type": "object" + }, + "UpdateMetricRequest": { + "description": "Request to update a metric.\n\n`description` uses double-Option to distinguish:\n- absent field → leave unchanged\n- explicit `null` → clear to None\n- `\"some text\"` → set to Some(\"some text\")", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "is_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "query_ref": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "UpdateRequest": { + "additionalProperties": false, + "description": "`PUT /v1/admin/metric-thresholds/{id}` body — update an existing row.\n\n`scope` / `role_slug` / `team_id` are intentionally accepted here even\nthough they're immutable post-create: when present, the gauntlet\ncompares the value to the row's current value and rejects with\n`failed_precondition` + `type: \"immutable_field\"` if they differ. Re-\nscoping requires DELETE + POST per DESIGN §3.7 line 1034.", + "properties": { + "alert_bad": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "alert_trigger": { + "format": "double", + "type": [ + "number", + "null" + ] }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "good": { + "format": "double", + "type": "number" }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "is_locked": { + "type": "boolean" }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "lock_reason": { + "type": [ + "string", + "null" + ] }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "role_slug": { + "type": [ + "string", + "null" + ] }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } + "scope": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Scope", + "description": "Echoed by the caller as a sanity check; the gauntlet validates it\nagainst the row's current value." } - } + ] }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "team_id": { + "type": [ + "string", + "null" + ] }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } + "warn": { + "format": "double", + "type": "number" } }, - "security": [ - { - "bearerAuth": [] - } - ] + "required": [ + "good", + "warn" + ], + "type": "object" }, - "put": { - "summary": "Update a metric", - "operationId": "analytics_api.metrics.update", - "requestBody": { - "description": "Metric fields to update", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMetricRequest" - } - } + "UpdateThresholdRequest": { + "description": "Request to update a threshold.", + "properties": { + "field_name": { + "type": [ + "string", + "null" + ] }, - "required": true + "level": { + "type": [ + "string", + "null" + ] + }, + "operator": { + "type": [ + "string", + "null" + ] + }, + "value": { + "format": "double", + "type": [ + "number", + "null" + ] + } }, + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Read-only query service over predefined ClickHouse metrics. Admins define metrics (named SQL queries) in MariaDB; the frontend queries them by UUID with OData-style filtering. The API Gateway mounts this service at /api/analytics.", + "title": "Analytics API", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/v1/admin/metric-thresholds": { + "get": { + "operationId": "analytics_api.admin.thresholds.list", "responses": { "200": { - "description": "Updated metric", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Metric" + "$ref": "#/components/schemas/ListResponse" } } - } + }, + "description": "List of metric thresholds" }, "400": { - "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Bad Request" }, "401": { - "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Forbidden" }, "404": { - "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Found" }, "409": { - "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" }, "429": { - "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Too Many Requests" }, "500": { - "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } - ] + ], + "summary": "List admin metric thresholds" }, - "delete": { - "summary": "Delete a metric", - "operationId": "analytics_api.metrics.delete", - "responses": { - "204": { - "description": "Metric deleted" - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "429": { - "description": "Too Many Requests", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/v1/metrics/{id}/query": { "post": { - "summary": "Query a single metric", - "operationId": "analytics_api.metrics.query", + "operationId": "analytics_api.admin.thresholds.create", "requestBody": { - "description": "OData-style query parameters", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryRequest" + "$ref": "#/components/schemas/CreateRequest" } } }, + "description": "Metric threshold to create", "required": true }, "responses": { - "200": { - "description": "Query result", + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryResponse" + "$ref": "#/components/schemas/AdminMetricThresholdView" } } - } + }, + "description": "Created metric threshold" }, "400": { - "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Bad Request" }, "401": { - "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Forbidden" }, "404": { - "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Found" }, "409": { - "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" }, "429": { - "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Too Many Requests" }, "500": { - "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } - ] + ], + "summary": "Create an admin metric threshold" } }, - "/v1/metrics/{id}/thresholds": { - "get": { - "summary": "List thresholds for a metric", - "operationId": "analytics_api.thresholds.list", + "/v1/admin/metric-thresholds/{id}": { + "delete": { + "operationId": "analytics_api.admin.thresholds.delete", "responses": { - "200": { - "description": "List of thresholds", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ThresholdListResponse" - } - } - } + "204": { + "description": "Metric threshold deleted" }, "400": { - "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Bad Request" }, "401": { - "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Forbidden" }, "404": { - "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Found" }, "409": { - "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" }, "429": { - "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Too Many Requests" }, "500": { - "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } - ] + ], + "summary": "Delete an admin metric threshold" }, - "post": { - "summary": "Create a threshold for a metric", - "operationId": "analytics_api.thresholds.create", - "requestBody": { - "description": "Threshold to create", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateThresholdRequest" - } - } - }, - "required": true - }, + "get": { + "operationId": "analytics_api.admin.thresholds.get", "responses": { - "201": { - "description": "Created threshold", + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Threshold" + "$ref": "#/components/schemas/AdminMetricThresholdView" } } - } + }, + "description": "Metric threshold" }, "400": { - "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Bad Request" }, "401": { - "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Forbidden" }, "404": { - "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Found" }, "409": { - "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" }, "429": { - "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Too Many Requests" }, "500": { - "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } - ] - } - }, - "/v1/metrics/{id}/thresholds/{tid}": { + ], + "summary": "Get an admin metric threshold by id" + }, "put": { - "summary": "Update a threshold", - "operationId": "analytics_api.thresholds.update", + "operationId": "analytics_api.admin.thresholds.update", "requestBody": { - "description": "Threshold fields to update", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateThresholdRequest" + "$ref": "#/components/schemas/UpdateRequest" } } }, + "description": "Metric threshold fields to update", "required": true }, "responses": { "200": { - "description": "Updated threshold", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Threshold" + "$ref": "#/components/schemas/AdminMetricThresholdView" } } - } + }, + "description": "Updated metric threshold" }, "400": { - "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Bad Request" }, "401": { - "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Forbidden" }, "404": { - "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Found" }, "409": { - "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" }, "429": { - "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Too Many Requests" }, "500": { - "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } - ] - }, - "delete": { - "summary": "Delete a threshold", - "operationId": "analytics_api.thresholds.delete", + ], + "summary": "Update an admin metric threshold" + } + }, + "/v1/catalog/get_metrics": { + "post": { + "operationId": "analytics_api.catalog.get_metrics", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMetricsRequest" + } + } + }, + "description": "Catalog read request context (role, team)", + "required": true + }, "responses": { - "204": { - "description": "Threshold deleted" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogResponse" + } + } + }, + "description": "Resolved metric catalog" }, "400": { - "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Bad Request" }, "401": { - "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Forbidden" }, "404": { - "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Found" }, "409": { - "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" }, "429": { - "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Too Many Requests" }, "500": { - "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } - ] + ], + "summary": "Read the metric catalog for the request context" } }, - "/v1/persons/{email}": { + "/v1/columns": { "get": { - "summary": "Resolve a person by email", - "operationId": "analytics_api.persons.get", + "operationId": "analytics_api.columns.list", "responses": { "200": { - "description": "Person", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Person" + "$ref": "#/components/schemas/ColumnListResponse" } } - } + }, + "description": "List of columns" }, "400": { - "description": "Bad Request", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Bad Request" }, "401": { - "description": "Unauthorized", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Forbidden" }, "404": { - "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Found" }, "409": { - "description": "Conflict", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" }, "429": { - "description": "Too Many Requests", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Too Many Requests" }, "500": { - "description": "Internal Server Error", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } - ] - } - } - }, - "components": { - "schemas": { - "AdminMetricThresholdView": { - "type": "object", - "description": "On-wire shape of one `metric_threshold` row in list / get responses.\n\n`metric_key` is NOT serialized — same backend-internal opacity rule the\nread endpoint follows (`domain/catalog/response.rs::MetricView`).\nConsumers identify a metric by `metric_id`.\n\nThe OpenAPI component is named `AdminMetricThresholdView` (via\n`#[schema(as)]`) to disambiguate from the catalog read path's\n`ThresholdView` (`domain::catalog::response::ThresholdView`, registered as\n`CatalogThresholdView`), which is a different wire shape. `#[schema(as)]`\nrenames only the OpenAPI component — it does NOT affect serde / the wire\nformat.", - "required": [ - "id", - "metric_id", - "scope", - "good", - "warn", - "is_locked", - "schema_status" ], - "properties": { - "alert_bad": { - "type": [ - "number", - "null" - ], - "format": "double" - }, - "alert_trigger": { - "type": [ - "number", - "null" - ], - "format": "double" - }, - "good": { - "type": "number", - "format": "double" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "is_locked": { - "type": "boolean" - }, - "lock_reason": { - "type": [ - "string", - "null" - ] - }, - "locked_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "locked_by": { - "type": [ - "string", - "null" - ] - }, - "metric_id": { - "type": "string", - "format": "uuid", - "description": "UUIDv7 of the corresponding `metric_catalog` row." - }, - "role_slug": { - "type": [ - "string", - "null" - ], - "description": "Empty-string sentinel collapsed to `None` on the wire so the JSON\nshape is `null` instead of `\"\"` (the latter would confuse FE\n\"is this set?\" predicates)." + "summary": "List queryable columns" + } + }, + "/v1/columns/{table}": { + "get": { + "operationId": "analytics_api.columns.list_for_table", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnListResponse" + } + } + }, + "description": "List of columns" }, - "schema_error_code": { - "type": [ - "string", - "null" - ], - "description": "Canonical error code (`table_not_found | column_not_found |\nclickhouse_unreachable | unknown`) when `schema_status = \"error\"`,\notherwise omitted." + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "schema_status": { - "type": "string", - "description": "One of `ok | error | unchecked`, joined from `metric_catalog.schema_status`\n(DESIGN §3.3 \"Schema status surface\"). Lets the admin UI flag a\nbroken metric before the operator submits a write." + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "scope": { - "$ref": "#/components/schemas/Scope" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "team_id": { - "type": [ - "string", - "null" - ] + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "tenant_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "`Some(_)` for tenant-scoped rows, `None` for `product-default`." + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "warn": { - "type": "number", - "format": "double" - } - } - }, - "BatchQueryItem": { - "allOf": [ - { - "$ref": "#/components/schemas/QueryRequest" + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - { - "type": "object", - "required": [ - "metric_id" - ], - "properties": { - "id": { - "type": [ - "string", - "null" - ] - }, - "metric_id": { - "type": "string", - "format": "uuid" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } } - } + }, + "description": "Internal Server Error" } - ] - }, - "BatchQueryRequest": { - "type": "object", - "required": [ - "queries" - ], - "properties": { - "queries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BatchQueryItem" - } + }, + "security": [ + { + "bearerAuth": [] } - } - }, - "BatchQueryResponse": { - "type": "object", - "required": [ - "results" ], - "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BatchQueryResult" - } - } - } - }, - "BatchQueryResult": { - "oneOf": [ - { - "allOf": [ - { - "$ref": "#/components/schemas/QueryResponse" - }, - { - "type": "object", - "required": [ - "metric_id" - ], - "properties": { - "id": { - "type": [ - "string", - "null" - ] - }, - "metric_id": { - "type": "string", - "format": "uuid" - } - } - }, - { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string", - "enum": [ - "ok" - ] - } + "summary": "List queryable columns for a table" + } + }, + "/v1/metric-results": { + "post": { + "operationId": "analytics_api.metric_results.create", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" } } - ] + }, + "description": "Metric results" }, - { - "type": "object", - "required": [ - "metric_id", - "error", - "status" - ], - "properties": { - "error": { - "$ref": "#/components/schemas/Problem" - }, - "id": { - "type": [ - "string", - "null" - ] - }, - "metric_id": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string", - "enum": [ - "error" - ] + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } } - } - } - ] - }, - "CatalogResponse": { - "type": "object", - "description": "Top-level response body. `tenant_id` is echoed for client-side cache\nreasoning AND re-asserted on cache hydrate as defense in depth against a\nmisconfigured cache backend serving a sibling tenant's payload.\n\n`links` carries the `metric_query_catalog` M:N mapping per ADR-003. The\nmapping is time/filter-invariant, so consumers cache it for the same TTL as\nthe catalog itself; see [`MetricQueryLink`].", - "required": [ - "tenant_id", - "generated_at", - "metrics", - "links" - ], - "properties": { - "generated_at": { - "type": "string", - "format": "date-time" - }, - "links": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricQueryLink" - } - }, - "metrics": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricView" - } + }, + "description": "Bad Request" }, - "tenant_id": { - "type": "string", - "format": "uuid" - } - } - }, - "CatalogThresholdView": { - "type": "object", - "description": "Resolved threshold for one metric.\n\n`good` / `warn` are `f64` on the wire — DECIMAL(20,6) in the DB rounds-trips\nthrough DOUBLE for every seed value (integers and one-decimal floats). If\nfuture seed entries need full-precision decimals, this is the place to switch\nto a string serializer; the FE byte-for-byte comparison gate (PRD §12) is\nthe regression detector.\n\nThe OpenAPI component is named `CatalogThresholdView` (via `#[schema(as)]`)\nto disambiguate from the admin-CRUD `ThresholdView`\n(`domain::admin_threshold::dto::ThresholdView`), which is a different wire\nshape registered under `AdminMetricThresholdView`. `#[schema(as)]` renames\nonly the OpenAPI component — it does NOT affect serde / the wire format.", - "required": [ - "good", - "warn", - "resolved_from", - "bounded_by_lock" - ], - "properties": { - "alert_bad": { - "type": [ - "number", - "null" - ], - "format": "double" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "alert_trigger": { - "type": [ - "number", - "null" - ], - "format": "double" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "bounded_by_lock": { - "type": "boolean", - "description": "`true` iff the walk halted on a locked broader-scope row before reaching\nthe most-specific candidate. Separate signal from `resolved_from`, which\nalways names the row that won." + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "good": { - "type": "number", - "format": "double" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "resolved_from": { - "type": "string", - "description": "One of `\"team+role\" | \"team\" | \"role\" | \"tenant\" | \"product-default\"`.\nNames the row that won the walk." + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "warn": { - "type": "number", - "format": "double" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } - } - }, - "ColumnListResponse": { - "type": "object", - "description": "Response envelope for `GET /v1/columns` and `GET /v1/columns/{table}`\n(`{ \"items\": [TableColumn] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the\nhandlers emit — gives the column-list endpoints a real OpenAPI schema.", - "required": [ - "items" - ], - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TableColumn" - } + }, + "security": [ + { + "bearerAuth": [] } - } - }, - "CreateMetricRequest": { - "type": "object", - "description": "Request to create a new metric.", - "required": [ - "name", - "query_ref" ], - "properties": { - "description": { - "type": [ - "string", - "null" - ] + "summary": "Compute metric results" + } + }, + "/v1/metrics": { + "get": { + "operationId": "analytics_api.metrics.list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricListResponse" + } + } + }, + "description": "List of metrics" }, - "name": { - "type": "string" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "query_ref": { - "type": "string" - } - } - }, - "CreateRequest": { - "type": "object", - "description": "`POST /v1/admin/metric-thresholds` body — create a new threshold row.\n\n`tenant_id` / `id` / `locked_by` / `locked_at` / `created_at` /\n`updated_at` are NOT accepted from the body. `deny_unknown_fields`\nenforces that at the serde layer.\n\n`role_slug` / `team_id` use `Option` — `None` is the canonical\nempty-string sentinel (DESIGN §3.7 + `infra/cache/catalog_cache.rs::cache_field`).", - "required": [ - "metric_id", - "scope", - "good", - "warn" - ], - "properties": { - "alert_bad": { - "type": [ - "number", - "null" - ], - "format": "double" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "alert_trigger": { - "type": [ - "number", - "null" - ], - "format": "double" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "good": { - "type": "number", - "format": "double" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "is_locked": { - "type": "boolean" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "lock_reason": { - "type": [ - "string", - "null" - ] + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "metric_id": { - "type": "string", - "format": "uuid" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List metrics" + }, + "post": { + "operationId": "analytics_api.metrics.create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMetricRequest" + } + } }, - "role_slug": { - "type": [ - "string", - "null" - ] + "description": "Metric to create", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Metric" + } + } + }, + "description": "Created metric" }, - "scope": { - "$ref": "#/components/schemas/Scope" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "team_id": { - "type": [ - "string", - "null" - ] + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "warn": { - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - "CreateThresholdRequest": { - "type": "object", - "description": "Request to create a threshold.", - "required": [ - "field_name", - "operator", - "value", - "level" - ], - "properties": { - "field_name": { - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "level": { - "type": "string", - "description": "Result level: `good`, `warning`, `critical`." + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "operator": { - "type": "string", - "description": "Comparison operator: `gt`, `ge`, `lt`, `le`, `eq`." + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "value": { - "type": "number", - "format": "double" - } - } - }, - "GetMetricsRequest": { - "type": "object", - "description": "Request body for `POST /v1/catalog/get_metrics`.\n\n`tenant_id` is intentionally NOT accepted here — it is resolved server-side\nfrom the session by `tenant_middleware` (Refs #522 auth-trait). Allowing a\nbody-supplied `tenant_id` would open a cross-tenant disclosure surface.\n`deny_unknown_fields` enforces that defensively at the parser layer: a\ncaller that smuggles `\"tenant_id\": \"...\"` into the body gets a 400 instead\nof a silent ignore.", - "properties": { - "role_slug": { - "type": [ - "string", - "null" - ], - "description": "Role slug for `role` / `team+role` resolution chains. `None` and `Some(\"\")`\nare semantically identical and produce the same cache key (canonical\nempty-string sentinel — see `cache_key` in the cache layer)." + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "team_id": { - "type": [ - "string", - "null" - ], - "description": "Team id for `team` / `team+role` resolution chains. Same `None` vs `Some(\"\")`\nequivalence as `role_slug`." + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "additionalProperties": false - }, - "ListResponse": { - "type": "object", - "description": "`GET /v1/admin/metric-thresholds` response envelope.\n\nWraps `items` in an object (instead of a bare array) so future\nadditions (pagination cursor, count, generated-at) are additive and\nnon-breaking. Mirrors the catalog read endpoint's envelope shape.", - "required": [ - "items" - ], - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AdminMetricThresholdView" - } + "security": [ + { + "bearerAuth": [] } - } - }, - "Metric": { - "type": "object", - "description": "A metric definition — an admin-configured SQL query against `ClickHouse`.\n\nThe `query_ref` field holds raw `ClickHouse` SQL. The query engine wraps it\nas a subquery, appending security filters + `OData` filters as parameterized\nWHERE clauses.", - "required": [ - "id", - "insight_tenant_id", - "name", - "query_ref", - "is_enabled", - "created_at", - "updated_at" ], - "properties": { - "created_at": { - "type": "string", - "format": "date-time" + "summary": "Create a metric" + } + }, + "/v1/metrics/queries": { + "post": { + "operationId": "analytics_api.metrics.query_batch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchQueryRequest" + } + } }, - "description": { - "type": [ - "string", - "null" - ] + "description": "Batch of per-metric queries", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchQueryResponse" + } + } + }, + "description": "Batch query result" }, - "id": { - "type": "string", - "format": "uuid" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "insight_tenant_id": { - "type": "string", - "format": "uuid" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "is_enabled": { - "type": "boolean" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "name": { - "type": "string" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "query_ref": { - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "updated_at": { - "type": "string", - "format": "date-time" + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } - } - }, - "MetricListResponse": { - "type": "object", - "description": "Response envelope for `GET /v1/metrics` (`{ \"items\": [MetricSummary] }`).\n\nDocs-only wrapper: the handler emits the same object shape via an inline\n`serde_json::json!` literal. Existing on the wire; this type just gives the\nlist endpoint a real OpenAPI schema instead of a generic object.", - "required": [ - "items" - ], - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricSummary" - } + }, + "security": [ + { + "bearerAuth": [] } - } - }, - "MetricQueryLink": { - "type": "object", - "description": "One link row from `metric_query_catalog`. Tells a consumer which catalog\nrows a `metrics.query_ref` emits when executed — the M:N answer ADR-001\nadded at the DB layer, surfaced here so consumers don't have to derive it\nby joining on backend-internal `metric_key` strings.\n\n`catalog_metric_ids` is the set of `metric_catalog.id` UUIDs the query\nproduces. The set is empty only when the linked catalog rows are all\n`is_enabled = false` (filtered out of the `metrics` array) — consumers\ndegrade gracefully on empty.", - "required": [ - "query_id", - "catalog_metric_ids" ], - "properties": { - "catalog_metric_ids": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" + "summary": "Query metrics in batch" + } + }, + "/v1/metrics/{id}": { + "delete": { + "operationId": "analytics_api.metrics.delete", + "responses": { + "204": { + "description": "Metric deleted" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } }, - "description": "`metric_catalog.id` UUIDs this query emits. Sorted ascending so the\nwire payload is byte-stable for cache + diff tooling." + "description": "Bad Request" }, - "query_id": { - "type": "string", - "format": "uuid", - "description": "`metrics.id` — the ClickHouse `query_ref` row this link is FROM." - } - } - }, - "MetricSummary": { - "type": "object", - "description": "Summary returned in list endpoints (no `query_ref`).", - "required": [ - "id", - "name" - ], - "properties": { - "description": { - "type": [ - "string", - "null" - ] + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "id": { - "type": "string", - "format": "uuid" + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "name": { - "type": "string" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] } - } - }, - "MetricView": { - "type": "object", - "description": "One catalog metric on the wire. `metric_key` is surfaced per ADR-002 as the\ntransitional FE-bridge identifier; consumers MUST still key lookups by `id`.", - "required": [ - "id", - "metric_key", - "label", - "higher_is_better", - "is_member_scale", - "source_tags", - "schema_status", - "thresholds" ], - "properties": { - "description": { - "type": [ - "string", - "null" - ] + "summary": "Delete a metric" + }, + "get": { + "operationId": "analytics_api.metrics.get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Metric" + } + } + }, + "description": "Metric" }, - "format": { - "type": [ - "string", - "null" - ] + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "higher_is_better": { - "type": "boolean" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "id": { - "type": "string", - "format": "uuid" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "is_member_scale": { - "type": "boolean" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "label": { - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "metric_key": { - "type": "string", - "description": "Backend's `.` identifier. Surfaced per ADR-002\nso the FE can align compiled-in `BULLET_DEFS` constants to wire rows\nduring the catalog-hydration transitional release; the stable lookup\nkey remains `id`." + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "schema_error_code": { - "type": [ - "string", - "null" - ], - "description": "Canonical code from `{ table_not_found, column_not_found,\nclickhouse_unreachable, unknown }`, only present when `schema_status = \"error\"`.\nRaw ClickHouse error text NEVER reaches consumers per DESIGN §3.3." + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get a metric by id" + }, + "put": { + "operationId": "analytics_api.metrics.update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMetricRequest" + } + } }, - "schema_status": { - "type": "string", - "description": "`\"ok\" | \"error\" | \"unchecked\"` — sourced from `metric_catalog.schema_status`.\nConsumers render `\"unchecked\"` the same as `\"ok\"` (validator hasn't run\nyet); only `\"error\"` triggers the broken-metric indicator." + "description": "Metric fields to update", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Metric" + } + } + }, + "description": "Updated metric" }, - "source_tags": { - "type": "array", - "items": { - "type": "string" - } + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "sublabel": { - "type": [ - "string", - "null" - ] + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "thresholds": { - "$ref": "#/components/schemas/CatalogThresholdView" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "unit": { - "type": [ - "string", - "null" - ] - } - } - }, - "PageInfo": { - "type": "object", - "description": "Pagination info.", - "required": [ - "has_next" - ], - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "has_next": { - "type": "boolean" - } - } - }, - "Person": { - "type": "object", - "description": "Person info returned by the Identity service.", - "required": [ - "email", - "display_name", - "first_name", - "last_name", - "department", - "division", - "job_title", - "status", - "subordinates" - ], - "properties": { - "department": { - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "display_name": { - "type": "string" + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "division": { - "type": "string" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update a metric" + } + }, + "/v1/metrics/{id}/query": { + "post": { + "operationId": "analytics_api.metrics.query", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" + } + } }, - "email": { - "type": "string" + "description": "OData-style query parameters", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryResponse" + } + } + }, + "description": "Query result" }, - "first_name": { - "type": "string" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "job_title": { - "type": "string" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "last_name": { - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "status": { - "type": "string" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "subordinates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Subordinate" - } + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "supervisor_email": { - "type": [ - "string", - "null" - ] + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "supervisor_name": { - "type": [ - "string", - "null" - ] + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] } - } - }, - "Problem": { - "type": "object", - "description": "RFC 9457 problem+json. `context` varies by error category.", - "required": [ - "type", - "title", - "status", - "detail", - "context" ], - "properties": { - "context": { - "type": "object" + "summary": "Query a single metric" + } + }, + "/v1/metrics/{id}/thresholds": { + "get": { + "operationId": "analytics_api.thresholds.list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThresholdListResponse" + } + } + }, + "description": "List of thresholds" }, - "detail": { - "type": "string" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "instance": { - "type": "string" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "status": { - "type": "integer", - "format": "int32" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "title": { - "type": "string" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "trace_id": { - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "type": { - "type": "string" + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] } - } + ], + "summary": "List thresholds for a metric" }, - "QueryRequest": { - "type": "object", - "description": "Query request body for `POST /v1/metrics/{id}/query`.\n\nUses `OData`-style parameters: `$filter`, `$orderby`, `$select`, `$top`, `$skip`.", - "properties": { - "$filter": { - "type": [ - "string", - "null" - ], - "description": "`OData` filter expression.\ne.g. `\"metric_date ge '2026-03-01' and metric_date lt '2026-04-01'\"`." + "post": { + "operationId": "analytics_api.thresholds.create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateThresholdRequest" + } + } + }, + "description": "Threshold to create", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Threshold" + } + } + }, + "description": "Created threshold" }, - "$orderby": { - "type": [ - "string", - "null" - ], - "description": "`OData` ordering expression.\ne.g. `\"metric_date desc\"`." + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "$select": { - "type": [ - "string", - "null" - ], - "description": "Comma-separated list of columns to return.\ne.g. `\"person_id, avg_hours, metric_date\"`." + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "$skip": { - "type": [ - "string", - "null" - ], - "description": "Opaque cursor for keyset pagination (from previous `page_info.cursor`)." + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "$top": { - "type": "integer", - "format": "int64", - "description": "Maximum number of rows (default 25, max 200).", - "minimum": 0 - } - } - }, - "QueryResponse": { - "type": "object", - "description": "Query response with cursor-based pagination.\n\n`items` rows carry a per-metric dynamic schema (the `SELECT` columns vary by\nmetric), so each row is an untyped JSON object.", - "required": [ - "items", - "page_info" - ], - "properties": { - "items": { - "type": "array", - "items": {} + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "page_info": { - "$ref": "#/components/schemas/PageInfo" - } - } - }, - "Scope": { - "type": "string", - "description": "Canonical scope values for `metric_threshold.scope`. Mirrors the DB-side\nENUM declared in `migration/m20260522_000002_metric_threshold.rs` line\n102–106 and the resolver's `Scope` (kept as a separate type because the\nresolver's enum is private to that module).\n\nWire form is the dash-keyed string the DB stores — deserializing via\n`serde(rename_all = \"kebab-case\")` would NOT produce the right value for\n`team+role` (kebab would yield `team-role`), so we spell each variant\nexplicitly with `#[serde(rename = ...)]`.", - "enum": [ - "product-default", - "tenant", - "role", - "team", - "team+role" - ] - }, - "Subordinate": { - "type": "object", - "description": "Subordinate summary.", - "required": [ - "email", - "display_name", - "job_title" - ], - "properties": { - "display_name": { - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "email": { - "type": "string" + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "job_title": { - "type": "string" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] } - } - }, - "TableColumn": { - "type": "object", - "description": "A column in the `ClickHouse` schema catalog.", - "required": [ - "id", - "clickhouse_table", - "field_name" ], - "properties": { - "clickhouse_table": { - "type": "string" + "summary": "Create a threshold for a metric" + } + }, + "/v1/metrics/{id}/thresholds/{tid}": { + "delete": { + "operationId": "analytics_api.thresholds.delete", + "responses": { + "204": { + "description": "Threshold deleted" }, - "field_description": { - "type": [ - "string", - "null" - ] + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "field_name": { - "type": "string" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "id": { - "type": "string", - "format": "uuid" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "insight_tenant_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" - } - } - }, - "Threshold": { - "type": "object", - "description": "A threshold rule — configured per metric, per field.\n\nThe query engine evaluates every result row against the metric's thresholds\nand attaches a `_thresholds` map to the response.", - "required": [ - "id", - "insight_tenant_id", - "metric_id", - "field_name", - "operator", - "value", - "level", - "created_at", - "updated_at" - ], - "properties": { - "created_at": { - "type": "string", - "format": "date-time" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "field_name": { - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "id": { - "type": "string", - "format": "uuid" + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "insight_tenant_id": { - "type": "string", - "format": "uuid" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete a threshold" + }, + "put": { + "operationId": "analytics_api.thresholds.update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateThresholdRequest" + } + } }, - "level": { - "type": "string" + "description": "Threshold fields to update", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Threshold" + } + } + }, + "description": "Updated threshold" }, - "metric_id": { - "type": "string", - "format": "uuid" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "operator": { - "type": "string" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "updated_at": { - "type": "string", - "format": "date-time" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "value": { - "type": "number", - "format": "double" - } - } - }, - "ThresholdListResponse": { - "type": "object", - "description": "Response envelope for `GET /v1/metrics/{id}/thresholds`\n(`{ \"items\": [Threshold] }`).\n\nDocs-only wrapper mirroring the inline `serde_json::json!` shape the list\nhandler emits.", - "required": [ - "items" - ], - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Threshold" - } - } - } - }, - "UpdateMetricRequest": { - "type": "object", - "description": "Request to update a metric.\n\n`description` uses double-Option to distinguish:\n- absent field → leave unchanged\n- explicit `null` → clear to None\n- `\"some text\"` → set to Some(\"some text\")", - "properties": { - "description": { - "type": [ - "string", - "null" - ] + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "is_enabled": { - "type": [ - "boolean", - "null" - ] + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, - "name": { - "type": [ - "string", - "null" - ] + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "query_ref": { - "type": [ - "string", - "null" - ] + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] } - } - }, - "UpdateRequest": { - "type": "object", - "description": "`PUT /v1/admin/metric-thresholds/{id}` body — update an existing row.\n\n`scope` / `role_slug` / `team_id` are intentionally accepted here even\nthough they're immutable post-create: when present, the gauntlet\ncompares the value to the row's current value and rejects with\n`failed_precondition` + `type: \"immutable_field\"` if they differ. Re-\nscoping requires DELETE + POST per DESIGN §3.7 line 1034.", - "required": [ - "good", - "warn" ], - "properties": { - "alert_bad": { - "type": [ - "number", - "null" - ], - "format": "double" - }, - "alert_trigger": { - "type": [ - "number", - "null" - ], - "format": "double" + "summary": "Update a threshold" + } + }, + "/v1/persons/{email}": { + "get": { + "operationId": "analytics_api.persons.get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + }, + "description": "Person" }, - "good": { - "type": "number", - "format": "double" + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" }, - "is_locked": { - "type": "boolean" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" }, - "lock_reason": { - "type": [ - "string", - "null" - ] + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" }, - "role_slug": { - "type": [ - "string", - "null" - ] + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" }, - "scope": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/Scope", - "description": "Echoed by the caller as a sanity check; the gauntlet validates it\nagainst the row's current value." + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } } - ] + }, + "description": "Conflict" }, - "team_id": { - "type": [ - "string", - "null" - ] + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" }, - "warn": { - "type": "number", - "format": "double" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" } }, - "additionalProperties": false - }, - "UpdateThresholdRequest": { - "type": "object", - "description": "Request to update a threshold.", - "properties": { - "field_name": { - "type": [ - "string", - "null" - ] - }, - "level": { - "type": [ - "string", - "null" - ] - }, - "operator": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": [ - "number", - "null" - ], - "format": "double" + "security": [ + { + "bearerAuth": [] } - } - } - }, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" + ], + "summary": "Resolve a person by email" } } }