From da4252aba44d5d84365aef7b9f04e0761c67d63c Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 23 Jun 2026 15:41:07 -0400 Subject: [PATCH 1/2] Add push-metrics command for Datadog integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the SQLite database and pushes GitHub project health metrics to Datadog as gauge snapshots at the current timestamp (avoiding Datadog's silent rejection of historical data points). Metrics pushed: cose.gh.issues gauge — count of open issues by tag group cose.gh.prs gauge — count of open PRs by tag group cose.gh.discussions gauge — count of open discussions by tag group cose.gh.issues.closed count — issues closed in the --since window cose.gh.prs.closed count — PRs closed in the --since window Tag model: - Labels with a prefix become structured tags (domain:vrl, type:bug) - Unprefixed labels become label: - GitHub issue types become issue_type: - Age bucket tag (age:0-7d / 7-30d / 30-90d / 90d+) on open items - state:open / state:closed on all items Open backlog uses no date filter — captures all open items regardless of when they were created. --since (default 30d) controls only the velocity window for closed metrics; accepts ISO date, YYYY-MM, or relative (30d, 3m, 1y). Usage: github-tools push-metrics --repo vectordotdev/vector --dry-run github-tools push-metrics --repo vectordotdev/vector --dd-api-key $DD_API_KEY github-tools push-metrics --repo vectordotdev/vector --dd-api-key $DD_API_KEY --since 7d --- Cargo.toml | 2 +- src/commands/fetch_automated_review_stats.rs | 10 +- src/commands/generate_charts.rs | 44 +-- src/commands/mod.rs | 1 + src/commands/push_metrics.rs | 385 +++++++++++++++++++ src/main.rs | 25 +- 6 files changed, 437 insertions(+), 30 deletions(-) create mode 100644 src/commands/push_metrics.rs diff --git a/Cargo.toml b/Cargo.toml index 54512ae..4fb81b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" [dependencies] # CLI -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } # Error handling anyhow = "1" diff --git a/src/commands/fetch_automated_review_stats.rs b/src/commands/fetch_automated_review_stats.rs index b8be1a8..24c3425 100644 --- a/src/commands/fetch_automated_review_stats.rs +++ b/src/commands/fetch_automated_review_stats.rs @@ -111,20 +111,18 @@ pub fn run(config: &Config, bot_login: Option<&str>, since: Option<&str>) -> Res for pr in nodes { // updatedAt >= mergedAt always, so this is a sound early-termination criterion. let updated_at = pr["updatedAt"].as_str().unwrap_or(""); - if let Some(ref ts) = since_ts { - if updated_at < ts.as_str() { + if let Some(ref ts) = since_ts + && updated_at < ts.as_str() { hit_boundary = true; continue; } - } // Skip PRs merged before the cutoff (updated after it, e.g. a late comment). let merged_at = pr["mergedAt"].as_str().unwrap_or(""); - if let Some(ref ts) = since_ts { - if merged_at < ts.as_str() { + if let Some(ref ts) = since_ts + && merged_at < ts.as_str() { continue; } - } stats.prs_scanned += 1; let pr_number = pr["number"].as_u64().unwrap_or(0); diff --git a/src/commands/generate_charts.rs b/src/commands/generate_charts.rs index 5f04962..6b3b5c1 100644 --- a/src/commands/generate_charts.rs +++ b/src/commands/generate_charts.rs @@ -106,7 +106,8 @@ fn parse_i64(s: &str) -> i64 { /// 1. Monthly trend: line chart with created vs closed series. /// Also optionally adds dashed overlay lines for label type columns. -/// `type_overlays`: (series_name, columns_to_sum) — mirrors Python's df[matching].sum(axis=1). +/// +/// `type_overlays`: (series_name, columns_to_sum) — mirrors Python's df[matching].sum(axis=1). pub fn monthly_trend( rows: &[&HashMap], created_col: &str, @@ -137,13 +138,13 @@ pub fn monthly_trend( .series( Line::new() .name("Created") - .data(created.iter().map(|v| *v).collect::>()) + .data(created.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_CREATED))), ) .series( Line::new() .name("Closed") - .data(closed.iter().map(|v| *v).collect::>()) + .data(closed.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_CLOSED))), ); @@ -155,7 +156,7 @@ pub fn monthly_trend( chart = chart.series( Line::new() .name(series_name.as_str()) - .data(vals.iter().map(|v| *v).collect::>()) + .data(vals.to_vec()) .line_style(LineStyle::new().type_(LineStyleType::Dashed)) .item_style(ItemStyle::new().color(color(c))), ); @@ -193,19 +194,19 @@ pub fn discussion_trend(rows: &[&HashMap]) -> Chart { .series( Line::new() .name("Created") - .data(created.iter().map(|v| *v).collect::>()) + .data(created.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_CREATED))), ) .series( Line::new() .name("Closed") - .data(closed.iter().map(|v| *v).collect::>()) + .data(closed.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_CLOSED))), ) .series( Line::new() .name("Answered") - .data(answered.iter().map(|v| *v).collect::>()) + .data(answered.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_ANSWERED))), ) } @@ -289,7 +290,7 @@ pub fn label_counts_over_time(rows: &[&HashMap]) -> Chart { chart = chart.series( Bar::new() .name(label.as_str()) - .data(vals.iter().map(|v| *v).collect::>()) + .data(vals.to_vec()) .item_style(ItemStyle::new().color(color(c))), ); } @@ -298,7 +299,8 @@ pub fn label_counts_over_time(rows: &[&HashMap]) -> Chart { } /// 5. Open/closed by integration label: stacked horizontal bar chart. -/// Filters to source:/transform:/sink: labels only, sorted by total descending. +/// +/// Filters to source:/transform:/sink: labels only, sorted by total descending. pub fn open_closed_by_label(rows: &[&HashMap]) -> Option { let mut pairs: Vec<(String, i64, i64)> = rows.iter() .filter_map(|r| { @@ -336,14 +338,14 @@ pub fn open_closed_by_label(rows: &[&HashMap]) -> Option Bar::new() .name("Closed") .stack("total") - .data(closed_vals.iter().map(|v| *v).collect::>()) + .data(closed_vals.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_CLOSED))), ) .series( Bar::new() .name("Open") .stack("total") - .data(open_vals.iter().map(|v| *v).collect::>()) + .data(open_vals.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_OPEN))), )) } @@ -405,7 +407,7 @@ pub fn integration_trends( chart = chart.series( Line::new() .name(col.as_str()) - .data(vals.iter().map(|v| *v).collect::>()) + .data(vals.to_vec()) .item_style(ItemStyle::new().color(color(c))), ); } @@ -573,14 +575,14 @@ pub fn unique_contributors_monthly(rows: &[&HashMap]) -> Option< Bar::new() .name("New") .stack("contributors") - .data(new_vals.iter().map(|v| *v).collect::>()) + .data(new_vals.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_NEW_CONTRIBUTOR))), ) .series( Bar::new() .name("Returning") .stack("contributors") - .data(ret_vals.iter().map(|v| *v).collect::>()) + .data(ret_vals.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_RETURNING_CONTRIBUTOR))), )) } @@ -625,14 +627,14 @@ pub fn unique_contributors_yearly(rows: &[&HashMap]) -> Option>()) + .data(new_vals.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_NEW_CONTRIBUTOR))), ) .series( Bar::new() .name("Returning") .stack("contributors") - .data(ret_vals.iter().map(|v| *v).collect::>()) + .data(ret_vals.to_vec()) .item_style(ItemStyle::new().color(color(COLOR_RETURNING_CONTRIBUTOR))), )) } @@ -1074,7 +1076,7 @@ pub fn run(input_dir: &str, repo: &str, output_dir: &str, start: Option<&str>) - } else { vec![] }; // Top 5 label columns for overlay (those that are not the standard cols) - let standard_issue_cols = ["month", "created_issues", "closed_issues"]; + let _standard_issue_cols = ["month", "created_issues", "closed_issues"]; // Fixed type overlays matching Python's TYPE_OVERLAYS. // Collects ALL matching columns per type and sums them (mirrors df[matching].sum(axis=1)), // so repos that use both "type: bug" (label) and "Bug" (native type) get a continuous series. @@ -1298,13 +1300,11 @@ pub fn run(input_dir: &str, repo: &str, output_dir: &str, start: Option<&str>) - for entry in sorted { let path = entry.path(); let repo_file = path.join(".repo"); - if path.is_dir() && repo_file.exists() { - if let Ok(content) = fs::read_to_string(&repo_file) { - if let Some((o, n)) = content.trim().split_once('/') { + if path.is_dir() && repo_file.exists() + && let Ok(content) = fs::read_to_string(&repo_file) + && let Some((o, n)) = content.trim().split_once('/') { repos_found.push((o.to_string(), n.to_string())); } - } - } } } generate_index(output_dir, &repos_found)?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 40bfdaf..6cd2005 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,5 +9,6 @@ pub mod fetch_issues; pub mod fetch_labels; pub mod generate_summaries; pub mod purge; +pub mod push_metrics; pub mod remove_legacy_label; pub mod workflows; diff --git a/src/commands/push_metrics.rs b/src/commands/push_metrics.rs new file mode 100644 index 0000000..0e14455 --- /dev/null +++ b/src/commands/push_metrics.rs @@ -0,0 +1,385 @@ +use crate::commands::fetch_issues::parse_since; +use crate::config::Config; +use anyhow::{Context, Result}; +use reqwest::blocking::Client; +use rusqlite::Connection; +use serde_json::json; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +const DD_API_URL: &str = "https://api.datadoghq.com/api/v2/series"; +const BATCH_SIZE: usize = 500; + +pub fn run( + config: &Config, + dd_api_key: &str, + dd_site: Option<&str>, + since: Option<&str>, + prefix: Option<&str>, + dry_run: bool, +) -> Result<()> { + let metric_prefix = prefix.unwrap_or("cose.gh"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time after epoch") + .as_secs() as i64; + + // `--since` controls the velocity window (items closed/merged in this period). + // Open backlog is always all-time — no date filter. + let velocity_since = parse_since(since.unwrap_or("30d"))?; + + let client = Client::new(); + let api_url = match dd_site { + Some(site) => format!("https://api.{site}/api/v2/series"), + None => DD_API_URL.to_string(), + }; + + let db_path = format!("out/db/{}_{}.db", config.org, config.repo); + let conn = Connection::open(&db_path) + .with_context(|| format!("Failed to open database: {db_path}"))?; + + let repo_tag = format!("repo:{}/{}", config.org, config.repo); + + println!("Reading from {db_path}..."); + println!(" Open backlog: all time"); + println!(" Velocity window: closed_at >= {velocity_since}"); + + let mut all_series = Vec::new(); + + // ── Open backlog gauges (all open items, no date filter, age bucket tag) ── + all_series.extend(open_items_gauge(&conn, "issues", &repo_tag, metric_prefix, now)?); + all_series.extend(open_items_gauge(&conn, "pull_requests", &repo_tag, metric_prefix, now)?); + all_series.extend(open_discussions_gauge(&conn, &repo_tag, metric_prefix, now)?); + + // ── Velocity counts (items closed/merged since velocity_since) ── + all_series.extend(closed_items_count(&conn, "issues", &repo_tag, &velocity_since, metric_prefix, now)?); + all_series.extend(closed_items_count(&conn, "pull_requests", &repo_tag, &velocity_since, metric_prefix, now)?); + + if all_series.is_empty() { + println!("No metrics to push."); + return Ok(()); + } + + if dry_run { + print_dry_run(&all_series); + return Ok(()); + } + + println!("Pushing {} metric series to Datadog...", all_series.len()); + for (i, chunk) in all_series.chunks(BATCH_SIZE).enumerate() { + let payload = json!({ "series": chunk }); + let response = client + .post(&api_url) + .header("DD-API-KEY", dd_api_key) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .context("Failed to send metrics to Datadog")?; + + let status = response.status(); + let body = response.text().unwrap_or_default(); + if status.is_success() { + if let Ok(parsed) = serde_json::from_str::(&body) + && let Some(errors) = parsed.get("errors").and_then(|e| e.as_array()) + && !errors.is_empty() + { + eprintln!(" Batch {}: {} errors:", i + 1, errors.len()); + for err in errors { + eprintln!(" {err}"); + } + continue; + } + println!(" Batch {}: {} series", i + 1, chunk.len()); + } else { + eprintln!(" Batch {} rejected ({status}): {body}", i + 1); + } + } + + println!("Done."); + Ok(()) +} + +/// Gauge: current count of open items grouped by tag combination, with age bucket tag. +/// No date filter — captures the full open backlog. +fn open_items_gauge( + conn: &Connection, + table: &str, + repo_tag: &str, + prefix: &str, + now: i64, +) -> Result> { + let metric_name = if table == "pull_requests" { + format!("{prefix}.prs") + } else { + format!("{prefix}.issues") + }; + + let label_map = build_label_map_open(conn, table)?; + + let issue_type_col = if table == "issues" { ", issue_type" } else { "" }; + let extra_filter = if table == "pull_requests" { " AND is_draft = 0" } else { "" }; + let query = format!( + "SELECT id, created_at{issue_type_col} FROM {table} WHERE state = 'open'{extra_filter}" + ); + + let mut stmt = conn.prepare(&query)?; + let mut rows = stmt.query([])?; + + let mut counts: HashMap, i64)> = HashMap::new(); + + while let Some(row) = rows.next()? { + let id: i64 = row.get(0)?; + let created_at: String = row.get(1)?; + let issue_type: Option = if table == "issues" { row.get(2)? } else { None }; + + let mut tags = vec![ + repo_tag.to_string(), + "state:open".to_string(), + age_bucket(&created_at, now), + ]; + + if let Some(ref it) = issue_type { + tags.push(format!("issue_type:{}", it.to_lowercase())); + } + + if let Some(labels) = label_map.get(&id) { + for label in labels { + tags.push(label_to_tag(label)); + } + } + + tags.sort(); + let key = tags.join(","); + let entry = counts.entry(key).or_insert_with(|| (tags, 0)); + entry.1 += 1; + } + + Ok(counts + .into_values() + .map(|(tags, count)| { + json!({ + "metric": metric_name, + "type": 3, // gauge + "points": [{"timestamp": now, "value": count}], + "tags": tags, + }) + }) + .collect()) +} + +/// Count: items closed/merged since `velocity_since`, grouped by tag combination. +/// Emitted at current timestamp — gives merge/close velocity for the period. +fn closed_items_count( + conn: &Connection, + table: &str, + repo_tag: &str, + velocity_since: &str, + prefix: &str, + now: i64, +) -> Result> { + let metric_name = if table == "pull_requests" { + format!("{prefix}.prs.closed") + } else { + format!("{prefix}.issues.closed") + }; + + let label_map = build_label_map_closed_since(conn, table, velocity_since)?; + + let issue_type_col = if table == "issues" { ", issue_type" } else { "" }; + let extra_filter = if table == "pull_requests" { " AND is_draft = 0" } else { "" }; + let query = format!( + "SELECT id{issue_type_col} FROM {table} + WHERE state = 'closed' AND closed_at >= ?{extra_filter}" + ); + + let mut stmt = conn.prepare(&query)?; + let mut rows = stmt.query(rusqlite::params![velocity_since])?; + + let mut counts: HashMap, i64)> = HashMap::new(); + + while let Some(row) = rows.next()? { + let id: i64 = row.get(0)?; + let issue_type: Option = if table == "issues" { row.get(1)? } else { None }; + + let mut tags = vec![repo_tag.to_string(), "state:closed".to_string()]; + + if let Some(ref it) = issue_type { + tags.push(format!("issue_type:{}", it.to_lowercase())); + } + + if let Some(labels) = label_map.get(&id) { + for label in labels { + tags.push(label_to_tag(label)); + } + } + + tags.sort(); + let key = tags.join(","); + let entry = counts.entry(key).or_insert_with(|| (tags, 0)); + entry.1 += 1; + } + + Ok(counts + .into_values() + .map(|(tags, count)| { + json!({ + "metric": metric_name, + "type": 1, // count — number of close events in the velocity window + "points": [{"timestamp": now, "value": count}], + "tags": tags, + }) + }) + .collect()) +} + +fn open_discussions_gauge( + conn: &Connection, + repo_tag: &str, + prefix: &str, + now: i64, +) -> Result> { + let query = + "SELECT category, closed, is_answered, created_at FROM discussions WHERE closed = 0"; + + let mut stmt = conn.prepare(query)?; + let mut rows = stmt.query([])?; + + let mut counts: HashMap, i64)> = HashMap::new(); + + while let Some(row) = rows.next()? { + let category: String = row.get(0)?; + let closed: bool = row.get(1)?; + let is_answered: Option = row.get(2)?; + let created_at: String = row.get(3)?; + + let answered = is_answered.unwrap_or(false); + let state = if closed { "closed" } else if answered { "answered" } else { "open" }; + + let mut tags = vec![ + repo_tag.to_string(), + format!("category:{}", category.to_lowercase()), + format!("state:{state}"), + format!("answered:{answered}"), + age_bucket(&created_at, now), + ]; + tags.sort(); + + let key = tags.join(","); + let entry = counts.entry(key).or_insert_with(|| (tags, 0)); + entry.1 += 1; + } + + Ok(counts + .into_values() + .map(|(tags, count)| { + json!({ + "metric": format!("{prefix}.discussions"), + "type": 3, // gauge + "points": [{"timestamp": now, "value": count}], + "tags": tags, + }) + }) + .collect()) +} + +fn build_label_map_open(conn: &Connection, table: &str) -> Result>> { + let query = format!( + "SELECT il.issue_id, l.name + FROM issue_labels il + JOIN labels l ON l.id = il.label_id + JOIN {table} t ON t.id = il.issue_id + WHERE t.state = 'open'" + ); + let mut stmt = conn.prepare(&query)?; + let mut rows = stmt.query([])?; + let mut map: HashMap> = HashMap::new(); + while let Some(row) = rows.next()? { + let id: i64 = row.get(0)?; + let name: String = row.get(1)?; + map.entry(id).or_default().push(name); + } + Ok(map) +} + +fn build_label_map_closed_since( + conn: &Connection, + table: &str, + since: &str, +) -> Result>> { + let query = format!( + "SELECT il.issue_id, l.name + FROM issue_labels il + JOIN labels l ON l.id = il.label_id + JOIN {table} t ON t.id = il.issue_id + WHERE t.state = 'closed' AND t.closed_at >= ?" + ); + let mut stmt = conn.prepare(&query)?; + let mut rows = stmt.query(rusqlite::params![since])?; + let mut map: HashMap> = HashMap::new(); + while let Some(row) = rows.next()? { + let id: i64 = row.get(0)?; + let name: String = row.get(1)?; + map.entry(id).or_default().push(name); + } + Ok(map) +} + +fn age_bucket(created_at: &str, now: i64) -> String { + let age_days = chrono::DateTime::parse_from_rfc3339(created_at) + .map(|dt| (now - dt.timestamp()).max(0) / 86400) + .unwrap_or(0); + let bucket = match age_days { + 0..=6 => "0-7d", + 7..=29 => "7-30d", + 30..=89 => "30-90d", + _ => "90d+", + }; + format!("age:{bucket}") +} + +fn label_to_tag(label: &str) -> String { + // "domain: vrl" -> "domain:vrl" + // "good first issue" -> "label:good first issue" + if let Some((prefix, value)) = label.split_once(": ") { + format!("{prefix}:{value}") + } else { + format!("label:{label}") + } +} + +fn print_dry_run(all_series: &[serde_json::Value]) { + let total_items: i64 = all_series + .iter() + .filter_map(|s| s["points"][0]["value"].as_i64()) + .sum(); + + println!( + "[dry-run] Would push {} series (representing {} total items) to Datadog", + all_series.len(), + total_items + ); + + let mut by_metric: HashMap = HashMap::new(); + for s in all_series { + let name = s["metric"].as_str().unwrap_or("unknown"); + let count = s["points"][0]["value"].as_i64().unwrap_or(0); + let entry = by_metric.entry(name.to_string()).or_default(); + entry.0 += 1; + entry.1 += count; + } + let mut metrics: Vec<_> = by_metric.into_iter().collect(); + metrics.sort(); + for (name, (series, count)) in &metrics { + println!(" {name}: {series} series, {count} items"); + } + + println!("\n Sample series:"); + for s in all_series.iter().take(8) { + let metric = s["metric"].as_str().unwrap_or(""); + let count = s["points"][0]["value"].as_i64().unwrap_or(0); + if let Some(tags) = s["tags"].as_array() { + let tags_str: Vec<_> = tags.iter().filter_map(|t| t.as_str()).collect(); + println!(" {metric} = {count}: {}", tags_str.join(", ")); + } + } +} diff --git a/src/main.rs b/src/main.rs index a8b0baa..125898b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use github_tools::{ commands::{ build_db, close_old_prs, compact, delete_stale_branches, fetch_automated_review_stats, fetch_discussions, fetch_issues, fetch_labels, generate_charts, generate_summaries, purge, - remove_legacy_label, workflows, + push_metrics, remove_legacy_label, workflows, }, config::{Config, Repo}, }; @@ -149,6 +149,21 @@ enum Command { #[arg(long)] limit: Option, }, + /// Push per-item metrics to Datadog (each issue/PR/discussion = data point with all labels as tags) + PushMetrics { + #[arg(long, help = "Repository, e.g. vectordotdev/vector")] + repo: String, + #[arg(long, help = "Datadog API key (or set DD_API_KEY in .env file)")] + dd_api_key: Option, + #[arg(long, help = "Datadog site (or set DD_SITE in .env file, e.g. datadoghq.eu)")] + dd_site: Option, + #[arg(long, help = "Only push items created since this date (default: 2026-01-01)")] + since: Option, + #[arg(long, help = "Metric name prefix (default: cose.gh)")] + prefix: Option, + #[arg(long, help = "Print metrics summary without sending to Datadog")] + dry_run: bool, + }, /// Count automated review comments by reaction (liked / disliked / no reaction). /// Omit --bot-login to list all review comment authors and discover the right login. AutomatedReviewStats { @@ -224,6 +239,14 @@ fn main() -> Result<()> { let config = Config::load(&Repo::parse(&repo)?, env_file.as_deref())?; build_db::run(&input, &config) } + Command::PushMetrics { repo, dd_api_key, dd_site, since, prefix, dry_run } => { + let config = Config::for_repo(&Repo::parse(&repo)?); + let api_key = dd_api_key + .or_else(|| std::env::var("DD_API_KEY").ok()) + .ok_or_else(|| anyhow::anyhow!("DD_API_KEY not set (use --dd-api-key or set DD_API_KEY env var)"))?; + let site = dd_site.or_else(|| std::env::var("DD_SITE").ok()); + push_metrics::run(&config, &api_key, site.as_deref(), since.as_deref(), prefix.as_deref(), dry_run) + } Command::AutomatedReviewStats { repo, env_file, bot_login, since } => { let config = Config::load(&Repo::parse(&repo)?, env_file.as_deref())?; fetch_automated_review_stats::run(&config, bot_login.as_deref(), since.as_deref()) From f0bae784fdf6cc5c64ba39deafabce83c2e8a007 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 23 Jun 2026 15:49:44 -0400 Subject: [PATCH 2/2] Fix four code review findings in push-metrics - Batch failures now return an error instead of silently succeeding; both non-2xx responses and 2xx responses with error bodies increment a failure counter and bail at the end. - --dry-run no longer requires DD_API_KEY; the key is resolved only when actually pushing. - Add --env-file so DD_API_KEY / DD_SITE can be loaded from a .env file consistently with other commands. - Fix --since help text to reflect actual behavior: velocity window for closed items (default 30d), not a creation-date filter. --- src/commands/push_metrics.rs | 10 ++++++++-- src/main.rs | 30 ++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/commands/push_metrics.rs b/src/commands/push_metrics.rs index 0e14455..e544930 100644 --- a/src/commands/push_metrics.rs +++ b/src/commands/push_metrics.rs @@ -66,6 +66,7 @@ pub fn run( } println!("Pushing {} metric series to Datadog...", all_series.len()); + let mut failed_batches = 0usize; for (i, chunk) in all_series.chunks(BATCH_SIZE).enumerate() { let payload = json!({ "series": chunk }); let response = client @@ -87,14 +88,19 @@ pub fn run( for err in errors { eprintln!(" {err}"); } - continue; + failed_batches += 1; + } else { + println!(" Batch {}: {} series", i + 1, chunk.len()); } - println!(" Batch {}: {} series", i + 1, chunk.len()); } else { eprintln!(" Batch {} rejected ({status}): {body}", i + 1); + failed_batches += 1; } } + if failed_batches > 0 { + anyhow::bail!("{failed_batches} batch(es) failed; see errors above"); + } println!("Done."); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 125898b..3532b6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,15 +149,17 @@ enum Command { #[arg(long)] limit: Option, }, - /// Push per-item metrics to Datadog (each issue/PR/discussion = data point with all labels as tags) + /// Push aggregate health metrics to Datadog (open backlog gauges + velocity counts) PushMetrics { #[arg(long, help = "Repository, e.g. vectordotdev/vector")] repo: String, - #[arg(long, help = "Datadog API key (or set DD_API_KEY in .env file)")] + #[arg(long, help = "Path to .env file (may contain DD_API_KEY / DD_SITE)")] + env_file: Option, + #[arg(long, help = "Datadog API key (overrides DD_API_KEY from env)")] dd_api_key: Option, - #[arg(long, help = "Datadog site (or set DD_SITE in .env file, e.g. datadoghq.eu)")] + #[arg(long, help = "Datadog site, e.g. datadoghq.eu (overrides DD_SITE from env)")] dd_site: Option, - #[arg(long, help = "Only push items created since this date (default: 2026-01-01)")] + #[arg(long, help = "Velocity window: how far back to count closed items (default: 30d). Accepts ISO date, YYYY-MM, or relative (30d, 3m, 1y)")] since: Option, #[arg(long, help = "Metric name prefix (default: cose.gh)")] prefix: Option, @@ -239,11 +241,23 @@ fn main() -> Result<()> { let config = Config::load(&Repo::parse(&repo)?, env_file.as_deref())?; build_db::run(&input, &config) } - Command::PushMetrics { repo, dd_api_key, dd_site, since, prefix, dry_run } => { + Command::PushMetrics { repo, env_file, dd_api_key, dd_site, since, prefix, dry_run } => { + // Load env file so DD_API_KEY / DD_SITE can come from it + if let Some(ref path) = env_file { + dotenvy::from_filename_override(path) + .with_context(|| format!("Failed to load env file: {path}"))?; + } else { + dotenvy::dotenv().ok(); + } let config = Config::for_repo(&Repo::parse(&repo)?); - let api_key = dd_api_key - .or_else(|| std::env::var("DD_API_KEY").ok()) - .ok_or_else(|| anyhow::anyhow!("DD_API_KEY not set (use --dd-api-key or set DD_API_KEY env var)"))?; + // Only require API key when actually pushing (dry-run never sends) + let api_key = if dry_run { + String::new() + } else { + dd_api_key + .or_else(|| std::env::var("DD_API_KEY").ok()) + .ok_or_else(|| anyhow::anyhow!("DD_API_KEY not set (use --dd-api-key or add DD_API_KEY to .env file)"))? + }; let site = dd_site.or_else(|| std::env::var("DD_SITE").ok()); push_metrics::run(&config, &api_key, site.as_deref(), since.as_deref(), prefix.as_deref(), dry_run) }