From 344d7440893b2fa6eebc8ce605bb6b350a55c8af Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Fri, 10 Apr 2026 19:35:28 +0200 Subject: [PATCH] feat: add 10 starter pack metrics (revenue, usage, sales, operations) --- docs/metrics/metrics.yml | 15 ++++++ .../operations/infrastructure_cost.yml | 40 ++++++++++++++++ .../operations/support_resolution_time.yml | 36 ++++++++++++++ docs/metrics/product_usage/active_users.yml | 29 +++++++++++ .../product_usage/feature_adoption.yml | 48 +++++++++++++++++++ docs/metrics/revenue/arr.yml | 29 +++++++++++ docs/metrics/revenue/churn_rate.yml | 48 +++++++++++++++++++ docs/metrics/revenue/mrr.yml | 39 +++++++++++++++ docs/metrics/sales/new_customers.yml | 36 ++++++++++++++ docs/metrics/sales/pipeline_value.yml | 34 +++++++++++++ docs/metrics/sales/upsell_expansion.yml | 30 ++++++++++++ tests/test_metrics.py | 15 ++++++ 12 files changed, 399 insertions(+) create mode 100644 docs/metrics/metrics.yml create mode 100644 docs/metrics/operations/infrastructure_cost.yml create mode 100644 docs/metrics/operations/support_resolution_time.yml create mode 100644 docs/metrics/product_usage/active_users.yml create mode 100644 docs/metrics/product_usage/feature_adoption.yml create mode 100644 docs/metrics/revenue/arr.yml create mode 100644 docs/metrics/revenue/churn_rate.yml create mode 100644 docs/metrics/revenue/mrr.yml create mode 100644 docs/metrics/sales/new_customers.yml create mode 100644 docs/metrics/sales/pipeline_value.yml create mode 100644 docs/metrics/sales/upsell_expansion.yml diff --git a/docs/metrics/metrics.yml b/docs/metrics/metrics.yml new file mode 100644 index 0000000..6478e94 --- /dev/null +++ b/docs/metrics/metrics.yml @@ -0,0 +1,15 @@ +version: "2.0" +description: "Business metrics starter pack. Import with: da metrics import docs/metrics/" +categories: + - name: revenue + folder: revenue/ + metrics: [total_revenue, mrr, arr, churn_rate] + - name: product_usage + folder: product_usage/ + metrics: [active_users, feature_adoption] + - name: sales + folder: sales/ + metrics: [new_customers, upsell_expansion, pipeline_value] + - name: operations + folder: operations/ + metrics: [support_resolution_time, infrastructure_cost] diff --git a/docs/metrics/operations/infrastructure_cost.yml b/docs/metrics/operations/infrastructure_cost.yml new file mode 100644 index 0000000..5b55cc7 --- /dev/null +++ b/docs/metrics/operations/infrastructure_cost.yml @@ -0,0 +1,40 @@ +name: infrastructure_cost +display_name: Infrastructure Cost +category: operations +type: sum +unit: USD +grain: monthly +table: infra_costs +expression: "SUM(cost_usd)" +time_column: usage_date +description: "Infrastructure Cost — total cloud infrastructure spend per month, broken down by provider, service, and environment." +dimensions: + - provider + - service + - environment +synonyms: + - cloud_cost + - infra_spend + - cloud_spend +notes: + - "Includes compute, storage, networking, and managed services" + - "Production and staging costs should be tracked separately via environment dimension" + - "Compare month-over-month to detect unexpected cost spikes" + - "Calculate cost per MAU to normalize against growth" +sql: | + SELECT + DATE_TRUNC('month', usage_date) AS month, + SUM(cost_usd) AS infrastructure_cost + FROM infra_costs + GROUP BY 1 + ORDER BY 1 +sql_by_provider: | + SELECT + DATE_TRUNC('month', usage_date) AS month, + provider, + service, + environment, + SUM(cost_usd) AS cost + FROM infra_costs + GROUP BY 1, 2, 3, 4 + ORDER BY 1, cost DESC diff --git a/docs/metrics/operations/support_resolution_time.yml b/docs/metrics/operations/support_resolution_time.yml new file mode 100644 index 0000000..26e1c19 --- /dev/null +++ b/docs/metrics/operations/support_resolution_time.yml @@ -0,0 +1,36 @@ +name: support_resolution_time +display_name: Support Resolution Time +category: operations +type: avg +unit: hours +grain: monthly +table: tickets +expression: "AVG(resolution_hours)" +time_column: created_at +description: "Average Support Resolution Time — the mean time in hours from ticket creation to resolution. Key support quality and efficiency metric." +dimensions: + - priority + - category +synonyms: + - mean_resolution_time + - avg_resolution_time + - time_to_resolve + - ttr +notes: + - "Resolution time = resolved_at - created_at, in hours" + - "Exclude tickets still open (no resolved_at)" + - "SLA targets vary by priority: Critical <4h, High <24h, Normal <72h" + - "Consider p50/p95 in addition to mean to detect outliers" +sql: | + SELECT + DATE_TRUNC('month', created_at) AS month, + priority, + category, + COUNT(*) AS tickets_resolved, + ROUND(AVG(resolution_hours), 2) AS avg_resolution_hours, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resolution_hours), 2) AS p50_hours, + ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY resolution_hours), 2) AS p95_hours + FROM tickets + WHERE resolved_at IS NOT NULL + GROUP BY 1, 2, 3 + ORDER BY 1, priority, avg_resolution_hours DESC diff --git a/docs/metrics/product_usage/active_users.yml b/docs/metrics/product_usage/active_users.yml new file mode 100644 index 0000000..fa034d1 --- /dev/null +++ b/docs/metrics/product_usage/active_users.yml @@ -0,0 +1,29 @@ +name: active_users +display_name: Monthly Active Users +category: product_usage +type: count +unit: users +grain: monthly +table: user_events +expression: "COUNT(DISTINCT user_id)" +time_column: event_date +description: "Monthly Active Users (MAU) — the count of unique users who performed at least one event in a given month." +dimensions: + - feature + - plan_type +synonyms: + - mau + - monthly_actives + - active_users_monthly +notes: + - "A user counts as active if they trigger any tracked event within the month" + - "Distinguish from logins-only — prefer meaningful engagement events" + - "Filter internal/test accounts before aggregating" +sql: | + SELECT + DATE_TRUNC('month', event_date) AS month, + COUNT(DISTINCT user_id) AS active_users + FROM user_events + WHERE event_type != 'internal' + GROUP BY 1 + ORDER BY 1 diff --git a/docs/metrics/product_usage/feature_adoption.yml b/docs/metrics/product_usage/feature_adoption.yml new file mode 100644 index 0000000..b2e7f16 --- /dev/null +++ b/docs/metrics/product_usage/feature_adoption.yml @@ -0,0 +1,48 @@ +name: feature_adoption +display_name: Feature Adoption Rate +category: product_usage +type: ratio +unit: percentage +grain: monthly +table: user_events +expression: "COUNT(DISTINCT feature_users) / COUNT(DISTINCT total_users) * 100" +time_column: event_date +description: "Feature Adoption Rate — the percentage of active users who used a specific feature in a given month." +dimensions: + - feature + - plan_type +synonyms: + - feature_usage_rate + - adoption_rate +notes: + - "Denominator is all MAU for the period, numerator is MAU who triggered the feature event" + - "Track each feature separately by filtering on feature_name or event_type" + - "Adoption >20% within 90 days of launch is a common success threshold" +sql: | + WITH monthly_active AS ( + SELECT + DATE_TRUNC('month', event_date) AS month, + COUNT(DISTINCT user_id) AS total_users + FROM user_events + WHERE event_type != 'internal' + GROUP BY 1 + ), + feature_users AS ( + SELECT + DATE_TRUNC('month', event_date) AS month, + feature_name, + COUNT(DISTINCT user_id) AS users_adopted + FROM user_events + WHERE event_type = 'feature_used' + AND event_type != 'internal' + GROUP BY 1, 2 + ) + SELECT + f.month, + f.feature_name, + f.users_adopted, + m.total_users, + ROUND(f.users_adopted * 100.0 / NULLIF(m.total_users, 0), 2) AS adoption_rate_pct + FROM feature_users f + JOIN monthly_active m ON f.month = m.month + ORDER BY f.month, adoption_rate_pct DESC diff --git a/docs/metrics/revenue/arr.yml b/docs/metrics/revenue/arr.yml new file mode 100644 index 0000000..952af08 --- /dev/null +++ b/docs/metrics/revenue/arr.yml @@ -0,0 +1,29 @@ +name: arr +display_name: Annual Recurring Revenue +category: revenue +type: sum +unit: USD +grain: monthly +table: subscriptions +expression: "SUM(mrr_amount) * 12" +time_column: billing_date +description: "Annual Recurring Revenue — MRR annualized. Standard SaaS valuation metric used for board reporting and investor communications." +dimensions: + - plan_type + - region +synonyms: + - annual_revenue + - annualized_revenue + - annualized_mrr +notes: + - "ARR = MRR * 12 — snapshot at end of period" + - "Not a trailing 12-month sum; it is the current MRR extrapolated to a year" + - "Excludes one-time and non-recurring revenue" +sql: | + SELECT + DATE_TRUNC('month', billing_date) AS month, + SUM(mrr_amount) * 12 AS arr + FROM subscriptions + WHERE status = 'active' + GROUP BY 1 + ORDER BY 1 diff --git a/docs/metrics/revenue/churn_rate.yml b/docs/metrics/revenue/churn_rate.yml new file mode 100644 index 0000000..2e6ff56 --- /dev/null +++ b/docs/metrics/revenue/churn_rate.yml @@ -0,0 +1,48 @@ +name: churn_rate +display_name: Monthly Churn Rate +category: revenue +type: ratio +unit: percentage +grain: monthly +table: subscriptions +expression: "churned_mrr / beginning_mrr * 100" +time_column: billing_date +description: "Monthly revenue churn rate — the percentage of MRR lost due to cancellations and downgrades in a given month." +dimensions: + - plan_type + - region +synonyms: + - mrr_churn + - revenue_churn + - monthly_churn +notes: + - "Revenue churn differs from customer churn — one large customer churning can dominate" + - "Negative churn occurs when expansion revenue exceeds lost revenue" + - "Target: <2% monthly for healthy SaaS businesses" + - "Churned MRR includes full cancellations and partial downgrades" +sql: | + WITH monthly_mrr AS ( + SELECT + DATE_TRUNC('month', billing_date) AS month, + SUM(CASE WHEN status = 'active' THEN mrr_amount ELSE 0 END) AS active_mrr, + SUM(CASE WHEN status = 'churned' + AND DATE_TRUNC('month', churned_at) = DATE_TRUNC('month', billing_date) + THEN mrr_amount ELSE 0 END) AS churned_mrr + FROM subscriptions + GROUP BY 1 + ), + lagged AS ( + SELECT + month, + churned_mrr, + LAG(active_mrr) OVER (ORDER BY month) AS beginning_mrr + FROM monthly_mrr + ) + SELECT + month, + churned_mrr, + beginning_mrr, + ROUND(churned_mrr / NULLIF(beginning_mrr, 0) * 100, 2) AS churn_rate_pct + FROM lagged + WHERE beginning_mrr IS NOT NULL + ORDER BY month diff --git a/docs/metrics/revenue/mrr.yml b/docs/metrics/revenue/mrr.yml new file mode 100644 index 0000000..5f87359 --- /dev/null +++ b/docs/metrics/revenue/mrr.yml @@ -0,0 +1,39 @@ +name: mrr +display_name: Monthly Recurring Revenue +category: revenue +type: sum +unit: USD +grain: monthly +table: subscriptions +expression: "SUM(mrr_amount)" +time_column: billing_date +description: "Monthly Recurring Revenue — the predictable monthly revenue from active subscriptions. Core SaaS health metric." +dimensions: + - plan_type + - region +synonyms: + - monthly_revenue + - recurring_revenue + - subscription_revenue +notes: + - "Excludes one-time fees, setup fees, and professional services" + - "Churned subscriptions are removed in the month they cancel" + - "Upgrades and downgrades are reflected in the month the change takes effect" + - "Use billing_date (not payment date) for recognition" +sql: | + SELECT + DATE_TRUNC('month', billing_date) AS month, + SUM(mrr_amount) AS mrr + FROM subscriptions + WHERE status = 'active' + GROUP BY 1 + ORDER BY 1 +sql_by_plan: | + SELECT + DATE_TRUNC('month', billing_date) AS month, + plan_type, + SUM(mrr_amount) AS mrr + FROM subscriptions + WHERE status = 'active' + GROUP BY 1, 2 + ORDER BY 1, 3 DESC diff --git a/docs/metrics/sales/new_customers.yml b/docs/metrics/sales/new_customers.yml new file mode 100644 index 0000000..5894611 --- /dev/null +++ b/docs/metrics/sales/new_customers.yml @@ -0,0 +1,36 @@ +name: new_customers +display_name: New Customers +category: sales +type: count +unit: customers +grain: monthly +table: orders +expression: "COUNT(DISTINCT customer_id)" +time_column: order_date +description: "New Customers — the count of customers placing their first-ever order in a given month." +dimensions: + - channel + - region +synonyms: + - new_logos + - first_time_buyers + - customer_acquisition +notes: + - "A customer is 'new' only if they have no prior orders in any historical period" + - "Exclude internal test accounts and employees from the count" + - "Use acquisition channel (UTM/referral) from first order for attribution" +sql: | + WITH first_orders AS ( + SELECT + customer_id, + MIN(order_date) AS first_order_date + FROM orders + WHERE status != 'cancelled' + GROUP BY customer_id + ) + SELECT + DATE_TRUNC('month', first_order_date) AS month, + COUNT(DISTINCT customer_id) AS new_customers + FROM first_orders + GROUP BY 1 + ORDER BY 1 diff --git a/docs/metrics/sales/pipeline_value.yml b/docs/metrics/sales/pipeline_value.yml new file mode 100644 index 0000000..c79b171 --- /dev/null +++ b/docs/metrics/sales/pipeline_value.yml @@ -0,0 +1,34 @@ +name: pipeline_value +display_name: Pipeline Value +category: sales +type: sum +unit: USD +grain: monthly +table: opportunities +expression: "SUM(deal_value * probability / 100)" +time_column: created_at +description: "Pipeline Value — the probability-weighted total value of open sales opportunities, used for revenue forecasting." +dimensions: + - stage + - owner + - region +synonyms: + - weighted_pipeline + - pipeline_forecast + - opportunity_value +notes: + - "Weighted by close probability for each stage (e.g., 20% at Qualify, 80% at Proposal)" + - "Snapshot the pipeline at month-end for trend analysis" + - "Exclude closed-won and closed-lost opportunities" + - "Compare to quota to assess pipeline coverage ratio (target: 3-4x)" +sql: | + SELECT + DATE_TRUNC('month', created_at) AS month, + stage, + COUNT(*) AS opportunity_count, + SUM(deal_value) AS total_deal_value, + SUM(deal_value * probability / 100.0) AS weighted_pipeline_value + FROM opportunities + WHERE status = 'open' + GROUP BY 1, 2 + ORDER BY 1, weighted_pipeline_value DESC diff --git a/docs/metrics/sales/upsell_expansion.yml b/docs/metrics/sales/upsell_expansion.yml new file mode 100644 index 0000000..68cbcc5 --- /dev/null +++ b/docs/metrics/sales/upsell_expansion.yml @@ -0,0 +1,30 @@ +name: upsell_expansion +display_name: Upsell & Expansion Revenue +category: sales +type: sum +unit: USD +grain: monthly +table: subscriptions +expression: "SUM(delta_mrr)" +time_column: billing_date +description: "Upsell & Expansion MRR — incremental recurring revenue gained from existing customers upgrading or expanding their subscriptions." +dimensions: + - plan_type + - region +synonyms: + - expansion_mrr + - upsell_revenue + - expansion_revenue +notes: + - "Only includes positive MRR changes (upgrades, seat additions, add-ons)" + - "Excludes new business — filter on customers with prior active subscriptions" + - "Negative expansion (downgrades) is tracked separately as contraction MRR" +sql: | + SELECT + DATE_TRUNC('month', billing_date) AS month, + SUM(delta_mrr) AS expansion_mrr + FROM subscriptions + WHERE change_type IN ('upgrade', 'expansion', 'seat_add') + AND delta_mrr > 0 + GROUP BY 1 + ORDER BY 1 diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 6ced1ac..fc8a064 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -396,6 +396,21 @@ class TestMetricRepositoryImport: assert len(all_metrics) == 2 +class TestStarterPack: + def test_import_starter_pack(self, db_conn): + from src.repositories.metrics import MetricRepository + from pathlib import Path + repo = MetricRepository(db_conn) + starter_dir = Path(__file__).parent.parent / "docs" / "metrics" + if not starter_dir.exists(): + pytest.skip("Starter pack not found") + count = repo.import_from_yaml(starter_dir) + assert count >= 11 # total_revenue + 10 new + assert repo.get("revenue/total_revenue") is not None + assert repo.get("revenue/mrr") is not None + assert repo.get("operations/infrastructure_cost") is not None + + class TestMetricRepositoryExport: def test_export_to_yaml(self, db_conn, metrics_dir, tmp_path): from src.repositories.metrics import MetricRepository