Monitoring and Metrics
Pawtograder exposes Prometheus metrics from two independent endpoints. Each has its own registry, its own metric-name prefix, and its own bearer token, so you scrape both.Scrape endpoints
Web app: /api/metrics
Set METRICS_SCRAPE_TOKEN and send it as Authorization: Bearer <token>. The route compares SHA-256 digests of the expected and presented tokens in constant time.
- With
METRICS_SCRAPE_TOKENunset, the route returns 503 with the bodymetrics disabled (METRICS_SCRAPE_TOKEN not set). It never serves metrics unauthenticated. - A missing or wrong bearer returns 401 with a
WWW-Authenticate: Bearerheader. - The route runs on the Node runtime and is marked
force-dynamic, so the registry snapshot is never cached.
Scrape the web app
Edge function: /functions/v1/metrics
The metrics edge function is deployed with verify_jwt = false, so it does not accept a Supabase JWT. It accepts GET only and returns 405 for any other method.
Authentication here is optional: when METRICS_TOKEN is unset the endpoint serves metrics to anyone who can reach it. When the variable is set, a request without a Bearer prefix or with the wrong token gets 401.
If the queue RPCs fail, the endpoint returns 500 with the body # Error generating metrics. Failures in the vacuum and RAM collectors are non-fatal: they are reported to Sentry and the rest of the response still renders.
Scrape the edge function
web_ metrics
The web app registry starts with prom-client’s default process and Node metrics under the web_ prefix, then adds the application signals below.
The HTTP histogram uses buckets from 5 ms to 30 s, because streaming routes such as the LLM hint and calendar export have a long tail. The Supabase RPC histogram tops out at 10 s.
Workflow gauges
These four gauges are the autograder pipeline’s success and latency signals. They are read fromworkflow_runs and workflow_run_error at scrape time, not incremented in request handlers.
Two self-monitoring metrics cover the refresh itself:
web_workflow_metrics_refresh_seconds (histogram) and web_workflow_metrics_refresh_errors_total (counter, labelled by step: workflow_runs_1h, workflow_runs_24h, queue_seconds, run_seconds, errors_1h, or refresh).
Each gauge family is reset only after its query succeeds, so a transient RPC failure leaves the last good values in place instead of exporting empty series. Watch web_workflow_metrics_refresh_errors_total to tell a genuinely idle pipeline from a stalled refresh.
pawtograder_ metrics
Queue depth and age
All eight are gauges with no labels, sourced from the
get_async_queue_sizes RPC.
pawtograder_queue_oldest_message_seconds{queue} reports the age of the oldest message per queue, including messages deferred by retry backoff. The queue label takes one of: async_calls, async_calls_dlq, async_calls_low_priority, gradebook_row_recalculate, gradebook_row_recalculate_dlq, discord_async_calls, discord_async_calls_dlq, notification_emails.
Circuit breakers
pawtograder_circuit_breaker_open{scope,key,state} is 1 when a breaker is open and 0 when it is closed, one series per breaker returned by the get_circuit_breaker_statuses RPC.
Rate limiter gauges
Pawtograder rate-limits outbound work with Bottleneck backed by a shared Redis store. The metrics collector connects with the same factory the limiters use:REDIS_URL (real Redis over ioredis) if set, otherwise UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. With neither configured it collects nothing and these three series are absent.
Limiters are discovered by scanning Redis for b_*_settings keys. Each discovered limiter emits three gauges labelled with limiter_id:
Database health
pawtograder_vacuum_alert is 1 per active alert from the vacuum_health_check RPC, labelled check, severity, and table_name. When the RPC fails or exceeds its 3-second timeout, the endpoint emits a single series labelled check="rpc_failed", severity="error", error_type="rpc_error" instead of the raw error. When there are no alerts it emits 0 with check="none", severity="ok".
The database_ram_metrics RPC produces pawtograder_db_-prefixed gauges, with labels taken from each row’s metric_labels:
pawtograder_db_buffer_cache_bytes: shared buffer cache used by a table or indexpawtograder_db_buffer_cache_total_used_bytes: total shared buffer cache in usepawtograder_db_connections: connections by statepawtograder_db_table_total_bytes: table size including indexes and TOASTpawtograder_db_dead_tuples: dead tuples per table
pawtograder_info{version} is a constant 1 used as a target-presence marker.
Cluster wiring
The Helm chart ships the scrape configuration, but it is off by default. Setmonitoring.enabled=true to render ServiceMonitor resources for Postgres (via a postgres_exporter sidecar), storage, edge functions, GoTrue, Kong, Realtime, Supavisor, and the web app. Scrape interval defaults to 30s with a 10s timeout.
The chart deploys no Prometheus or Grafana of its own. It assumes the cluster runs kube-prometheus-stack, which auto-discovers ServiceMonitor resources in any namespace and picks up dashboards from ConfigMaps labelled grafana_dashboard: "1".
The web app’s
ServiceMonitor reads METRICS_SCRAPE_TOKEN from the JWT secret and injects it as the bearer. The edge functions’ ServiceMonitor marks METRICS_TOKEN as optional: true, so a deployment that intentionally leaves edge metrics unauthenticated still gets scraped instead of having the whole ServiceMonitor rejected.postgres_exporter queries ConfigMap that adds Pawtograder-specific series on top of the exporter defaults, including pawtograder_replication_*, pawtograder_wal_archiving_*, pawtograder_db_connections_*, pawtograder_classes_*, pawtograder_active_submissions_active_count, pawtograder_help_queue_depth_*, and pawtograder_table_sizes_*.
Dashboards and alerts
Eight Grafana dashboards ship incharts/pawtograder/dashboards/ and are individually toggleable through monitoring.dashboards.*: stack-overview, postgres-deep-dive, realtime-fanout, edge-functions, app-business, rate-limiting, edge-soak, and queues-and-workers. Each exposes a datasource template variable rather than a hardcoded datasource UID, so they import cleanly into any Prometheus-compatible Grafana.
Alert rules live in charts/pawtograder/templates/prometheus-rules.yaml, grouped as pawtograder.backup, pawtograder.walg, pawtograder.replication, pawtograder.postgres, pawtograder.app, pawtograder.edgefunctions, pawtograder.secrets, and pawtograder.certs. Routing is in alertmanager-config.yaml.
Error tracking and product analytics
Errors do not go to Sentry’s hosted service. All three Sentry initializations point atNEXT_PUBLIC_BUGSINK_DSN, a self-hosted Bugsink instance, with integrations: [] because Bugsink supports no integrations. Server and edge configs are registered from instrumentation.ts, which also re-exports Sentry.captureRequestError as onRequestError. Edge functions initialize separately through npm:@sentry/deno.
The browser client adds tunnel: "/api/tunnel" to route events through the app’s own origin, disables session replay, and drops React hydration mismatch errors (minified codes #418, #423, #425) in beforeSend, since those are usually caused by extensions rewriting the DOM before hydration.
PostHog is initialized in the same client entry point when NEXT_PUBLIC_POSTHOG_KEY is set, with persistence: "localStorage" and cross_subdomain_cookie: false.
There is no distributed tracing. Every Sentry configuration sets
tracesSampleRate: 0, so latency questions have to be answered from the Prometheus histograms above.