diff --git a/docs/components/backend/analytics/openapi.json b/docs/components/backend/analytics/openapi.json index f6eae1a7a..d6dcd10a5 100644 --- a/docs/components/backend/analytics/openapi.json +++ b/docs/components/backend/analytics/openapi.json @@ -493,6 +493,20 @@ "null" ] }, + "subject": { + "description": "The single topic this metric belongs to within its family, so a surface\nlisting a family can partition it into topics rather than only sorting\nby name. Exactly one per metric; absent only for metrics that declare\nnone.", + "type": [ + "string", + "null" + ] + }, + "tags": { + "description": "Cross-cutting labels a surface can filter or search by; many per metric,\nunlike the singular `subject`. Empty when the metric declares none.", + "items": { + "type": "string" + }, + "type": "array" + }, "unit": { "type": [ "string", @@ -506,6 +520,7 @@ "format", "direction", "dimensions", + "tags", "is_enabled", "origin", "schema_status" diff --git a/docs/domain/metrics/specs/DESIGN.md b/docs/domain/metrics/specs/DESIGN.md index 5cdc6e8e7..73259f52f 100644 --- a/docs/domain/metrics/specs/DESIGN.md +++ b/docs/domain/metrics/specs/DESIGN.md @@ -325,6 +325,7 @@ metric_source_dimensions metric_definitions metric_definition_inputs metric_definition_dimensions +metric_definition_tags ``` `metric_sources` stores typed source refs. @@ -338,6 +339,8 @@ metric_definition_dimensions ```text metric_key label +short_label +subject description explanation unit @@ -353,6 +356,17 @@ schema_status schema_error_code ``` +`subject` is the single topic a metric belongs to within its family — the +grouping a surface listing a whole family uses to partition it into topics +(`meetings`, `messages`, `email`, `documents`) rather than only sorting by +name. Exactly one per metric, which is the partition a source key cannot +provide: `metric_definition_inputs` binds each input to its own measure, so a +ratio's numerator and denominator may come from different sources, and a +grouping derived from the source is not a partition. Every builtin declares a +subject; a builtin registry test (`every_metric_declares_a_shaped_subject`) +enforces presence and shape. The column is nullable so a custom definition may +omit it. + `unit` is a display suffix for formats that do not fully determine presentation on their own (e.g. `"lines"`, `"days"`, `"h"`). `percent` and `currency` are presentation-complete — the frontend renders `%` or a @@ -374,6 +388,14 @@ denominator `metric_definition_dimensions` maps metrics to source dimensions. +`metric_definition_tags` holds a metric's cross-cutting tags — free-form slugs +a surface can filter or search by (`rate`, `duration`, `distribution`), many +per metric, unlike the singular `subject`. Tags are not bound to a source, so +this table carries the tag string directly rather than referencing +`metric_source_dimensions`. A builtin registry test +(`metric_tags_are_shaped_and_unique_per_metric`) pins their shape and +per-metric uniqueness. + Rules: - Product definitions have `tenant_id = NULL`. @@ -638,10 +660,13 @@ derived from it. 1. Add one entry to the `metrics` list in `src/backend/services/analytics/src/domain/metric_definitions/registry.yaml`: metric key (`namespace.metric_name`, lowercase snake case), label, - description, unit, format, direction, entity type, computation, input role - mapping to the measure, allowed dimensions, peer cohort key. + subject (the one topic within the family this metric groups under — a + lowercase snake-case slug, required), description, unit, format, direction, + entity type, computation, input role mapping to the measure, allowed + dimensions, peer cohort key, and optionally tags (cross-cutting slugs). 2. Run `cargo test -p analytics` — the registry invariant tests validate - key shapes, input/measure references, and computation field combinations. + key shapes, subject/tag shapes, input/measure references, and computation + field combinations. The reconciler seeds the definition on the next deploy. If every input measure has healthy evidence metadata, the metric automatically receives drilldown, 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 033fc08cb..5cdfb010b 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/builtin.rs @@ -99,6 +99,14 @@ pub struct MetricSeed { /// None = the full label is already compact enough. #[serde(default)] pub short_label: Option, + /// The single topic this metric belongs to within its family, so a surface + /// listing a family can partition it into topics. Required for builtins — + /// exactly one per metric, which is the partition a source key cannot give. + pub subject: String, + /// Cross-cutting labels a surface can filter or search by; many per metric, + /// unlike the singular `subject`. + #[serde(default)] + pub tags: Vec, #[serde(default)] pub description: Option, #[serde(default)] @@ -314,6 +322,53 @@ mod tests { } } + // Width of metric_definitions.subject and metric_definition_tags.tag. A + // longer authored value would pass shape checks but fail or truncate at + // reconcile time, so the bound is enforced here at build time. + const METADATA_MAX_LEN: usize = 64; + + #[test] + fn every_metric_declares_a_shaped_subject() { + for metric in builtin_metrics() { + assert!( + is_snake_case(&metric.subject), + "{} declares an unshaped subject {:?}", + metric.metric_key, + metric.subject + ); + assert!( + metric.subject.len() <= METADATA_MAX_LEN, + "{} subject {:?} exceeds {METADATA_MAX_LEN} chars", + metric.metric_key, + metric.subject + ); + } + } + + #[test] + fn metric_tags_are_shaped_and_unique_per_metric() { + for metric in builtin_metrics() { + let mut seen = BTreeSet::new(); + for tag in &metric.tags { + assert!( + is_snake_case(tag), + "{} declares an unshaped tag {tag:?}", + metric.metric_key + ); + assert!( + tag.len() <= METADATA_MAX_LEN, + "{} tag {tag:?} exceeds {METADATA_MAX_LEN} chars", + metric.metric_key + ); + assert!( + seen.insert(tag.as_str()), + "{} declares duplicate tag {tag:?}", + metric.metric_key + ); + } + } + } + #[test] fn ratio_metrics_have_numerator_and_denominator_roles() { for metric in builtin_metrics() { diff --git a/src/backend/services/analytics/src/domain/metric_definitions/listing.rs b/src/backend/services/analytics/src/domain/metric_definitions/listing.rs index 636c7f49b..6ef9b396d 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/listing.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/listing.rs @@ -18,7 +18,7 @@ use uuid::Uuid; use crate::domain::metric_definitions::definition::{MetricDirection, MetricFormat, MetricOrigin}; use crate::domain::metric_definitions::error_code::{MetricSchemaErrorCode, SchemaStatus}; -use crate::domain::metric_definitions::repository::fetch_dimensions; +use crate::domain::metric_definitions::repository::{fetch_dimensions, fetch_tags}; use crate::domain::metric_drilldown::{MetricDrilldownCapability, load_capabilities}; /// Response body for `GET /v1/metric-definitions`. Metrics are sorted by @@ -37,12 +37,20 @@ pub struct MetricDefinitionView { /// Compact label for dense surfaces; absent when the full label is /// already compact enough. pub short_label: Option, + /// The single topic this metric belongs to within its family, so a surface + /// listing a family can partition it into topics rather than only sorting + /// by name. Exactly one per metric; absent only for metrics that declare + /// none. + pub subject: Option, pub description: Option, pub explanation: Option, pub unit: Option, pub format: MetricFormat, pub direction: MetricDirection, pub dimensions: Vec, + /// Cross-cutting labels a surface can filter or search by; many per metric, + /// unlike the singular `subject`. Empty when the metric declares none. + pub tags: Vec, pub is_enabled: bool, /// `builtin` metrics read managed observation relations; `custom` metrics /// execute inline SQL at query time. The validator stamps `schema_status` @@ -72,6 +80,7 @@ struct ListingRow { metric_key: String, label: String, short_label: Option, + subject: Option, description: Option, explanation: Option, unit: Option, @@ -111,8 +120,11 @@ pub async fn list_definition_views( let dimensions = fetch_dimensions(db, &definition_ids) .await .map_err(|error| db_error(&error))?; + let tags = fetch_tags(db, &definition_ids) + .await + .map_err(|error| db_error(&error))?; - let mut metrics = build_views(selected, dimensions)?; + let mut metrics = build_views(selected, dimensions, tags)?; for metric in &mut metrics { metric.drilldown = capabilities.remove(&metric.metric_key); } @@ -142,6 +154,7 @@ fn select_rows(rows: Vec) -> Vec { fn build_views( selected: Vec, mut dimensions: HashMap>, + mut tags: HashMap>, ) -> Result, CanonicalError> { let mut metrics = Vec::with_capacity(selected.len()); for row in selected { @@ -165,12 +178,14 @@ fn build_views( metric_key: row.metric_key, label: row.label, short_label: row.short_label, + subject: row.subject, description: row.description, explanation: row.explanation, unit: row.unit, format, direction, dimensions: dimensions.remove(&row.definition_id).unwrap_or_default(), + tags: tags.remove(&row.definition_id).unwrap_or_default(), is_enabled: row.is_enabled, origin, schema_status, @@ -194,6 +209,7 @@ async fn fetch_listing_rows( d.metric_key AS metric_key, \ d.label AS label, \ d.short_label AS short_label, \ + d.subject AS subject, \ d.description AS description, \ d.explanation AS explanation, \ d.unit AS unit, \ @@ -239,6 +255,7 @@ mod tests { metric_key: metric_key.to_owned(), label: label.to_owned(), short_label: None, + subject: None, description: None, explanation: None, unit: None, @@ -277,12 +294,14 @@ mod tests { #[test] fn build_views_decodes_columns_and_attaches_dimensions() { let mut r = row("git.commits", None, "Commits"); + r.subject = Some("commits".to_owned()); r.schema_status = "error".to_owned(); r.schema_error_code = Some("table_not_found".to_owned()); let id = r.definition_id; let dims = HashMap::from([(id, vec!["repo".to_owned()])]); + let tags = HashMap::from([(id, vec!["rate".to_owned()])]); - let Ok(views) = build_views(vec![r], dims) else { + let Ok(views) = build_views(vec![r], dims, tags) else { panic!("canonical rows must map"); }; assert_eq!(views.len(), 1); @@ -298,6 +317,8 @@ mod tests { Some(MetricSchemaErrorCode::TableNotFound) ); assert_eq!(view.dimensions, vec!["repo".to_owned()]); + assert_eq!(view.subject.as_deref(), Some("commits")); + assert_eq!(view.tags, vec!["rate".to_owned()]); } #[test] @@ -305,7 +326,7 @@ mod tests { let mut r = row("team.velocity", None, "Velocity"); r.origin = "custom".to_owned(); - let Ok(views) = build_views(vec![r], HashMap::new()) else { + let Ok(views) = build_views(vec![r], HashMap::new(), HashMap::new()) else { panic!("canonical rows must map"); }; let Some(view) = views.first() else { @@ -315,16 +336,18 @@ mod tests { assert_eq!(view.schema_status, SchemaStatus::Unchecked); assert_eq!(view.schema_error_code, None); assert_eq!(view.last_observed_date, None); + assert_eq!(view.subject, None); + assert!(view.tags.is_empty()); } #[test] fn build_views_rejects_a_noncanonical_enum_value() { let mut r = row("git.commits", None, "Commits"); r.format = "not-a-format".to_owned(); - assert!(build_views(vec![r], HashMap::new()).is_err()); + assert!(build_views(vec![r], HashMap::new(), HashMap::new()).is_err()); let mut r = row("git.commits", None, "Commits"); r.origin = "not-an-origin".to_owned(); - assert!(build_views(vec![r], HashMap::new()).is_err()); + assert!(build_views(vec![r], HashMap::new(), HashMap::new()).is_err()); } } diff --git a/src/backend/services/analytics/src/domain/metric_definitions/registry.yaml b/src/backend/services/analytics/src/domain/metric_definitions/registry.yaml index 84032e816..945c470fa 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/registry.yaml +++ b/src/backend/services/analytics/src/domain/metric_definitions/registry.yaml @@ -179,6 +179,7 @@ sources: metrics: - metric_key: ai.accepted_lines source_key: ai_usage + subject: code label: AI-added lines short_label: AI lines + description: Accepted added coding output @@ -196,6 +197,7 @@ metrics: - tool - metric_key: ai.removed_lines source_key: ai_usage + subject: code label: AI-removed lines short_label: AI lines − description: Accepted deleted coding output @@ -213,6 +215,7 @@ metrics: - tool - metric_key: ai.active_days source_key: ai_usage + subject: activity label: AI active days short_label: AI days description: Days with any AI activity across dev and assistant tools @@ -229,6 +232,7 @@ metrics: dimensions: [] - metric_key: ai.cost source_key: ai_usage + subject: cost label: AI usage cost description: AI usage priced at vendor token/usage rates explanation: Person-attributed AI usage priced at the vendor's token or usage rates — what the consumption would cost if billed purely by usage. Includes usage a seat or subscription already covered, and excludes seat and subscription fees, so it is not the amount invoiced. Covers the tools whose connector prices usage per person. @@ -244,6 +248,7 @@ metrics: - tool - metric_key: ai.accepted_edit_actions source_key: ai_usage + subject: code label: Accepted AI edits short_label: AI edits description: Accepted tool or edit suggestions @@ -261,6 +266,9 @@ metrics: - tool - metric_key: ai.tool_acceptance_rate source_key: ai_usage + subject: code + tags: + - rate label: AI tool acceptance short_label: AI accept % description: Accepted divided by offered AI edits @@ -280,6 +288,7 @@ metrics: - tool - metric_key: ai.assistant_messages source_key: ai_usage + subject: assistant label: AI assistant messages short_label: AI msgs description: Assistant messages @@ -298,6 +307,7 @@ metrics: - surface - metric_key: ai.assistant_actions source_key: ai_usage + subject: assistant label: AI assistant actions short_label: AI actions description: Assistant actions @@ -316,6 +326,7 @@ metrics: - surface - metric_key: ai.dev_conversations source_key: ai_usage + subject: assistant label: AI dev conversations short_label: AI dev chats description: Coding tool conversations where the source reports them @@ -333,6 +344,7 @@ metrics: - tool - metric_key: ai.chat_assistant_conversations source_key: ai_usage + subject: assistant label: AI chat conversations short_label: AI chats description: Chat assistant conversations @@ -351,6 +363,7 @@ metrics: - surface - metric_key: git.commits source_key: git + subject: commits label: Commits description: Authored commits explanation: Distinct authored commits across connected git sources, excluding merge commits. @@ -369,6 +382,7 @@ metrics: - source - metric_key: git.code_lines source_key: git + subject: code_changes label: Code lines added short_label: Code lines description: Lines added to code files @@ -390,6 +404,7 @@ metrics: - source - metric_key: git.lines_added source_key: git + subject: code_changes label: Lines added short_label: Lines + description: All lines added, by file category @@ -412,6 +427,7 @@ metrics: - source - metric_key: git.lines_removed source_key: git + subject: code_changes label: Lines removed short_label: Lines − description: All lines removed, by file category @@ -434,6 +450,7 @@ metrics: - source - metric_key: git.prs_created source_key: git + subject: pull_requests label: Pull requests created short_label: PRs opened description: Authored pull requests @@ -454,6 +471,7 @@ metrics: - source - metric_key: git.prs_merged source_key: git + subject: pull_requests label: Pull requests merged short_label: PRs merged description: Authored pull requests merged @@ -474,6 +492,9 @@ metrics: - source - metric_key: git.merge_rate source_key: git + subject: pull_requests + tags: + - rate label: PR merge rate short_label: Merge % description: Share of created pull requests that merged @@ -496,6 +517,9 @@ metrics: - source - metric_key: git.commits_per_active_day source_key: git + subject: commits + tags: + - rate label: Commits per active day short_label: Commits/day description: Commit cadence on days with commits @@ -514,6 +538,9 @@ metrics: dimensions: [] - metric_key: git.commit_size source_key: git + subject: commits + tags: + - distribution label: Commit size description: Typical diff size per commit explanation: Median diff size of authored commits (lines added plus removed). Smaller commits are easier to review. @@ -532,6 +559,9 @@ metrics: - source - metric_key: git.pr_size source_key: git + subject: pull_requests + tags: + - distribution label: PR size description: Typical diff size per pull request explanation: Median diff size of authored pull requests (lines added plus removed). Smaller requests are easier to review. Sources that do not report line counts contribute no values. @@ -551,6 +581,10 @@ metrics: - source - metric_key: git.pr_cycle_time_h source_key: git + subject: pull_requests + tags: + - duration + - distribution label: PR cycle time short_label: PR cycle description: Typical hours from open to merge @@ -571,6 +605,7 @@ metrics: - source - metric_key: collab.messages_sent source_key: collab + subject: messaging label: Messages Sent short_label: Msgs description: Chat messages sent @@ -588,6 +623,7 @@ metrics: - tool - metric_key: collab.channel_posts source_key: collab + subject: messaging label: Channel Posts short_label: Channel posts description: Messages posted to shared channels, including replies @@ -605,6 +641,9 @@ metrics: - tool - metric_key: collab.dm_ratio source_key: collab + subject: messaging + tags: + - rate label: DM Ratio short_label: DM % description: Share of messages sent in direct or group chats @@ -624,6 +663,9 @@ metrics: - tool - metric_key: collab.msgs_per_active_day source_key: collab + subject: messaging + tags: + - rate label: Messages per Active Day short_label: Msgs/day description: Chat messages divided by chat-active days @@ -644,6 +686,7 @@ metrics: - tool - metric_key: collab.active_days source_key: collab + subject: activity label: Active Days description: Days with collaboration activity explanation: Distinct days on which a person took a deliberate collaboration action — sending a message, sending email, engaging or sharing a file, or attending a meeting. Passive activity such as receiving or reading email is excluded. @@ -660,6 +703,7 @@ metrics: - tool - metric_key: collab.emails_sent source_key: collab + subject: email label: Emails Sent short_label: Emails sent description: Emails sent @@ -677,6 +721,7 @@ metrics: - tool - metric_key: collab.emails_received source_key: collab + subject: email label: Emails Received short_label: Emails rcvd description: Emails received @@ -694,6 +739,7 @@ metrics: - tool - metric_key: collab.emails_read source_key: collab + subject: email label: Emails Read short_label: Emails read description: Emails read @@ -711,6 +757,7 @@ metrics: - tool - metric_key: collab.files_engaged source_key: collab + subject: documents label: Files Engaged description: Files viewed or edited explanation: Files a person viewed or edited. @@ -727,6 +774,7 @@ metrics: - tool - metric_key: collab.files_shared_internal source_key: collab + subject: documents label: Files Shared (Internal) short_label: Files (int) description: Files shared inside the organization @@ -744,6 +792,7 @@ metrics: - tool - metric_key: collab.files_shared_external source_key: collab + subject: documents label: Files Shared (External) short_label: Files (ext) description: Files shared outside the organization @@ -761,6 +810,7 @@ metrics: - tool - metric_key: collab.files_shared source_key: collab + subject: documents label: Files Shared short_label: Files shared description: Files shared with any recipient @@ -778,6 +828,9 @@ metrics: - scope - metric_key: collab.meeting_hours source_key: collab + subject: meetings + tags: + - duration label: Meeting Hours short_label: Mtg hrs description: Hours spent in meetings @@ -795,6 +848,7 @@ metrics: - tool - metric_key: collab.meetings_count source_key: collab + subject: meetings label: Meetings Attended short_label: Mtgs description: Distinct meetings attended @@ -812,6 +866,7 @@ metrics: - tool - metric_key: collab.meeting_free_days source_key: collab + subject: meetings label: Meeting-Free Days short_label: Mtg-free days description: Active days with no meeting time @@ -828,6 +883,9 @@ metrics: dimensions: [] - metric_key: collab.focus_time_pct source_key: collab + subject: focus + tags: + - rate label: Focus Time short_label: Focus % description: Share of the workday outside meetings @@ -846,6 +904,7 @@ metrics: dimensions: [] - metric_key: collab.breadth source_key: collab + subject: activity label: Collaboration Breadth short_label: Breadth description: Distinct collaboration modalities used @@ -862,6 +921,7 @@ metrics: dimensions: [] - metric_key: collab.meetings_organized source_key: collab + subject: meetings label: Meetings Organized short_label: Mtgs hosted description: Meetings organized @@ -879,6 +939,7 @@ metrics: - tool - metric_key: collab.adhoc_meetings source_key: collab + subject: meetings label: Ad-hoc Meetings short_label: Ad-hoc mtgs description: Unscheduled meetings attended @@ -896,6 +957,7 @@ metrics: - tool - metric_key: collab.scheduled_meetings source_key: collab + subject: meetings label: Scheduled Meetings short_label: Sched. mtgs description: Scheduled meetings attended @@ -913,12 +975,12 @@ metrics: - tool - metric_key: tasks.closed source_key: task + subject: throughput label: Issues closed short_label: Issues description: Every issue closed in the tracker explanation: >- - All issues a person moved into a closed status during the period. Bugs are - part of this number and are listed separately by type. + All issues a person moved into a closed status during the period. Bugs are part of this number and are listed separately by type. unit: issues format: integer direction: higher_is_better @@ -932,12 +994,12 @@ metrics: - type - metric_key: tasks.bugs_fixed source_key: task + subject: quality label: Bugs closed short_label: Bugs description: Bug-type issues closed explanation: >- - Issues of a bug type a person closed during the period. Part of issues - closed, not a separate total. + Issues of a bug type a person closed during the period. Part of issues closed, not a separate total. unit: issues format: integer direction: higher_is_better @@ -950,12 +1012,12 @@ metrics: dimensions: [] - metric_key: tasks.closed_non_bug source_key: task + subject: throughput label: Non-bug issues closed short_label: Non-bug description: Closed issues of a known non-bug type explanation: >- - Issues of a known non-bug type a person closed during the period. Issues - whose type cannot be determined are excluded rather than counted here. + Issues of a known non-bug type a person closed during the period. Issues whose type cannot be determined are excluded rather than counted here. unit: issues format: integer direction: higher_is_better @@ -968,6 +1030,10 @@ metrics: dimensions: [] - metric_key: tasks.dev_time source_key: task + subject: cycle_time + tags: + - duration + - distribution label: Development time short_label: Dev time description: Time an issue spends in active development @@ -984,6 +1050,10 @@ metrics: dimensions: [] - metric_key: tasks.resolution_time source_key: task + subject: cycle_time + tags: + - duration + - distribution label: Time to resolution short_label: Resolution description: Issue lifetime from creation to close @@ -1000,6 +1070,10 @@ metrics: dimensions: [] - metric_key: tasks.pickup_time source_key: task + subject: cycle_time + tags: + - duration + - distribution label: Pickup time short_label: Pickup description: Wait before work starts on an issue @@ -1016,6 +1090,9 @@ metrics: dimensions: [] - metric_key: tasks.flow_efficiency source_key: task + subject: cycle_time + tags: + - rate label: Flow efficiency short_label: Flow % description: Active development share of issue lifetime @@ -1036,6 +1113,9 @@ metrics: dimensions: [] - metric_key: tasks.reopen_rate source_key: task + subject: quality + tags: + - rate label: Reopen rate short_label: Reopen % description: Closed issues reopened shortly after @@ -1054,6 +1134,9 @@ metrics: dimensions: [] - metric_key: tasks.due_date_compliance source_key: task + subject: predictability + tags: + - rate label: Due date compliance short_label: Due date % description: On-time share of issues with a due date @@ -1072,6 +1155,9 @@ metrics: dimensions: [] - metric_key: tasks.on_time_delivery source_key: task + subject: predictability + tags: + - rate label: On-time delivery short_label: On-time % description: On-time share of all closed issues @@ -1090,6 +1176,10 @@ metrics: dimensions: [] - metric_key: tasks.avg_slip source_key: task + subject: predictability + tags: + - rate + - duration label: Average slip short_label: Avg slip description: How late overdue issues close @@ -1109,6 +1199,9 @@ metrics: dimensions: [] - metric_key: tasks.estimation_accuracy source_key: task + subject: predictability + tags: + - rate label: Estimation accuracy short_label: Estimate % description: How close estimates land to time spent @@ -1132,6 +1225,9 @@ metrics: dimensions: [] - metric_key: tasks.worklog_accuracy source_key: task + subject: predictability + tags: + - rate label: Worklog accuracy short_label: Worklog % description: Logged time versus tracked development @@ -1152,13 +1248,14 @@ metrics: dimensions: [] - metric_key: tasks.bugs_ratio source_key: task + subject: quality + tags: + - rate label: Bugs share of closed issues short_label: Bug share description: Bugs as a share of all closed issues explanation: >- - Bug-type issues as a share of all closed issues, issues of an - undetermined type included in the denominator. A share, so it cannot - exceed 100%. + Bug-type issues as a share of all closed issues, issues of an undetermined type included in the denominator. A share, so it cannot exceed 100%. format: percent direction: lower_is_better entity_type: person @@ -1173,6 +1270,7 @@ metrics: dimensions: [] - metric_key: tasks.stale_in_progress source_key: task + subject: throughput label: Stale in progress short_label: Stale WIP description: Open issues idle for over two weeks @@ -1189,6 +1287,7 @@ metrics: dimensions: [] - metric_key: wiki.pages_created source_key: wiki + subject: authoring label: Pages created short_label: New pages description: Wiki pages authored @@ -1205,6 +1304,7 @@ metrics: dimensions: [] - metric_key: wiki.edits source_key: wiki + subject: authoring label: Page edits short_label: Edits description: Wiki edit sessions @@ -1221,6 +1321,7 @@ metrics: dimensions: [] - metric_key: wiki.pages_edited source_key: wiki + subject: authoring label: Pages edited short_label: Pages edited description: Distinct wiki pages edited @@ -1237,6 +1338,7 @@ metrics: dimensions: [] - metric_key: wiki.comments source_key: wiki + subject: engagement label: Comments received short_label: Comments description: Comments on the person's wiki pages 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 efb78a726..7b2edb98a 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/repository.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/repository.rs @@ -58,6 +58,12 @@ struct DimensionRow { dimension_key: String, } +#[derive(Debug, FromQueryResult)] +struct TagRow { + metric_definition_id: Uuid, + tag: String, +} + #[derive(Debug)] enum ClassifiedInputs { Available(Vec), @@ -447,6 +453,46 @@ pub(super) async fn fetch_dimensions( }) } +pub(super) async fn fetch_tags( + 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 \ + t.metric_definition_id AS metric_definition_id, \ + t.tag AS tag \ + FROM metric_definition_tags t \ + WHERE t.metric_definition_id IN ({placeholders}) \ + ORDER BY t.metric_definition_id, t.display_order, t.tag" + ); + let values = definition_ids + .iter() + .map(|id| Value::Bytes(Some(Box::new(id.as_bytes().to_vec())))) + .collect::>(); + + TagRow::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.tag); + } + out + }) +} + fn build_definition( row: &DefinitionRow, inputs: &[MetricInput], 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 33fc68704..715fe30a1 100644 --- a/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs +++ b/src/backend/services/analytics/src/domain/metric_definitions/seeds.rs @@ -1,4 +1,4 @@ -use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, Statement, Value}; +use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, Statement, TransactionTrait, Value}; use uuid::Uuid; use crate::domain::metric_definitions::builtin::{ @@ -10,12 +10,20 @@ pub async fn reconcile_builtin_definitions(db: &DatabaseConnection) -> Result<() reconcile_source(db, builtin_source).await?; } + // One metric's definition and all its child rows (inputs, dimensions, tags) + // converge in a single transaction: a mid-way failure never leaves a metric + // with a partial child set, and a concurrent reconciler on another replica + // sees the whole prior set or the whole new one, never a delete-in-progress. + // DESIGN requires builtin upserts to be idempotent and race-safe. 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?; + let txn = db.begin().await?; + let source_id = fetch_source_id(&txn, &metric.source_key).await?; + upsert_metric(&txn, metric).await?; + let metric_id = fetch_metric_id(&txn, &metric.metric_key).await?; + replace_inputs(&txn, source_id, metric_id, &metric.inputs).await?; + replace_dimensions(&txn, source_id, metric_id, &metric.dimensions).await?; + replace_tags(&txn, metric_id, &metric.tags).await?; + txn.commit().await?; } disable_missing_builtin_rows(db).await?; @@ -96,17 +104,18 @@ async fn upsert_source( Ok(()) } -async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<(), DbErr> { +async fn upsert_metric(db: &impl ConnectionTrait, 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, short_label, description, explanation, unit, format, direction, entity_type, \ + (id, tenant_id, metric_key, label, short_label, subject, description, explanation, unit, format, direction, entity_type, \ computation_type, scale, transform_multiplier, transform_offset, transform_clamp_min, \ transform_clamp_max, peer_cohort_key, origin, is_enabled) \ - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'builtin', TRUE) \ + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'builtin', TRUE) \ ON DUPLICATE KEY UPDATE \ label = VALUES(label), \ short_label = VALUES(short_label), \ + subject = VALUES(subject), \ description = VALUES(description), \ explanation = VALUES(explanation), \ unit = VALUES(unit), \ @@ -127,6 +136,7 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( Value::from(metric.metric_key.as_str()), Value::from(metric.label.as_str()), nullable_str(metric.short_label.as_deref()), + Value::from(metric.subject.as_str()), nullable_str(metric.description.as_deref()), nullable_str(metric.explanation.as_deref()), nullable_str(metric.unit.as_deref()), @@ -150,7 +160,7 @@ async fn upsert_metric(db: &DatabaseConnection, metric: &MetricSeed) -> Result<( } async fn replace_inputs( - db: &DatabaseConnection, + db: &impl ConnectionTrait, source_id: Uuid, metric_id: Uuid, inputs: &[InputSeed], @@ -182,7 +192,7 @@ async fn replace_inputs( } async fn replace_dimensions( - db: &DatabaseConnection, + db: &impl ConnectionTrait, source_id: Uuid, metric_id: Uuid, dimensions: &[String], @@ -213,6 +223,36 @@ async fn replace_dimensions( Ok(()) } +async fn replace_tags( + db: &impl ConnectionTrait, + metric_id: Uuid, + tags: &[String], +) -> Result<(), DbErr> { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "DELETE FROM metric_definition_tags WHERE metric_definition_id = ?", + [uuid_value(metric_id)], + )) + .await?; + + for (idx, tag) in tags.iter().enumerate() { + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO metric_definition_tags \ + (id, metric_definition_id, tag, display_order) \ + VALUES (?, ?, ?, ?)", + [ + uuid_value(Uuid::now_v7()), + uuid_value(metric_id), + Value::from(tag.as_str()), + Value::from(order_value(idx)), + ], + )) + .await?; + } + Ok(()) +} + async fn disable_missing_builtin_rows(db: &DatabaseConnection) -> Result<(), DbErr> { let metric_keys = builtin_metrics() .iter() @@ -292,7 +332,7 @@ async fn disable_missing( Ok(()) } -async fn fetch_source_id(db: &DatabaseConnection, source_key: &str) -> Result { +async fn fetch_source_id(db: &impl ConnectionTrait, source_key: &str) -> Result { fetch_uuid( db, "SELECT id FROM metric_sources WHERE tenant_id IS NULL AND source_key = ?", @@ -303,7 +343,7 @@ async fn fetch_source_id(db: &DatabaseConnection, source_key: &str) -> Result Result { @@ -317,7 +357,7 @@ async fn fetch_measure_id( } async fn fetch_source_dimension_id( - db: &DatabaseConnection, + db: &impl ConnectionTrait, source_id: Uuid, dimension_key: &str, ) -> Result { @@ -330,7 +370,7 @@ async fn fetch_source_dimension_id( .await } -async fn fetch_metric_id(db: &DatabaseConnection, metric_key: &str) -> Result { +async fn fetch_metric_id(db: &impl ConnectionTrait, metric_key: &str) -> Result { fetch_uuid( db, "SELECT id FROM metric_definitions WHERE tenant_id IS NULL AND metric_key = ?", @@ -341,7 +381,7 @@ async fn fetch_metric_id(db: &DatabaseConnection, metric_key: &str) -> Result Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared(ADD_COLUMN) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE metric_definitions \ + DROP COLUMN IF EXISTS subject", + ) + .await?; + Ok(()) + } +} diff --git a/src/backend/services/analytics/src/migration/m20260810_000002_metric_definition_tags.rs b/src/backend/services/analytics/src/migration/m20260810_000002_metric_definition_tags.rs new file mode 100644 index 000000000..a411c1599 --- /dev/null +++ b/src/backend/services/analytics/src/migration/m20260810_000002_metric_definition_tags.rs @@ -0,0 +1,46 @@ +//! Adds free-form tags to metric definitions. Tags are cross-cutting labels a +//! surface can filter or search by (e.g. `time`, `calendar`) — many per +//! metric, unlike the singular grouping `subject`. Tags are plain slugs, not +//! bound to a source the way dimensions are, so this table carries the label +//! directly rather than referencing `metric_source_dimensions`. + +use sea_orm_migration::prelude::*; + +pub const REQUIRED_TAG_CHECKS: &[&str] = &[ + "chk_metric_definition_tags_tag_shape", + "chk_metric_definition_tags_display_order_nonnegative", +]; + +const CREATE_TABLE: &str = "CREATE TABLE IF NOT EXISTS metric_definition_tags ( + id BINARY(16) NOT NULL PRIMARY KEY, + metric_definition_id BINARY(16) NOT NULL, + tag VARCHAR(64) NOT NULL, + display_order INT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_metric_definition_tags_tag (metric_definition_id, tag), + CONSTRAINT fk_metric_definition_tags_definition FOREIGN KEY (metric_definition_id) REFERENCES metric_definitions(id) ON DELETE CASCADE, + CONSTRAINT chk_metric_definition_tags_tag_shape CHECK (tag REGEXP BINARY '^[a-z][a-z0-9_]*$'), + CONSTRAINT chk_metric_definition_tags_display_order_nonnegative CHECK (display_order >= 0) +)"; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared(CREATE_TABLE) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS metric_definition_tags") + .await?; + Ok(()) + } +} diff --git a/src/backend/services/analytics/src/migration/mod.rs b/src/backend/services/analytics/src/migration/mod.rs index b7ae3a284..3e316f36c 100644 --- a/src/backend/services/analytics/src/migration/mod.rs +++ b/src/backend/services/analytics/src/migration/mod.rs @@ -58,6 +58,8 @@ mod m20260727_000001_metric_evidence; mod m20260730_000001_saved_queries; mod m20260805_000001_semantic_definition_core; mod m20260806_000001_metric_custom_observation_sql; +mod m20260810_000001_metric_definition_subject; +mod m20260810_000002_metric_definition_tags; use sea_orm_migration::prelude::*; @@ -125,6 +127,8 @@ impl MigratorTrait for Migrator { Box::new(m20260730_000001_saved_queries::Migration), Box::new(m20260805_000001_semantic_definition_core::Migration), Box::new(m20260806_000001_metric_custom_observation_sql::Migration), + Box::new(m20260810_000001_metric_definition_subject::Migration), + Box::new(m20260810_000002_metric_definition_tags::Migration), ] } } @@ -167,6 +171,10 @@ pub const REQUIRED_CHECKS_BY_TABLE: &[(&str, &[&str])] = &[ "metric_definition_dimensions", m20260625_000001_metric_definitions::REQUIRED_DIMENSION_CHECKS, ), + ( + "metric_definition_tags", + m20260810_000002_metric_definition_tags::REQUIRED_TAG_CHECKS, + ), ( "semantic_datasets", m20260805_000001_semantic_definition_core::REQUIRED_DATASET_CHECKS, @@ -239,6 +247,10 @@ mod tests { "metric_definition_dimensions", m20260625_000001_metric_definitions::REQUIRED_DIMENSION_CHECKS, ), + ( + "metric_definition_tags", + m20260810_000002_metric_definition_tags::REQUIRED_TAG_CHECKS, + ), ( "semantic_datasets", m20260805_000001_semantic_definition_core::REQUIRED_DATASET_CHECKS, diff --git a/src/ingestion/tests/e2e/lib/collect_metric_definitions.py b/src/ingestion/tests/e2e/lib/collect_metric_definitions.py index 257621708..26612cfee 100644 --- a/src/ingestion/tests/e2e/lib/collect_metric_definitions.py +++ b/src/ingestion/tests/e2e/lib/collect_metric_definitions.py @@ -14,16 +14,22 @@ def collect(cfg: SessionConfig, out_dir: str | Path) -> Path: SELECT d.metric_key, d.label, + d.subject, d.computation_type, d.peer_cohort_key, - GROUP_CONCAT(sd.dimension_key ORDER BY dd.display_order SEPARATOR ',') + GROUP_CONCAT(sd.dimension_key ORDER BY dd.display_order SEPARATOR ','), + ( + SELECT GROUP_CONCAT(t.tag ORDER BY t.display_order SEPARATOR ',') + FROM metric_definition_tags t + WHERE t.metric_definition_id = d.id + ) FROM metric_definitions d LEFT JOIN metric_definition_dimensions dd ON dd.metric_definition_id = d.id LEFT JOIN metric_source_dimensions sd ON sd.id = dd.source_dimension_id WHERE d.tenant_id IS NULL AND d.origin = 'builtin' AND d.is_enabled = TRUE - GROUP BY d.id, d.metric_key, d.label, d.computation_type, d.peer_cohort_key + GROUP BY d.id, d.metric_key, d.label, d.subject, d.computation_type, d.peer_cohort_key ORDER BY d.metric_key """, ) @@ -33,11 +39,13 @@ def collect(cfg: SessionConfig, out_dir: str | Path) -> Path: { "metric_key": metric_key, "label": label, + "subject": subject, "computation": computation, "peer_cohort_key": peer_cohort_key, "dimensions": dimensions.split(",") if dimensions else [], + "tags": tags.split(",") if tags else [], } - for metric_key, label, computation, peer_cohort_key, dimensions in rows + for metric_key, label, subject, computation, peer_cohort_key, dimensions, tags in rows ] output_dir = Path(out_dir) output_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/stand/api/schemas/analytics.py b/tests/stand/api/schemas/analytics.py index 589c9c8cd..ffccaf74d 100644 --- a/tests/stand/api/schemas/analytics.py +++ b/tests/stand/api/schemas/analytics.py @@ -640,6 +640,8 @@ class MetricDefinitionView(BaseModel): schema_error_code: MetricSchemaErrorCode | None = None schema_status: SchemaStatus short_label: str | None = Field(None, description='Compact label for dense surfaces; absent when the full label is\nalready compact enough.') + subject: str | None = Field(None, description='The single topic this metric belongs to within its family, so a surface\nlisting a family can partition it into topics rather than only sorting\nby name. Exactly one per metric; absent only for metrics that declare\nnone.') + tags: list[str] = Field(..., description='Cross-cutting labels a surface can filter or search by; many per metric,\nunlike the singular `subject`. Empty when the metric declares none.') unit: str | None = None