diff --git a/crates/trapfall-core/src/store.rs b/crates/trapfall-core/src/store.rs index 5ee9f85..e3d1f61 100644 --- a/crates/trapfall-core/src/store.rs +++ b/crates/trapfall-core/src/store.rs @@ -188,6 +188,10 @@ impl Store { self.db.count_release_health(project_id, release, env).await } + pub async fn list_environments(&self, project_id: &str) -> Result> { + self.db.list_environments(project_id).await + } + // ── Alert Rules ──────────────────────────────────────────────────── pub async fn create_alert_rule( diff --git a/crates/trapfall-db/src/lib.rs b/crates/trapfall-db/src/lib.rs index 1c371da..e7d3840 100644 --- a/crates/trapfall-db/src/lib.rs +++ b/crates/trapfall-db/src/lib.rs @@ -258,6 +258,11 @@ pub trait Database: Send + Sync { ) -> Result>; async fn count_release_health(&self, project_id: &str, release: Option<&str>, env: Option<&str>) -> Result; + /// List distinct environment values observed for a project, drawn from + /// both `release_health` and `transactions` tables. NULL environments + /// are excluded. Ordered alphabetically. + async fn list_environments(&self, project_id: &str) -> Result>; + // ── Alert Rules ──────────────────────────────────────────────────── async fn create_alert_rule( diff --git a/crates/trapfall-db/src/postgres.rs b/crates/trapfall-db/src/postgres.rs index 38b8707..730912a 100644 --- a/crates/trapfall-db/src/postgres.rs +++ b/crates/trapfall-db/src/postgres.rs @@ -564,6 +564,23 @@ impl Database for PostgresBackend { Ok(count) } + async fn list_environments(&self, project_id: &str) -> Result> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT DISTINCT environment FROM ( + SELECT environment FROM release_health + WHERE project_id = $1 AND environment IS NOT NULL + UNION + SELECT environment FROM transactions + WHERE project_id = $2 AND environment IS NOT NULL + ) AS envs ORDER BY environment", + ) + .bind(project_id) + .bind(project_id) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(e,)| e).collect()) + } + // ── Alert Rules ──────────────────────────────────────────────────── async fn create_alert_rule( diff --git a/crates/trapfall-db/src/sqlite.rs b/crates/trapfall-db/src/sqlite.rs index 89debd1..3aac435 100644 --- a/crates/trapfall-db/src/sqlite.rs +++ b/crates/trapfall-db/src/sqlite.rs @@ -554,6 +554,23 @@ impl Database for SqliteBackend { Ok(count) } + async fn list_environments(&self, project_id: &str) -> Result> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT DISTINCT environment FROM ( + SELECT environment FROM release_health + WHERE project_id = ? AND environment IS NOT NULL + UNION + SELECT environment FROM transactions + WHERE project_id = ? AND environment IS NOT NULL + ) AS envs ORDER BY environment", + ) + .bind(project_id) + .bind(project_id) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(e,)| e).collect()) + } + // ── Alert Rules ──────────────────────────────────────────────────── async fn create_alert_rule( @@ -1476,6 +1493,55 @@ mod tests { assert_eq!(none, 0); } + #[tokio::test] + async fn test_list_environments_unions_release_health_and_transactions() { + let db = open_backend().await; + db.create_project("app", "App").await.unwrap(); + let project = db.get_project_by_slug("app").await.unwrap().unwrap(); + + // release_health contributes `production` + `staging` + for env in ["production", "staging"] { + let aggregates = trapfall_proto::SessionAggregates { + aggregates: vec![trapfall_proto::SessionAggregateItem { + started: "2026-01-01T00:00:00Z".into(), + distinct_id: None, + exited: 1, + errored: 0, + abnormal: 0, + crashed: 0, + }], + attributes: trapfall_proto::SessionAttributes { + release: "1.0.0".into(), + environment: Some(env.into()), + ip_address: None, + user_agent: None, + }, + }; + db.insert_release_health(&project.id, &aggregates).await.unwrap(); + } + + // transactions contribute `development` (verifies UNION across tables) + let tx = trapfall_proto::Transaction { + event_id: "tx-env-1".into(), + level: trapfall_proto::Level::Info, + transaction: "GET /".into(), + start_timestamp: 1703894474.0, + timestamp: 1703894474.5, + release: Some("1.0.0".into()), + environment: Some("development".into()), + spans: vec![], + contexts: None, + request: None, + tags: None, + extra: None, + }; + db.insert_transaction(&project.id, &tx).await.unwrap(); + + let envs = db.list_environments(&project.id).await.unwrap(); + // Distinct + sorted alphabetically, NULLs excluded + assert_eq!(envs, vec!["development".to_string(), "production".to_string(), "staging".to_string()]); + } + #[tokio::test] async fn test_crash_rate_calculation() { let db = open_backend().await; diff --git a/crates/trapfalld/src/openapi.yaml b/crates/trapfalld/src/openapi.yaml index 11d788c..2329f32 100644 --- a/crates/trapfalld/src/openapi.yaml +++ b/crates/trapfalld/src/openapi.yaml @@ -239,6 +239,37 @@ paths: "404": description: Not found + # ── Environments ─────────────────────────────────────────────── + + /api/0/projects/{slug}/environments: + get: + tags: [Projects] + summary: List distinct environments for a project + description: > + Returns distinct `environment` values observed across the project's + release-health sessions and transactions, ordered alphabetically. + Powers the dynamic environment filter in the dashboard. NULL + environments are excluded. + operationId: listEnvironments + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + "200": + description: Distinct environment values + content: + application/json: + schema: + type: array + items: + type: string + example: ["development", "production", "staging"] + "404": + description: Project not found + # ── Issues ────────────────────────────────────────────────────── /api/0/projects/{slug}/issues: diff --git a/crates/trapfalld/src/server.rs b/crates/trapfalld/src/server.rs index 569c856..f47806f 100644 --- a/crates/trapfalld/src/server.rs +++ b/crates/trapfalld/src/server.rs @@ -156,6 +156,7 @@ pub fn router(state: AppState) -> Router { .route("/api/0/projects/{slug}/search", get(search_issues)) .route("/api/0/projects/{slug}/release-health/crash-rate", get(get_crash_rate)) .route("/api/0/projects/{slug}/release-health", get(list_release_health)) + .route("/api/0/projects/{slug}/environments", get(list_environments)) .route("/api/0/projects/{slug}/transactions", get(list_transactions)) .route("/api/0/projects/{slug}/transactions/slowest", get(get_slowest_transactions)) .route("/api/0/projects/{slug}/transactions/{txn_id}", get(get_transaction)) @@ -895,6 +896,27 @@ async fn list_release_health( Ok(Json(ListResponse { data, total, page, per_page: limit as u32 })) } +/// List distinct environments observed for a project (release_health + +/// transactions). Powers the dynamic environment filter in the dashboard. +async fn list_environments( + State(state): State, + Path(slug): Path, +) -> Result>, StatusCode> { + let store = state.store.clone(); + let project = store + .get_project_by_slug(&slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let envs = store.list_environments(&project.id).await.map_err(|e| { + tracing::warn!(error = %e, project_slug = %slug, "list_environments failed"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(envs)) +} + async fn get_crash_rate( State(state): State, Path(slug): Path, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 985400e..ef32499 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -310,6 +310,10 @@ class ApiClient { if (env) path += `&env=${encodeURIComponent(env)}`; return this.get(path); } + + async listEnvironments(projectSlug: string): Promise { + return this.get(`/projects/${projectSlug}/environments`); + } } diff --git a/web/src/routes/(dashboard)/release-health/+page.svelte b/web/src/routes/(dashboard)/release-health/+page.svelte index 08f030c..a7b53de 100644 --- a/web/src/routes/(dashboard)/release-health/+page.svelte +++ b/web/src/routes/(dashboard)/release-health/+page.svelte @@ -29,13 +29,25 @@ let error = $state(''); let filterRelease: string = $state(''); let filterEnv: string = $state(''); + let environments: string[] = $state([]); let debounceTimer: ReturnType | undefined = undefined; - const envOptions = [ - { value: '', label: 'All' }, - { value: 'production', label: 'Production' }, - { value: 'development', label: 'Development' } - ]; + // Dynamic environment filter options. Built from distinct environments + // observed in the DB (release-health + transactions), plus "All". + // The active filter is always present even if not yet loaded from the API + // (e.g. restored from a URL param before the fetch resolves). + let envOptions = $derived.by(() => { + const opts = [{ value: '', label: 'All' }]; + const seen = new Set(); + for (const env of environments) { + opts.push({ value: env, label: env }); + seen.add(env); + } + if (filterEnv && !seen.has(filterEnv)) { + opts.push({ value: filterEnv, label: filterEnv }); + } + return opts; + }); let aggregateExited: number = $derived(sessions.reduce((s, r) => s + r.exited, 0)); let aggregateErrored: number = $derived(sessions.reduce((s, r) => s + r.errored, 0)); @@ -56,6 +68,15 @@ } } + async function loadEnvironments() { + if (!selectedProject) return; + try { + environments = await api.listEnvironments(selectedProject); + } catch { + environments = []; + } + } + async function loadSessions() { if (!selectedProject) return; loading = true; @@ -90,6 +111,7 @@ } function loadData() { + loadEnvironments(); loadCrashRate(); loadSessions(); }