From 6b2dcd41a60af0c27d6b1c1f75ae1fa298b730c2 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 15 Jul 2026 21:42:18 +0200 Subject: [PATCH] feat(metrics): per-app observability dashboard over execution_logs (A2) Aggregates the existing execution_logs table (no hot-path instrumentation): ExecutionLogRepository::summarize_for_app returns counts, error rate, latency avg/p50/p95 (percentile_cont), by-status/by-source breakdowns, and an hourly series over a trailing window (clamped 1h..90d). New metrics_api router at GET /api/v1/admin/apps/{id}/metrics?window= (AppLogRead), and a dashboard Metrics tab (summary cards + an inline-SVG per-hour bar chart, no JS dep). Pinned by a manager-core test asserting the exact rollup (percentiles included) and the empty-app zero case. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- crates/manager-core/src/lib.rs | 2 + crates/manager-core/src/metrics_api.rs | 118 ++++++++ crates/manager-core/src/repo.rs | 165 +++++++++++ crates/manager-core/tests/metrics_summary.rs | 150 ++++++++++ crates/picloud/src/lib.rs | 44 +-- dashboard/src/lib/AppTabBar.svelte | 1 + dashboard/src/lib/api.ts | 35 +++ .../src/routes/apps/[slug]/+layout.svelte | 2 + .../routes/apps/[slug]/metrics/+page.svelte | 275 ++++++++++++++++++ 10 files changed, 774 insertions(+), 20 deletions(-) create mode 100644 crates/manager-core/src/metrics_api.rs create mode 100644 crates/manager-core/tests/metrics_summary.rs create mode 100644 dashboard/src/routes/apps/[slug]/metrics/+page.svelte diff --git a/CLAUDE.md b/CLAUDE.md index 8750d17..2197a54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,6 @@ Environment variables consumed by the `picloud` binary: ## Out of MVP -This section captured the original MVP cut. Most of it has since **shipped in v1.1.x**: queue triggers, cron triggers, inbound email (`email:receive`, HMAC-webhook model), KV / docs / email / users / HTTP SDKs, function-to-function `invoke()`, and secrets are all live (blueprint §12 Phase 4 table). The **Workflows** track (DAG + nested workflows) has since **shipped** (migrations `0071`/`0072`). A **§9.4 service-interceptor** KV allow/deny slice has since shipped (migration `0073`). **Still deferred:** the rest of §9.4 (data transform, non-kv services, after-hooks, chaining), a metrics/observability dashboard, a raw SMTP-listener ingress, and **multi-node cluster mode**. Don't pre-build for them — but don't make decisions that close the door on them either. +This section captured the original MVP cut. Most of it has since **shipped in v1.1.x**: queue triggers, cron triggers, inbound email (`email:receive`, HMAC-webhook model), KV / docs / email / users / HTTP SDKs, function-to-function `invoke()`, and secrets are all live (blueprint §12 Phase 4 table). The **Workflows** track (DAG + nested workflows) has since **shipped** (migrations `0071`/`0072`). A **§9.4 service-interceptor** KV allow/deny slice has since shipped (migration `0073`). A read-only **metrics/observability dashboard** has since shipped (A2): `GET /api/v1/admin/apps/{id}/metrics?window=` aggregates the existing `execution_logs` table (counts, error rate, latency avg/p50/p95, an hourly series) via `ExecutionLogRepository::summarize_for_app` + `metrics_api`, surfaced as the dashboard's per-app **Metrics** tab — no hot-path instrumentation. **Still deferred:** the rest of §9.4 (data transform, non-kv services, after-hooks, chaining), a raw SMTP-listener ingress, and **multi-node cluster mode**. Don't pre-build for them — but don't make decisions that close the door on them either. **Pulled forward to Phase 3 (pre-v1.1):** admin auth, multi-app scoping. The general cross-app **export/import** sharing model stays at v1.3+; note that v1.2 §11.6 shipped a narrower form — **group-owned shared collections** (KV/docs/files/topics/queues) let apps in one subtree share data through the owning group, with the ancestor-chain walk as the isolation boundary. See blueprint §11.5 + design-doc §11.6. diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 0db547c..49651fe 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -78,6 +78,7 @@ pub mod kv_service; pub mod log_sink; pub mod login_rate_limit; pub mod materialize; +pub mod metrics_api; pub mod migrations; pub mod module_source; pub mod outbox_event_emitter; @@ -231,6 +232,7 @@ pub use kv_api::{kv_admin_router, KvAdminState}; pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo}; pub use kv_service::KvServiceImpl; pub use log_sink::PostgresExecutionLogSink; +pub use metrics_api::{metrics_router, MetricsState}; pub use module_source::PostgresModuleSource; pub use outbox_event_emitter::OutboxEventEmitter; pub use outbox_repo::{ diff --git a/crates/manager-core/src/metrics_api.rs b/crates/manager-core/src/metrics_api.rs new file mode 100644 index 0000000..6660594 --- /dev/null +++ b/crates/manager-core/src/metrics_api.rs @@ -0,0 +1,118 @@ +//! `/api/v1/admin/apps/{id}/metrics` — read-only observability rollup (A2). +//! +//! Aggregates the existing `execution_logs` table (counts, error rate, latency +//! percentiles, an hourly series) for one app over a trailing window. No +//! hot-path instrumentation: the data plane already persists `duration_ms` / +//! `status` / `source` / `created_at` per run, so this is a pure read model +//! behind the dashboard's Metrics tab. +//! +//! Capability: `AppLogRead` (the same tier as the per-script log view), +//! resolved against the app loaded from the path. + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::get; +use axum::{Extension, Router}; +use picloud_shared::{AppId, Principal}; +use serde::Deserialize; +use serde_json::json; + +use crate::app_repo::AppRepository; +use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; +use crate::repo::{ExecutionLogRepository, MetricsSummary}; + +#[derive(Clone)] +pub struct MetricsState { + pub logs: Arc, + pub apps: Arc, + pub authz: Arc, +} + +pub fn metrics_router(state: MetricsState) -> Router { + Router::new() + .route("/apps/{app_id}/metrics", get(summary)) + .with_state(state) +} + +/// Default rollup window when `?window=` is absent (last 24 hours). +const DEFAULT_WINDOW_HOURS: i32 = 24; + +#[derive(Debug, Deserialize)] +pub struct MetricsQuery { + /// Trailing window in hours (clamped to `[1, 2160]` by the repo). + #[serde(default)] + pub window: Option, +} + +async fn summary( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Query(q): Query, +) -> Result, MetricsApiError> { + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; + require(s.authz.as_ref(), &principal, Capability::AppLogRead(app_id)).await?; + let window = q.window.unwrap_or(DEFAULT_WINDOW_HOURS); + let summary = s + .logs + .summarize_for_app(app_id, window) + .await + .map_err(|e| MetricsApiError::Backend(e.to_string()))?; + Ok(Json(summary)) +} + +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) + .await + .map_err(|e| MetricsApiError::Backend(e.to_string()))? + .map(|l| l.app.id) + .ok_or(MetricsApiError::AppNotFound) +} + +#[derive(Debug, thiserror::Error)] +pub enum MetricsApiError { + #[error("app not found")] + AppNotFound, + #[error("forbidden")] + Forbidden, + #[error("authorization repo error: {0}")] + AuthzRepo(String), + #[error("metrics backend: {0}")] + Backend(String), +} + +impl From for MetricsApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl IntoResponse for MetricsApiError { + fn into_response(self) -> Response { + use axum::http::StatusCode; + let (status, body) = match &self { + Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })), + Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), + Self::AuthzRepo(e) => { + tracing::error!(error = %e, "metrics admin authz error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + Self::Backend(e) => { + tracing::error!(error = %e, "metrics admin backend error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + }; + (status, Json(body)).into_response() + } +} diff --git a/crates/manager-core/src/repo.rs b/crates/manager-core/src/repo.rs index c247412..613074d 100644 --- a/crates/manager-core/src/repo.rs +++ b/crates/manager-core/src/repo.rs @@ -731,6 +731,40 @@ impl ExecutionLogCursor { } } +/// One `(key, count)` row of a metrics breakdown (by status or by source). +#[derive(Debug, Clone, serde::Serialize)] +pub struct MetricsCount { + pub key: String, + pub count: i64, +} + +/// One hour-bucket of the metrics time series. +#[derive(Debug, Clone, serde::Serialize)] +pub struct MetricsBucket { + pub ts: chrono::DateTime, + pub total: i64, + pub errors: i64, +} + +/// Aggregate execution metrics for one app over a trailing window — the read +/// model behind the observability dashboard. Sourced entirely from +/// `execution_logs` (no hot-path instrumentation): counts + error rate + +/// latency percentiles + an hourly series. +#[derive(Debug, Clone, serde::Serialize)] +pub struct MetricsSummary { + pub window_hours: i32, + pub total: i64, + pub errors: i64, + /// Errors / total in `[0, 1]` (0 when there are no executions). + pub error_rate: f64, + pub avg_duration_ms: f64, + pub p50_duration_ms: f64, + pub p95_duration_ms: f64, + pub by_status: Vec, + pub by_source: Vec, + pub series: Vec, +} + #[async_trait] pub trait ExecutionLogRepository: Send + Sync { /// F-P-005: keyset cursor on `(created_at, id)` — `cursor` is the @@ -743,6 +777,14 @@ pub trait ExecutionLogRepository: Send + Sync { cursor: Option, source: Option, ) -> Result, ScriptRepositoryError>; + + /// A2: aggregate metrics for `app_id` over the trailing `window_hours`. + /// Read-only rollup over `execution_logs`. + async fn summarize_for_app( + &self, + app_id: AppId, + window_hours: i32, + ) -> Result; } pub struct PostgresExecutionLogRepository { @@ -813,6 +855,129 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository { Ok(rows.into_iter().map(Into::into).collect()) } + + async fn summarize_for_app( + &self, + app_id: AppId, + window_hours: i32, + ) -> Result { + // Clamp the window to something sane (1 hour … 90 days) so a crafted + // `?window=` can't ask for an unbounded scan. + let window_hours = window_hours.clamp(1, 24 * 90); + let app = app_id.into_inner(); + + // Headline aggregate: totals, error count, latency mean + percentiles. + // `<> 'success'` counts every non-success terminal state as an error. + let agg = sqlx::query_as::<_, AggRow>( + "SELECT count(*)::int8 AS total, \ + count(*) FILTER (WHERE status <> 'success')::int8 AS errors, \ + COALESCE(avg(duration_ms), 0)::float8 AS avg_ms, \ + COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p50, \ + COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p95 \ + FROM execution_logs \ + WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour')", + ) + .bind(app) + .bind(window_hours) + .fetch_one(&self.pool) + .await?; + + let by_status = sqlx::query_as::<_, CountRow>( + "SELECT status AS key, count(*)::int8 AS count \ + FROM execution_logs \ + WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour') \ + GROUP BY status ORDER BY count DESC", + ) + .bind(app) + .bind(window_hours) + .fetch_all(&self.pool) + .await?; + + let by_source = sqlx::query_as::<_, CountRow>( + "SELECT source AS key, count(*)::int8 AS count \ + FROM execution_logs \ + WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour') \ + GROUP BY source ORDER BY count DESC", + ) + .bind(app) + .bind(window_hours) + .fetch_all(&self.pool) + .await?; + + let series = sqlx::query_as::<_, BucketRow>( + "SELECT date_trunc('hour', created_at) AS ts, count(*)::int8 AS total, \ + count(*) FILTER (WHERE status <> 'success')::int8 AS errors \ + FROM execution_logs \ + WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour') \ + GROUP BY ts ORDER BY ts", + ) + .bind(app) + .bind(window_hours) + .fetch_all(&self.pool) + .await?; + + // Counts are execution tallies over a bounded window — always far below + // f64's 2^53 exact-integer range, so the cast is lossless in practice. + #[allow(clippy::cast_precision_loss)] + let error_rate = if agg.total > 0 { + agg.errors as f64 / agg.total as f64 + } else { + 0.0 + }; + Ok(MetricsSummary { + window_hours, + total: agg.total, + errors: agg.errors, + error_rate, + avg_duration_ms: agg.avg_ms, + p50_duration_ms: agg.p50, + p95_duration_ms: agg.p95, + by_status: by_status + .into_iter() + .map(|r| MetricsCount { + key: r.key, + count: r.count, + }) + .collect(), + by_source: by_source + .into_iter() + .map(|r| MetricsCount { + key: r.key, + count: r.count, + }) + .collect(), + series: series + .into_iter() + .map(|r| MetricsBucket { + ts: r.ts, + total: r.total, + errors: r.errors, + }) + .collect(), + }) + } +} + +#[derive(sqlx::FromRow)] +struct AggRow { + total: i64, + errors: i64, + avg_ms: f64, + p50: f64, + p95: f64, +} + +#[derive(sqlx::FromRow)] +struct CountRow { + key: String, + count: i64, +} + +#[derive(sqlx::FromRow)] +struct BucketRow { + ts: chrono::DateTime, + total: i64, + errors: i64, } #[derive(sqlx::FromRow)] diff --git a/crates/manager-core/tests/metrics_summary.rs b/crates/manager-core/tests/metrics_summary.rs new file mode 100644 index 0000000..f09bdb5 --- /dev/null +++ b/crates/manager-core/tests/metrics_summary.rs @@ -0,0 +1,150 @@ +//! A2: `ExecutionLogRepository::summarize_for_app` — the read model behind the +//! observability dashboard. Seeds a known set of `execution_logs` rows for one +//! app and asserts the rollup: totals, error rate, latency mean + percentiles, +//! the by-status / by-source breakdowns, and the hourly series. Scoped to a +//! unique `app_id`, so it is unaffected by any other rows in a shared DB. +//! Skips cleanly when `DATABASE_URL` is unset. + +use picloud_manager_core::repo::{ExecutionLogRepository, PostgresExecutionLogRepository}; +use picloud_shared::AppId; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use uuid::Uuid; + +async fn pool_or_skip() -> Option { + let Ok(url) = std::env::var("DATABASE_URL") else { + picloud_test_support::abort_if_db_required("metrics_summary"); + eprintln!("metrics_summary: DATABASE_URL unset — skipping"); + return None; + }; + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .expect("connect to DATABASE_URL"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("apply migrations"); + Some(pool) +} + +async fn make_app(pool: &PgPool) -> Uuid { + // `apps.group_id` is NOT NULL, so anchor the app under a fresh root group. + let gslug = format!("metrics-g-{}", Uuid::new_v4()); + let group: (Uuid,) = + sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id") + .bind(&gslug) + .fetch_one(pool) + .await + .expect("group insert"); + let slug = format!("metrics-{}", Uuid::new_v4()); + let row: (Uuid,) = + sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") + .bind(&slug) + .bind(group.0) + .fetch_one(pool) + .await + .expect("app insert"); + row.0 +} + +async fn seed(pool: &PgPool, app: Uuid, duration_ms: i32, status: &str, source: &str) { + sqlx::query( + "INSERT INTO execution_logs (app_id, request_id, duration_ms, status, source) \ + VALUES ($1, gen_random_uuid(), $2, $3, $4)", + ) + .bind(app) + .bind(duration_ms) + .bind(status) + .bind(source) + .execute(pool) + .await + .expect("seed execution_logs row"); +} + +fn count_of(counts: &[picloud_manager_core::repo::MetricsCount], key: &str) -> i64 { + counts.iter().find(|c| c.key == key).map_or(0, |c| c.count) +} + +#[tokio::test] +async fn summarize_rolls_up_counts_error_rate_and_percentiles() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let app = make_app(&pool).await; + + // 3 successes (http), 1 error (kv), 1 timeout (cron). Durations chosen so + // the percentiles are exact: sorted [10, 20, 30, 50, 100]. + seed(&pool, app, 10, "success", "http").await; + seed(&pool, app, 20, "success", "http").await; + seed(&pool, app, 30, "success", "http").await; + seed(&pool, app, 100, "error", "kv").await; + seed(&pool, app, 50, "timeout", "cron").await; + + let repo = PostgresExecutionLogRepository::new(pool.clone()); + let s = repo + .summarize_for_app(AppId::from(app), 24) + .await + .expect("summarize"); + + assert_eq!(s.total, 5, "total executions"); + // Every non-success terminal state counts as an error: error + timeout = 2. + assert_eq!(s.errors, 2, "error count"); + assert!( + (s.error_rate - 0.4).abs() < 1e-9, + "error rate: {}", + s.error_rate + ); + // mean of [10,20,30,100,50] = 42. + assert!( + (s.avg_duration_ms - 42.0).abs() < 1e-6, + "avg: {}", + s.avg_duration_ms + ); + // percentile_cont(0.5) of the 5 sorted values = 30. + assert!( + (s.p50_duration_ms - 30.0).abs() < 1e-6, + "p50: {}", + s.p50_duration_ms + ); + // percentile_cont(0.95): 0.95*(n-1)=3.8 → interpolate 50..100 → 90. + assert!( + (s.p95_duration_ms - 90.0).abs() < 1e-6, + "p95: {}", + s.p95_duration_ms + ); + + assert_eq!(count_of(&s.by_status, "success"), 3); + assert_eq!(count_of(&s.by_status, "error"), 1); + assert_eq!(count_of(&s.by_status, "timeout"), 1); + + assert_eq!(count_of(&s.by_source, "http"), 3); + assert_eq!(count_of(&s.by_source, "kv"), 1); + assert_eq!(count_of(&s.by_source, "cron"), 1); + + // All rows land in the current hour bucket; the series sums back to the total. + assert!(!s.series.is_empty(), "series has at least one bucket"); + assert_eq!(s.series.iter().map(|b| b.total).sum::(), 5); + assert_eq!(s.series.iter().map(|b| b.errors).sum::(), 2); +} + +#[tokio::test] +async fn summarize_of_an_empty_app_is_all_zero() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let app = make_app(&pool).await; + let repo = PostgresExecutionLogRepository::new(pool.clone()); + let s = repo + .summarize_for_app(AppId::from(app), 24) + .await + .expect("summarize"); + assert_eq!(s.total, 0); + assert_eq!(s.errors, 0); + assert!( + (s.error_rate - 0.0).abs() < 1e-9, + "no executions → zero rate" + ); + assert!(s.series.is_empty()); +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index f8a2319..582c677 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -13,8 +13,8 @@ use picloud_manager_core::{ admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api, apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router, docs_admin_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router, - migrations, projects_router, rebuild_route_table, require_authenticated, route_admin_router, - secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo, + metrics_router, migrations, projects_router, rebuild_route_table, require_authenticated, + route_admin_router, secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, @@ -23,22 +23,22 @@ use picloud_manager_core::{ FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl, GroupMembersRepository, GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, - OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, - PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, - PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, - PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, - PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, - PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, - PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupCollectionResolver, - PostgresGroupDocsRepo, PostgresGroupKvRepo, PostgresGroupMembersRepository, - PostgresGroupQueueRepo, PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, - PostgresProjectRepository, PostgresPubsubRepo, PostgresRouteRepository, - PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, - PostgresVarsRepo, PrincipalResolver, ProjectRepository, ProjectsState, PubsubServiceImpl, - RealtimeAuthorityImpl, RepoResolver, RouteAdminState, SandboxCeiling, ScriptRepository, - SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, - TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, - VarsServiceImpl, + MetricsState, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, + PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, + PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, + PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, + PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, + PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, + PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, + PostgresGroupCollectionResolver, PostgresGroupDocsRepo, PostgresGroupKvRepo, + PostgresGroupMembersRepository, PostgresGroupQueueRepo, PostgresGroupRepository, + PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository, PostgresPubsubRepo, + PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, + PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, ProjectRepository, ProjectsState, + PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, SandboxCeiling, + ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, + TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, + UsersServiceImpl, VarsApiState, VarsServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -553,7 +553,7 @@ pub async fn build_app( let admin = AdminState { repo: script_repo.clone(), - logs: log_repo, + logs: log_repo.clone(), apps: apps_repo.clone(), authz: authz.clone(), validator: engine.clone(), @@ -810,6 +810,12 @@ pub async fn build_app( apps: apps_repo.clone(), authz: authz.clone(), })) + // A2: read-only observability rollup over execution_logs. + .merge(metrics_router(MetricsState { + logs: log_repo.clone(), + apps: apps_repo.clone(), + authz: authz.clone(), + })) // §11.6 M4: read-only operator inspection of a group's shared collections. .merge(picloud_manager_core::group_blobs_api::group_blobs_router( picloud_manager_core::group_blobs_api::GroupBlobsState { diff --git a/dashboard/src/lib/AppTabBar.svelte b/dashboard/src/lib/AppTabBar.svelte index 4748d97..d6536c1 100644 --- a/dashboard/src/lib/AppTabBar.svelte +++ b/dashboard/src/lib/AppTabBar.svelte @@ -39,6 +39,7 @@ href: `${base}/apps/${slug}/workflows`, adminOnly: true }, + { id: 'metrics', label: 'Metrics', href: `${base}/apps/${slug}/metrics`, adminOnly: true }, { id: 'dead-letters', label: 'Dead letters', diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 7dbdc04..dbd048c 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -218,6 +218,33 @@ export interface ExecutionLog { created_at: string; } +/** A2: one `(key, count)` row of a metrics breakdown. */ +export interface MetricsCount { + key: string; + count: number; +} + +/** A2: one hour-bucket of the metrics time series. */ +export interface MetricsBucket { + ts: string; + total: number; + errors: number; +} + +/** A2: the observability rollup for one app over a trailing window. */ +export interface MetricsSummary { + window_hours: number; + total: number; + errors: number; + error_rate: number; + avg_duration_ms: number; + p50_duration_ms: number; + p95_duration_ms: number; + by_status: MetricsCount[]; + by_source: MetricsCount[]; + series: MetricsBucket[]; +} + export interface CreateScriptInput { app_id: string; name: string; @@ -1169,6 +1196,14 @@ export const api = { ) }, + metrics: { + /** A2: aggregate execution metrics for an app over the trailing window (hours). */ + summary: (idOrSlug: string, windowHours = 24) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/metrics?window=${windowHours}` + ) + }, + deadLetters: { count: (idOrSlug: string) => adminRequest<{ unresolved: number }>( diff --git a/dashboard/src/routes/apps/[slug]/+layout.svelte b/dashboard/src/routes/apps/[slug]/+layout.svelte index 5ed1424..fb327d0 100644 --- a/dashboard/src/routes/apps/[slug]/+layout.svelte +++ b/dashboard/src/routes/apps/[slug]/+layout.svelte @@ -72,6 +72,8 @@ return 'queues'; case 'workflows': return 'workflows'; + case 'metrics': + return 'metrics'; case 'dead-letters': return 'dead-letters'; default: diff --git a/dashboard/src/routes/apps/[slug]/metrics/+page.svelte b/dashboard/src/routes/apps/[slug]/metrics/+page.svelte new file mode 100644 index 0000000..2993f18 --- /dev/null +++ b/dashboard/src/routes/apps/[slug]/metrics/+page.svelte @@ -0,0 +1,275 @@ + + + + Metrics · {slug} · PiCloud + + +
+
+
+

Metrics

+

Execution rollup over the selected window (from the request log).

+
+
+ {#each windows as w (w.hours)} + + {/each} +
+
+ + {#if error} + + {:else if loading && !summary} +

Loading…

+ {:else if summary} + {#if summary.total === 0} +

No executions recorded in this window.

+ {:else} +
+
+ Executions + {summary.total} +
+
0}> + Error rate + {pct(summary.error_rate)} + {summary.errors} errors +
+
+ Avg latency + {ms(summary.avg_duration_ms)} +
+
+ p50 + {ms(summary.p50_duration_ms)} +
+
+ p95 + {ms(summary.p95_duration_ms)} +
+
+ +

Executions per hour

+
+ + {#each summary.series as b, i (b.ts)} + {@const total = (b.total / maxTotal) * (chartH - 4)} + {@const errs = (b.errors / maxTotal) * (chartH - 4)} + {@const x = i * barStep + (barStep - barW()) / 2} + + {fmtBucket(b.ts)}: {b.total} ({b.errors} errors) + + + {fmtBucket(b.ts)}: {b.errors} errors + + {/each} + +
+ +
+
+

By status

+ + + {#each summary.by_status as row (row.key)} + + + + + {/each} + +
{row.key}{row.count}
+
+
+

By source

+ + + {#each summary.by_source as row (row.key)} + + + + + {/each} + +
{row.key}{row.count}
+
+
+ {/if} + {/if} +
+ +