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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/trapfall-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
self.db.list_environments(project_id).await
}

// ── Alert Rules ────────────────────────────────────────────────────

pub async fn create_alert_rule(
Expand Down
5 changes: 5 additions & 0 deletions crates/trapfall-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ pub trait Database: Send + Sync {
) -> Result<Vec<crate::common::ReleaseHealthRow>>;
async fn count_release_health(&self, project_id: &str, release: Option<&str>, env: Option<&str>) -> Result<i64>;

/// 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<Vec<String>>;

// ── Alert Rules ────────────────────────────────────────────────────

async fn create_alert_rule(
Expand Down
17 changes: 17 additions & 0 deletions crates/trapfall-db/src/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,23 @@ impl Database for PostgresBackend {
Ok(count)
}

async fn list_environments(&self, project_id: &str) -> Result<Vec<String>> {
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(
Expand Down
66 changes: 66 additions & 0 deletions crates/trapfall-db/src/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,23 @@ impl Database for SqliteBackend {
Ok(count)
}

async fn list_environments(&self, project_id: &str) -> Result<Vec<String>> {
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(
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions crates/trapfalld/src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions crates/trapfalld/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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<AppState>,
Path(slug): Path<String>,
) -> Result<Json<Vec<String>>, 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<AppState>,
Path(slug): Path<String>,
Expand Down
4 changes: 4 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,10 @@ class ApiClient {
if (env) path += `&env=${encodeURIComponent(env)}`;
return this.get<CrashRateResponse>(path);
}

async listEnvironments(projectSlug: string): Promise<string[]> {
return this.get<string[]>(`/projects/${projectSlug}/environments`);
}
}


Expand Down
32 changes: 27 additions & 5 deletions web/src/routes/(dashboard)/release-health/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,25 @@
let error = $state('');
let filterRelease: string = $state('');
let filterEnv: string = $state('');
let environments: string[] = $state([]);
let debounceTimer: ReturnType<typeof setTimeout> | 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<string>();
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));
Expand All @@ -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;
Expand Down Expand Up @@ -90,6 +111,7 @@
}

function loadData() {
loadEnvironments();
loadCrashRate();
loadSessions();
}
Expand Down
Loading