Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 124 additions & 1 deletion docs/components/backend/analytics/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,14 @@
],
"type": "object"
},
"EvidenceGranularity": {
"enum": [
"event",
"source_summary",
"derived_population"
],
"type": "string"
},
"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.",
Expand Down Expand Up @@ -707,6 +715,16 @@
"direction": {
"$ref": "#/components/schemas/MetricDirection"
},
"drilldown": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/MetricDrilldownCapability"
}
]
},
"explanation": {
"type": [
"string",
Expand Down Expand Up @@ -793,6 +811,24 @@
],
"type": "object"
},
"MetricDimensionFilterDto": {
"properties": {
"dimension": {
"type": "string"
},
"values": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"dimension",
"values"
],
"type": "object"
},
"MetricDimensionFilterRequest": {
"properties": {
"dimension": {
Expand All @@ -819,6 +855,20 @@
],
"type": "string"
},
"MetricDrilldownCapability": {
"properties": {
"granularity": {
"items": {
"$ref": "#/components/schemas/EvidenceGranularity"
},
"type": "array"
}
},
"required": [
"granularity"
],
"type": "object"
},
"MetricFormat": {
"enum": [
"integer",
Expand Down Expand Up @@ -928,6 +978,16 @@
"direction": {
"$ref": "#/components/schemas/MetricDirection"
},
"drilldown": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/MetricDrilldownCapability"
}
]
},
"explanation": {
"type": [
"string",
Expand All @@ -943,6 +1003,9 @@
"metric_key": {
"type": "string"
},
"selection": {
"$ref": "#/components/schemas/MetricResultSelectionDto"
},
"short_label": {
"type": [
"string",
Expand All @@ -967,12 +1030,39 @@
"label",
"format",
"direction",
"views"
"views",
"selection"
],
"type": "object"
}
]
},
"MetricResultSelectionDto": {
"properties": {
"entity": {
"$ref": "#/components/schemas/MetricResultsEntityDto"
},
"filters": {
"items": {
"$ref": "#/components/schemas/MetricDimensionFilterDto"
},
"type": "array"
},
"metric_key": {
"type": "string"
},
"period": {
"$ref": "#/components/schemas/MetricResultsPeriodDto"
}
},
"required": [
"metric_key",
"entity",
"period",
"filters"
],
"type": "object"
},
"MetricResultViewDto": {
"oneOf": [
{
Expand Down Expand Up @@ -1111,6 +1201,24 @@
],
"type": "object"
},
"MetricResultsEntityDto": {
"properties": {
"ids": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"type": "string"
}
},
"required": [
"type",
"ids"
],
"type": "object"
},
"MetricResultsPeriod": {
"properties": {
"from": {
Expand All @@ -1126,6 +1234,21 @@
],
"type": "object"
},
"MetricResultsPeriodDto": {
"properties": {
"from": {
"type": "string"
},
"to": {
"type": "string"
}
},
"required": [
"from",
"to"
],
"type": "object"
},
"MetricResultsRequest": {
"properties": {
"entity": {
Expand Down
23 changes: 23 additions & 0 deletions docs/domain/metrics/specs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ has one granularity:
- `source_summary`: the finest summary preserved by silver.
- `derived_population`: a source entity participating in a derived metric.

Definitions do not declare a separate drilldown strategy. The runtime resolves
the definition's existing input roles and source measures, requires every input
to use the same evidence relation, and compiles the evidence selection from
that metadata. A new metric over existing evidence-backed measures therefore
inherits drilldown without metric-specific SQL, backend branches, or frontend
configuration.

The schema validator probes every standard column. Drilldown capability is
absent until the probe is definitively healthy and every metric input has
granularity metadata. Missing, unchecked, or invalid evidence fails closed.
`POST /v1/metric-results` and `GET /v1/metric-definitions` expose that
capability; consumers omit evidence actions when it is absent.

The evidence contract has these limitations:

- Summary-grain silver cannot produce event-grain evidence. AI and
Expand Down Expand Up @@ -326,10 +339,20 @@ type MetricResult = {
format: "integer" | "decimal" | "currency" | "percent"
direction: "higher_is_better" | "lower_is_better" | "neutral"
views: MetricResultView[]
selection: {
metric_key: string
entity: { type: string; ids: string[] }
period: { from: string; to: string }
filters: Array<{ dimension: string; values: string[] }>
}
drilldown?: {
granularity: Array<"event" | "source_summary" | "derived_population">
}
} & (
| { computation: "sum" }
| { computation: "ratio"; scale: number }
| { computation: "median" }
| { computation: "distinct_count" }
)
```

Expand Down
83 changes: 64 additions & 19 deletions src/backend/services/analytics/src/api/metric_results.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use std::time::Duration;

Expand All @@ -10,6 +10,7 @@ use toolkit_canonical_errors::CanonicalError;

use super::AppState;
use super::error::MetricError;
use crate::domain::metric_drilldown::load_capabilities;
use crate::domain::metric_results::{
BatchItem, BreakdownQueryRow, CompiledQuery, HistogramQueryRow, MetricResultViewDto,
MetricResultsRequest, MetricResultsResponse, PeerWideRow, PeriodWideRow, PlannedQuery,
Expand All @@ -32,23 +33,36 @@ pub async fn query_metric_results(
Extension(ctx): Extension<SecurityContext>,
Json(req): Json<MetricResultsRequest>,
) -> Result<Json<MetricResultsResponse>, CanonicalError> {
let req = validate_request(&state.db, ctx.subject_tenant_id(), req).await?;
let mut ranking_results = BTreeMap::new();
let mut rankings = stream::iter(plan_rankings(&req))
.map(|ranking| {
let state = Arc::clone(&state);
async move {
let comment = format!("metric-results:ranking:{}", ranking.key.rank_metric_key);
let rows = fetch_rows::<RankingQueryRow>(&state, ranking.query, &comment).await?;
let groups = build_ranked_groups(&ranking.dimensions, rows)?;
Ok::<_, CanonicalError>((ranking.key, groups))
}
})
.buffer_unordered(QUERY_CONCURRENCY);
while let Some(result) = rankings.next().await {
let (key, groups) = result?;
ranking_results.insert(key, groups);
}
let tenant_id = ctx.subject_tenant_id();
let req = validate_request(&state.db, tenant_id, req).await?;
let metric_keys = req
.metrics
.iter()
.map(|metric| metric.def.key().to_owned())
.collect::<Vec<_>>();
let capabilities = load_capabilities(&state.db, tenant_id, &metric_keys);
let rankings = async {
let mut ranking_results = BTreeMap::new();
let mut rankings = stream::iter(plan_rankings(&req))
.map(|ranking| {
let state = Arc::clone(&state);
async move {
let comment = format!("metric-results:ranking:{}", ranking.key.rank_metric_key);
let rows =
fetch_rows::<RankingQueryRow>(&state, ranking.query, &comment).await?;
let groups = build_ranked_groups(&ranking.dimensions, rows)?;
Ok::<_, CanonicalError>((ranking.key, groups))
}
})
.buffer_unordered(QUERY_CONCURRENCY);
while let Some(result) = rankings.next().await {
let (key, groups) = result?;
ranking_results.insert(key, groups);
}
Ok::<_, CanonicalError>(ranking_results)
};
let (ranking_results, capabilities) = tokio::join!(rankings, capabilities);
let ranking_results = ranking_results?;
let planned = plan_queries(&req, &ranking_results)?;

let mut views_by_metric: Vec<Vec<Option<MetricResultViewDto>>> = req
Expand All @@ -68,6 +82,13 @@ pub async fn query_metric_results(
}
}

let capabilities = match capabilities {
Ok(capabilities) => capabilities,
Err(error) => {
tracing::warn!(error = ?error, "metric drilldown capability load failed");
HashMap::default()
}
};
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());
Expand All @@ -78,7 +99,31 @@ pub async fn query_metric_results(
enforce_view_row_limit(&view, format!("metrics[{idx}].views[{view_index}]"))?;
views.push(view);
}
metrics.push(build_metric_result(&metric.def, views));
let selection = crate::domain::metric_results::MetricResultSelectionDto {
metric_key: metric.def.key().to_owned(),
entity: crate::domain::metric_results::MetricResultsEntityDto {
r#type: req.entity_type.clone(),
ids: req.entity_ids.clone(),
},
period: crate::domain::metric_results::MetricResultsPeriodDto {
from: req.from.to_string(),
to: req.to.to_string(),
},
filters: metric
.filters
.iter()
.map(
|filter| crate::domain::metric_results::MetricDimensionFilterDto {
dimension: filter.dimension.clone(),
values: filter.values.clone(),
},
)
.collect(),
};

let mut result = build_metric_result(&metric.def, views, selection);
result.drilldown = capabilities.get(metric.def.key()).cloned();
metrics.push(result);
}

let response = MetricResultsResponse { metrics };
Expand Down
Loading
Loading