Observability + Rate Limiting — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. The three e2e tasks (T3, T6, T9) are controller-driven — do NOT delegate them to a subagent. This is a 3-phase plan: Phase 1 (richer metrics + dependency-aware/readyz), Phase 2 (opt-in OpenTelemetry tracing), Phase 3 (admin-configurable rate limits). Land phases in order; each phase's e2e closes it.
Goal: Deepen Obscura's operability for on-prem/air-gapped deployments (CORE platform, no license gate). Three additive capabilities on top of the existing baseline (RED metrics at /metrics, /healthz+/readyz, three in-memory limiters, slog request logging): (1) richer Prometheus series (DB pool, dependency health, scheduler jobs, rate-limit rejections, AI tokens) plus a dependency-aware deep /readyz; (2) OpenTelemetry tracing that is off by default — nothing leaves the box unless an OTLP endpoint is configured; (3) admin-tunable rate limits with a safety floor that prevents lockout, live-applied via atomic.Pointer (no restart). Everything preserves the air-gapped-by-default posture and leaves the demo byte-for-byte unchanged until an operator opts in.
Architecture:
- Metrics extend the existing private registry. internal/httpapi/metrics.go already owns a private prometheus.Registry + a promauto factory + the RED vectors. Phase 1 adds gauge/counter/histogram fields there plus two scrape-time custom prometheus.Collectors (DB pool from pgxpool.Stat(), AI tokens from ai_usage), all registered on the SAME private registry served at /metrics. No new Go dep (client_golang is already present).
- A shared DependencyProbe backs both /readyz and the dependency_up gauges. A new small type in httpapi runs per-dependency checks (each bounded ~2s, concurrently) for postgres / minio / extract / embed / ai / gotenberg / protection-engine — but only for the dependencies actually configured for this deployment. It serves the deep /readyz (200 {ready,deps} / 503) and refreshes the gauges on a background tick so metrics stay fresh independent of a scrape. /healthz stays a static cheap liveness 200.
- Tracing is a no-op unless configured. A new internal/platform/obs package exposes SetupTracing(ctx, cfg) (shutdown, error). With no OTEL_EXPORTER_OTLP_ENDPOINT it installs nothing — the global OTel provider stays the built-in no-op (zero span allocation). When set, it installs an OTLP/HTTP exporter + batching provider + W3C propagator. Instrumentation is hand-rolled (a chi-compatible HTTP span middleware + a pgx.QueryTracer) to avoid the heavier otelhttp/otelpgx deps; trace/span ids are injected into the slog request log so logs correlate even with no collector.
- Rate limits become a live-applied singleton. Migration 00086 adds rate_limit_settings (id=1 CHECK), seeded with today's constants so behavior is unchanged. A tiny internal/ratelimit context (domain + adapters, no app-service layer) loads/saves it; the httpapi Server holds an atomic.Pointer[ratelimit.Config] (the license hot-swap pattern) that the limiter middleware reads live. Admin GET/PUT /api/v1/admin/rate-limits (perm rbac.admin) edits it behind a safety floor; a new Admin → Observability tab is the UI.
- Wiring gotcha (critical): this server is http.ServeMux → per-prefix → chi (wire.go). /metrics, /healthz, /readyz are mounted on the OUTER mux directly (not inside chi), so Phase-1/2 changes to those three handlers go in wire.go, NOT the chi router. Consequently /readyz and /metrics are deliberately NOT wrapped by the chi requestLog/instrument/tracing middleware (scrapes don't pollute the RED metrics, and readiness probes aren't traced) — this is correct and intentional.
Tech Stack: Go modular monolith (go/, chi router, pgx v5, goose migrations). New Go deps in Phase 2 only — the OpenTelemetry SDK + OTLP/HTTP exporter (go get + commit go.mod/go.sum; the repo does NOT vendor; air-gap builds fine — modules are cached). Phases 1 and 3 need no new Go dep. React + Carbon (@carbon/react) SPA (web/), TanStack Query, openapi-typescript-generated client — no new npm deps. Tested by curl/docker against the deployed stack.
Spec: docs/superpowers/specs/2026-07-06-observability-design.md
Global Constraints (every task)
- NEVER
go test— the test DSN (:55432) IS the live demo Postgres (deploy-postgres-1). Go verify iscd go && go build ./... && go vet ./...only (vet compiles test files too, so keep existing tests compiling — notably the ~23db.Open(ctx, dsn())test call-sites stay valid becauseOpengains a variadic...Option, a backward-compatible signature change). - Web verify:
cd web && npx tsc --noEmit && npx vite build. npm run gen:apiafter ANYapi/openapi.yamledit (regeneratesweb/src/api/schema.ts). Run it fromweb/. Commit the regeneratedschema.tswith the task.- NO new npm dependencies (
npm installis broken: npm11/node25 arborist crash). NEW GO DEPS ARE EXPECTED IN PHASE 2 (OpenTelemetry) — connected build host, same mechanismclient_golang/go-ldapwere added with:go get <pkg>@<ver> && go mod tidyfromgo/, commitgo/go.mod+go/go.sum. Phases 1 & 3 add no dep. - Deploy ONLY from repo root:
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After deploy assert/me enabled_modules == [ai, correspondence, esign, semantic, watermarking](dev-logindirector@obscura.local, host port 38080). - Tracing is OFF by default (air-gapped): the deploy env sets no
OTEL_*vars → no spans,/metricsstill serves. The e2e that turns it on reverts it so the deployed stack ends with tracing off. - Rate-limit floor prevents lockout: save-validation enforces per-limit minimums so an admin (or the SPA) can't be throttled to a standstill and the auth-IP brute-force floor can't be disabled to 0. A malformed stored row falls back to the seeded defaults.
- Commit per task on
main, do NOT push. NEVERgit add -A(go/obscura-serveris a tracked ELF binary) — alwaysgit addexplicit paths. - i18n en/id parity (tsc-enforced): feature-co-located
web/src/features/admin/i18n.ts; nested groups;enandididentical in shape. Smart-quote gotcha: the Edit tool can mangle’/“”; after editing an i18n file verifynpx tsc --noEmitand if quotes broke, rewrite the whole file with Write. down -vis FORBIDDEN (a prior incident wiped the demo pgdata + miniodata). Neverdocker compose down -v. Single-service teardown for the e2e usesdocker compose stop <service>thendocker compose start <service>(NOTrm); the tracing e2e uses a throwawaydocker runcollector removed withdocker rm -f.- e2e restores all state — restart any stopped service; reset
rate_limit_settingsto seeded defaults; redeploy with tracing off. Re-assert the demo (5 modules; tracing off; limits at defaults).
File Map
| File | Task | Role |
|---|---|---|
go/internal/platform/db/db.go |
T1, T4 | add Stat() accessor (T1); Open variadic Option + WithQueryTracer (T4) |
go/internal/platform/blob/minio.go |
T1 | add Healthy(ctx) (BucketExists round-trip) |
go/internal/httpapi/metrics.go |
T1 | new fields (dep_up gauge, scheduler counters, rate-limit-rejected) + SetDependencyUp/RecordSchedulerRun/RecordRateLimitRejected/RateLimitRejections/Register |
go/internal/httpapi/metrics_collectors.go (new) |
T1 | DBPoolCollector + AITokensCollector + AITokenQuery (scrape-time custom collectors) |
go/internal/scheduler/app/service.go |
T1 | SetObserver hook + wrap the handler run in RunDue |
go/internal/httpapi/ratelimit.go |
T1, T3 | rateLimit gains a name param + rejection metric (T1); reads live config via selector (T3-phase3) |
go/internal/httpapi/handlers_public_sign.go |
T1, T3 | bump rejection metric on the 2 denies (T1); read live public-sign budget (T3-phase3) |
go/internal/httpapi/server.go |
T1, T5, T7 | rate-limit call sites w/ names (T1); r.Use(s.tracing) (T5); RateLimitStore/limits + admin routes (T7) |
go/cmd/obscura-server/wire.go |
T1, T2, T4, T7 | register collectors + scheduler observer (T1); build probe + deep /readyz + tick (T2); tracing setup + pgx tracer (T4); rate-limit store + seed (T7) |
go/internal/httpapi/dependency.go (new) |
T2 | DependencyProbe + HTTPHealthCheck + deep /readyz handler |
go/go.mod, go/go.sum |
T4 | add OpenTelemetry SDK + OTLP/HTTP exporter |
go/internal/platform/config/config.go |
T4 | OTELConfig + field + fail-closed validate() |
go/internal/platform/obs/tracing.go (new) |
T4 | Config, SetupTracing, NewPgxTracer |
go/internal/httpapi/tracing.go (new) |
T5 | s.tracing HTTP span middleware |
go/internal/httpapi/logging.go |
T5 | inject trace_id/span_id into the slog request line |
go/migrations/00086_rate_limit_settings.sql (new) |
T7 | singleton table seeded with current consts |
go/internal/ratelimit/domain/ratelimit.go (new) |
T7 | Config + floors + Validate + Defaults |
go/internal/ratelimit/adapters/pg.go (new) |
T7 | Store (Load/Save, fallback-to-defaults on corrupt row) |
go/internal/httpapi/handlers_ratelimit.go (new) |
T7 | GetRateLimits/PutRateLimits (rbac.admin) |
api/openapi.yaml |
T7 | /admin/rate-limits GET/PUT paths + schemas |
web/src/api/schema.ts |
T7 | regenerated via gen:api |
web/src/features/admin/data.ts |
T8 | useRateLimits/useSaveRateLimits/useReadyz |
web/src/features/admin/ObservabilityTab.tsx (new) |
T8 | rate-limit editors + rejection counts + dependency-health strip |
web/src/features/admin/AdminPage.tsx |
T8 | register the Observability tab (Tab + TabPanel + import) |
web/src/features/admin/i18n.ts |
T8 | admin.observability.* + admin.tabs.observability (en/id) |
web/src/styles/app.css |
T8 | small .obs-* layout block |
Scouted anchors (verified this session — cite these while implementing)
- Migrations run to
00085(ls go/migrations:…00082_user_provider,00083_ldap_group_roles,00084_scim,00085_scim_group_roles). Next free =00086. metrics.go(go/internal/httpapi/metrics.go):Metrics{ reg *prometheus.Registry; reqTotal/reqDuration *…Vec; inFlight Gauge; loginTotal *CounterVec }(:20-33).NewMetrics()builds on a privateprometheus.NewRegistry()viaf := promauto.With(reg)(:37-39), pre-touches known label values (:60-63).Handler()=promhttp.HandlerFor(m.reg, …)(:68).instrumentRED middleware (:75-92) records under the chi route PATTERN (chi.RouteContext(r.Context()).RoutePattern(), falls back to"other").RecordLogin(ok)(:95). Extend this struct + factory; register custom collectors onm.reg.db.go(go/internal/platform/db/db.go):DB{ pool *pgxpool.Pool }(:26-28).Open(ctx, dsn)at:31usespgxpool.New(ctx, dsn)— noStat()accessor exists; add one (func (d *DB) Stat() *pgxpool.Stat { return d.pool.Stat() }).Ping(ctx)at:47(used by/readyz).Exec(ctx) Executorat:62;Do(ctx, fn)tx at:73. Only caller ofdb.Openoutside tests iswire.go:106; ~23_test.gofiles calldb.Open(ctx, dsn())→ the new signature MUST stay call-compatible → use variadic...Option.wire.go(go/cmd/obscura-server/wire.go):run(ctx, cfg, logger)at:83;database, err := db.Open(ctx, cfg.DatabaseURL)at:106,defer database.Close()at:110.blobStore, err := blob.NewMinIOStore(…)at:143.schedulerSvc := schedulerapp.NewService(…)at:265.officeConv := render.NewGotenbergOffice(cfg.GotenbergURL)at:262. Background loops viago loop(ctx, logger, name, interval, fn)at:614-636(audit-chainer/outbox-relay/scheduler).engine := selectProtectionEngine(cfg, logger)at:609(hasengine.Ready(ctx) error).metrics := httpapi.NewMetrics()at:428;api := httpapi.NewServer(httpapi.Deps{…})at:429-475. Outer mux at:639:GET /healthzstatic (:640-643),GET /readyz= DBPing+engine.Ready(:644-655),mux.Handle("GET /metrics", api.MetricsHandler())(:659),mux.Handle("/api/v1/", …)+/scim/v2/(:665-667). Shutdown:srv.Shutdown(shutdownCtx)at:686-688.scheduler/app/service.go:RunDue(ctx) (ran int, err error)at:74loops registered handlers, claims viarepo.ClaimDue, runsr.handler(ctx)at:87.Service{ repo; handlers map[string]registration; clock func() time.Time }(:35-39);clockistime.Now().UTC(:48). Add anonRun func(task string, d time.Duration, err error)field +SetObserver+ wrap the handler call.ratelimit.go:rateLimiter{ mu; hits map[string]*limWindow; window };newRateLimiter(window);allow(key, max, now) bool(:33). ConstsapiRateWindow=time.Minute,apiRateUserMax=600,apiRateAuthRouteMax=60,apiRateTSAMax=600(:57-62). SingletonsuserLimiter/authLimiter/tsaLimiter(:67-71).func (s *Server) rateLimit(rl, keyFn, max)writes a 429writeProblemon deny (:77-89).principalKeyFn/ipKeyFn/tsaKeyFn(:94-125). Call sites inserver.go:authRL := s.rateLimit(authLimiter, ipKeyFn, apiRateAuthRouteMax)(:232),tsaRL := s.rateLimit(tsaLimiter, tsaKeyFn, apiRateTSAMax)(:269),r.Use(s.rateLimit(userLimiter, principalKeyFn, apiRateUserMax))(:284).handlers_public_sign.go:publicSignLimiter{ mu; hits map[string]*limWindow }+ singletonpubSignLimiter(:42);limWindowtype defined here (:37, shared byratelimit.go); constspubSignWindow=time.Minute,pubSignSendMax=5,pubSignSubmitMax=10(:44-48). Denies are in*ServermethodsPublicSendOTP(:163) andPublicSubmitOTP(:179) — both havesin scope.config.go:Configstruct + nested config structs withenv:"…" envDefault:"…"tags;Validate()(:404) fail-closed switch pattern; per-providervalidate()helpers (OIDCConfig.validate,LDAPConfig.validate,ESignPeruriConfig.validate).GotenbergURLdefaulthttp://localhost:33000(:42).ExtractConfig{ Provider "none|sidecar"; SidecarURL "http://localhost:38001" }(:332).EmbedConfig{ Provider "mock|sidecar|openai"; SidecarURL "http://localhost:38000" }(:318).AIConfig{ Provider (AI_CHAT_PROVIDER "mock"); BaseURL (AI_CHAT_BASE_URL); … }(:300).env.ParseAs[Config]()at:393.ai_usagetable (go/migrations/00077_ai_conversations.sql:37-44):day date, feature text, requests int, tokens_in bigint, tokens_out bigint, PRIMARY KEY (day, feature). Collector SQL:SELECT feature, COALESCE(sum(tokens_in),0), COALESCE(sum(tokens_out),0) FROM ai_usage GROUP BY feature.server.go:Depsstruct (:73-108) +Serverstruct (:111-150, notelic atomic.Pointer[licenseState]at:149— the hot-swap pattern to mirror).NewServer(d Deps)(:153-206) maps Deps→fields, defaults nilmetrics/logger.MetricsHandler()(:210).Handler()(:215) middleware order:RequestID→requestLog(s.logger)→s.metrics.instrument→Recoverer(:221-224). Admin routes gated withr.With(s.requirePerm("rbac.admin")).Get/Put/…(:621-646, e.g./admin/ldap/status,/admin/scim/token).logging.go:requestLog(logger)(:20) emits onelogger.LogAttrs(ctx, LevelInfo, "http_request", slog.String("method",…), …, slog.String("remote", r.RemoteAddr))line (:34-42) — injecttrace_id/span_idhere.blob/minio.go:MinIOStore{ client *minio.Client; bucket; cipher };NewMinIOStore(:57) usesclient.BucketExists(ctx, cfg.Bucket)(:66). File importsfmt. AddHealthy(ctx) error=BucketExistsround-trip.- Sidecars expose
/healthz; Gotenberg exposes/health— confirmed indeploy/docker-compose.yml: embed-sidecar + extract-sidecar healthchecks GEThttp://localhost:8000/healthz(:48,:70); in-network they arehttp://embed-sidecar:8000/http://extract-sidecar:8000(EMBED_SIDECAR_URL/EXTRACT_SIDECAR_URL,:120/:128);GOTENBERG_URL: http://gotenberg:3000(:115). go.mod: hasgithub.com/jackc/pgx/v5 v5.10.0(:16),github.com/prometheus/client_golang v1.23.2(:20). Nogo.opentelemetry.io/otel— added in T4.AdminPage.tsx: tab labels<Tab>{t('admin.tabs.X')}</Tab>(:107-117, last isldap:117); panels in a<TabPanels>(:120-240, last is<TabPanel><LdapTab/></TabPanel>:237-239); tab components imported:37-48. Register the new tab as the LAST tab + LAST panel (order of<Tab>s must match order of<TabPanel>s).admin/data.ts: hooks useapi,okfrom@/api/clientanduseQuery/useMutation/useQueryClient(already imported); e.g.api.GET('/api/v1/admin/users', {…}).then(ok)(:78,:126).admin/i18n.ts:export const en = {…}thenexport const id: typeof en = {…};admin.tabs.*group (:12),admin.tabs.ldap:'Directory'(:23) / id (:545);ldap:{…}/scim:{…}copy groups (en:465/:492, id counterparts). Addtabs.observability+ anobservability:{…}group in both.app.css: reusable.ldap-map__addflex row (:3630);.scim-token__*blocks (:3952). Add a small.obs-*block near these.
---
PHASE 1 — Richer metrics + dependency-aware /readyz
New Prometheus series (DB pool, dependency_up, scheduler jobs, rate-limit rejections, AI tokens) and a deep /readyz. No new dep; no config change; the demo's /metrics gains series and /readyz gains a JSON body but stays 200 when healthy.
Task 1: Metrics plumbing — collectors, gauges, counters, scheduler observer, rate-limit rejection metric
Files: go/internal/platform/db/db.go, go/internal/platform/blob/minio.go, go/internal/httpapi/metrics.go, go/internal/httpapi/metrics_collectors.go (new), go/internal/scheduler/app/service.go, go/internal/httpapi/ratelimit.go, go/internal/httpapi/server.go, go/internal/httpapi/handlers_public_sign.go, go/cmd/obscura-server/wire.go.
Interface produced (consumed by T2): metrics.SetDependencyUp(dep, up); the exported collector constructors httpapi.NewDBPoolCollector/NewAITokensCollector/AITokenQuery; db.Stat(); blobStore.Healthy(ctx).
- [ ] Step 1 —
db.Stat()accessor. Ingo/internal/platform/db/db.go, afterPing(:47), add:
// Stat returns pool statistics (connection counts, acquire waits) for the metrics collector.
func (d *DB) Stat() *pgxpool.Stat { return d.pool.Stat() }
pgxpool is already imported.
- [ ] Step 2 —
blobStore.Healthy. Ingo/internal/platform/blob/minio.go, afterEncrypted()(:49), add:
// Healthy reports whether the blob store is reachable (a cheap BucketExists round-trip). Used by
// the dependency probe backing /readyz.
func (s *MinIOStore) Healthy(ctx context.Context) error {
if _, err := s.client.BucketExists(ctx, s.bucket); err != nil {
return fmt.Errorf("blob: bucket check: %w", err)
}
return nil
}
context and fmt are already imported.
- [ ] Step 3 — Metrics struct + factory + methods. In
go/internal/httpapi/metrics.go: add"sync/atomic"to the import block. Add these fields toMetrics(afterloginTotal):
depUp *prometheus.GaugeVec // label: dep — 1 up / 0 down (set by the DependencyProbe)
schedRuns *prometheus.CounterVec // labels: task, result (ok|error)
schedDuration *prometheus.HistogramVec // label: task
rateRejected *prometheus.CounterVec // label: limiter (user|auth_ip|tsa|public_sign)
// rateRejectedN mirrors rateRejected as plain atomics so the admin rate-limits view can read
// live counts back without a Prometheus gather.
rateRejectedN map[string]*atomic.Int64
In NewMetrics, inside the m := &Metrics{…} literal (after loginTotal: …), add:
depUp: f.NewGaugeVec(prometheus.GaugeOpts{
Name: "obscura_dependency_up",
Help: "External dependency health (1 = reachable, 0 = down), labelled by dependency.",
}, []string{"dep"}),
schedRuns: f.NewCounterVec(prometheus.CounterOpts{
Name: "obscura_scheduler_runs_total",
Help: "Scheduler task executions, labelled by task and result (ok|error).",
}, []string{"task", "result"}),
schedDuration: f.NewHistogramVec(prometheus.HistogramOpts{
Name: "obscura_scheduler_run_duration_seconds",
Help: "Scheduler task run duration in seconds, labelled by task.",
Buckets: prometheus.DefBuckets,
}, []string{"task"}),
rateRejected: f.NewCounterVec(prometheus.CounterOpts{
Name: "obscura_rate_limit_rejected_total",
Help: "Requests rejected by a rate limiter, labelled by limiter.",
}, []string{"limiter"}),
rateRejectedN: map[string]*atomic.Int64{
"user": {}, "auth_ip": {}, "tsa": {}, "public_sign": {},
},
After the existing m.loginTotal.WithLabelValues(...) pre-touch lines (before return m), pre-touch the known limiter labels so the series exist from t=0:
for _, name := range []string{"user", "auth_ip", "tsa", "public_sign"} {
m.rateRejected.WithLabelValues(name)
}
Append these methods to the file:
// SetDependencyUp records a dependency's health (1 up / 0 down); called by the DependencyProbe.
func (m *Metrics) SetDependencyUp(dep string, up bool) {
v := 0.0
if up {
v = 1
}
m.depUp.WithLabelValues(dep).Set(v)
}
// RecordSchedulerRun records one scheduler task execution (result ok|error) + its duration.
func (m *Metrics) RecordSchedulerRun(task string, d time.Duration, err error) {
result := "ok"
if err != nil {
result = "error"
}
m.schedRuns.WithLabelValues(task, result).Inc()
m.schedDuration.WithLabelValues(task).Observe(d.Seconds())
}
// RecordRateLimitRejected bumps the rejection counter for a limiter (user|auth_ip|tsa|public_sign).
func (m *Metrics) RecordRateLimitRejected(limiter string) {
m.rateRejected.WithLabelValues(limiter).Inc()
if n := m.rateRejectedN[limiter]; n != nil {
n.Add(1)
}
}
// RateLimitRejections returns the current rejection counts per limiter (for the admin view).
func (m *Metrics) RateLimitRejections() map[string]int64 {
out := make(map[string]int64, len(m.rateRejectedN))
for k, v := range m.rateRejectedN {
out[k] = v.Load()
}
return out
}
// Register adds a custom collector (the DB-pool + AI-tokens scrape collectors) to the private
// registry so they appear on /metrics.
func (m *Metrics) Register(c prometheus.Collector) error { return m.reg.Register(c) }
- [ ] Step 4 — Custom collectors. Create
go/internal/httpapi/metrics_collectors.go:
package httpapi
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus"
"github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
)
// ---------------------------------------------------------------------------
// DB pool collector — reads pool.Stat() at scrape time (no background goroutine).
// ---------------------------------------------------------------------------
var (
dbPoolTotal = prometheus.NewDesc("obscura_db_pool_total_conns", "Total connections currently in the pgx pool.", nil, nil)
dbPoolAcquired = prometheus.NewDesc("obscura_db_pool_acquired_conns", "Currently acquired (in-use) connections.", nil, nil)
dbPoolIdle = prometheus.NewDesc("obscura_db_pool_idle_conns", "Currently idle connections.", nil, nil)
dbPoolMax = prometheus.NewDesc("obscura_db_pool_max_conns", "Configured maximum pool size.", nil, nil)
dbPoolAcquireTotal = prometheus.NewDesc("obscura_db_pool_acquire_total", "Cumulative count of successful connection acquires.", nil, nil)
dbPoolAcquireWait = prometheus.NewDesc("obscura_db_pool_acquire_wait_seconds_total", "Cumulative time spent blocked waiting for a connection.", nil, nil)
)
// DBPoolCollector exports pgxpool statistics. It reads Stat() on each scrape, so there is no
// background work and the numbers are always current.
type DBPoolCollector struct{ stat func() *pgxpool.Stat }
// NewDBPoolCollector builds the collector over a pool Stat() accessor (pass database.Stat).
func NewDBPoolCollector(stat func() *pgxpool.Stat) *DBPoolCollector { return &DBPoolCollector{stat: stat} }
// Describe implements prometheus.Collector.
func (c *DBPoolCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- dbPoolTotal
ch <- dbPoolAcquired
ch <- dbPoolIdle
ch <- dbPoolMax
ch <- dbPoolAcquireTotal
ch <- dbPoolAcquireWait
}
// Collect implements prometheus.Collector.
func (c *DBPoolCollector) Collect(ch chan<- prometheus.Metric) {
s := c.stat()
if s == nil {
return
}
ch <- prometheus.MustNewConstMetric(dbPoolTotal, prometheus.GaugeValue, float64(s.TotalConns()))
ch <- prometheus.MustNewConstMetric(dbPoolAcquired, prometheus.GaugeValue, float64(s.AcquiredConns()))
ch <- prometheus.MustNewConstMetric(dbPoolIdle, prometheus.GaugeValue, float64(s.IdleConns()))
ch <- prometheus.MustNewConstMetric(dbPoolMax, prometheus.GaugeValue, float64(s.MaxConns()))
ch <- prometheus.MustNewConstMetric(dbPoolAcquireTotal, prometheus.CounterValue, float64(s.AcquireCount()))
ch <- prometheus.MustNewConstMetric(dbPoolAcquireWait, prometheus.CounterValue, s.AcquireDuration().Seconds())
}
// ---------------------------------------------------------------------------
// AI-tokens collector — sums ai_usage per feature at scrape time.
// ---------------------------------------------------------------------------
// AITokenRow is one feature's cumulative token totals.
type AITokenRow struct {
Feature string
TokensIn int64
TokensOut int64
}
var aiTokensDesc = prometheus.NewDesc(
"obscura_ai_tokens_total",
"Cumulative AI tokens by feature and direction (in|out), summed from ai_usage.",
[]string{"feature", "direction"}, nil,
)
// AITokensCollector exports AI token totals. The DB read is bounded and best-effort: a query error
// omits the ai series for that scrape rather than failing the whole /metrics response.
type AITokensCollector struct {
query func(ctx context.Context) ([]AITokenRow, error)
}
// NewAITokensCollector builds the collector over an ai_usage aggregate reader (use AITokenQuery).
func NewAITokensCollector(q func(ctx context.Context) ([]AITokenRow, error)) *AITokensCollector {
return &AITokensCollector{query: q}
}
// Describe implements prometheus.Collector.
func (c *AITokensCollector) Describe(ch chan<- *prometheus.Desc) { ch <- aiTokensDesc }
// Collect implements prometheus.Collector.
func (c *AITokensCollector) Collect(ch chan<- prometheus.Metric) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := c.query(ctx)
if err != nil {
return
}
for _, r := range rows {
ch <- prometheus.MustNewConstMetric(aiTokensDesc, prometheus.CounterValue, float64(r.TokensIn), r.Feature, "in")
ch <- prometheus.MustNewConstMetric(aiTokensDesc, prometheus.CounterValue, float64(r.TokensOut), r.Feature, "out")
}
}
// AITokenQuery builds the ai_usage aggregate reader over the DB (wired in wire.go).
func AITokenQuery(database *db.DB) func(ctx context.Context) ([]AITokenRow, error) {
return func(ctx context.Context) ([]AITokenRow, error) {
rows, err := database.Exec(ctx).Query(ctx,
`SELECT feature, COALESCE(sum(tokens_in), 0), COALESCE(sum(tokens_out), 0) FROM ai_usage GROUP BY feature`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AITokenRow
for rows.Next() {
var r AITokenRow
if err := rows.Scan(&r.Feature, &r.TokensIn, &r.TokensOut); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
}
- [ ] Step 5 — Scheduler observer. In
go/internal/scheduler/app/service.go: add a field toService(afterclock):
onRun func(task string, d time.Duration, err error) // optional metrics observer (nil = no-op)
Add a setter (after NewService):
// SetObserver registers a callback invoked after each task run (task name, duration, handler
// error). Used to feed scheduler metrics. nil disables it.
func (s *Service) SetObserver(fn func(task string, d time.Duration, err error)) { s.onRun = fn }
In RunDue, replace the run block (currently if herr := r.handler(ctx); herr != nil && err == nil { … }; ran++) with:
start := s.clock()
herr := r.handler(ctx)
if s.onRun != nil {
s.onRun(name, s.clock().Sub(start), herr)
}
if herr != nil && err == nil {
err = fmt.Errorf("scheduler task %q: %w", name, herr)
}
ran++
time and fmt are already imported.
- [ ] Step 6 — Rate-limit rejection metric (middleware + public-sign). In
go/internal/httpapi/ratelimit.go, changerateLimitto take anameand bump the metric on deny (keepmax intfor now — Phase 3 makes it live):
func (s *Server) rateLimit(rl *rateLimiter, name string, keyFn func(*http.Request) (string, bool), max int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, limited := keyFn(r)
if limited && !rl.allow(key, max, time.Now()) {
s.metrics.RecordRateLimitRejected(name)
w.Header().Set("Retry-After", strconv.Itoa(int(rl.window.Seconds())))
writeProblem(w, &kernel.Error{Kind: kernel.ErrRateLimited, Code: "api.rate_limited", Message: "too many requests — please slow down"})
return
}
next.ServeHTTP(w, r)
})
}
}
In go/internal/httpapi/server.go, update the three call sites:
authRL := s.rateLimit(authLimiter, "auth_ip", ipKeyFn, apiRateAuthRouteMax)
tsaRL := s.rateLimit(tsaLimiter, "tsa", tsaKeyFn, apiRateTSAMax)
r.Use(s.rateLimit(userLimiter, "user", principalKeyFn, apiRateUserMax))
In go/internal/httpapi/handlers_public_sign.go, add the metric bump inside both deny branches (PublicSendOTP :163, PublicSubmitOTP :179), as the first line inside each if !pubSignLimiter.allow(...) { block:
s.metrics.RecordRateLimitRejected("public_sign")
- [ ] Step 7 — Register collectors + scheduler observer in wire.go. In
go/cmd/obscura-server/wire.go, immediately aftermetrics := httpapi.NewMetrics()(:428), add:
// Phase-1 observability plumbing: pool + AI-token scrape collectors and the scheduler run
// observer, all on the same private registry served at /metrics.
if err := metrics.Register(httpapi.NewDBPoolCollector(database.Stat)); err != nil {
return fmt.Errorf("register db pool collector: %w", err)
}
if err := metrics.Register(httpapi.NewAITokensCollector(httpapi.AITokenQuery(database))); err != nil {
return fmt.Errorf("register ai tokens collector: %w", err)
}
schedulerSvc.SetObserver(metrics.RecordSchedulerRun)
- [ ] Step 8 — Verify + commit.
cd go && go build ./... && go vet ./.... Commit:
git add go/internal/platform/db/db.go go/internal/platform/blob/minio.go go/internal/httpapi/metrics.go go/internal/httpapi/metrics_collectors.go go/internal/scheduler/app/service.go go/internal/httpapi/ratelimit.go go/internal/httpapi/server.go go/internal/httpapi/handlers_public_sign.go go/cmd/obscura-server/wire.go
git commit -m "feat(metrics): db-pool + dependency_up + scheduler + rate-limit-rejected + ai-tokens series"
Task 2: DependencyProbe + deep /readyz + background gauge tick
Files: go/internal/httpapi/dependency.go (new), go/cmd/obscura-server/wire.go.
Interface produced (consumed by T3 e2e): deep /readyz returning {ready, deps{}} 200/503; obscura_dependency_up{dep} gauges refreshed on a tick and on each /readyz hit.
- [ ] Step 1 — DependencyProbe. Create
go/internal/httpapi/dependency.go:
package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// DependencyProbe checks each configured external dependency with a short per-dep timeout and
// (a) backs the deep /readyz response and (b) refreshes the obscura_dependency_up gauges. It is
// constructed in wire.go with closures that reach the real dependencies (DB, blob store, sidecars,
// Gotenberg, protection engine). ONLY dependencies actually configured for THIS deployment are
// added, so e.g. a build with EXTRACT_PROVIDER=none has no "extract" check and /readyz never 503s
// on a component the deployment does not run.
type DependencyProbe struct {
metrics *Metrics
timeout time.Duration
checks []depCheck
}
type depCheck struct {
name string
check func(ctx context.Context) error
}
// NewDependencyProbe builds an empty probe. perDepTimeout bounds each individual check (~2s) so a
// hung dependency cannot stall the probe; checks run concurrently, so the whole probe is ~one
// timeout worst-case.
func NewDependencyProbe(m *Metrics, perDepTimeout time.Duration) *DependencyProbe {
return &DependencyProbe{metrics: m, timeout: perDepTimeout}
}
// Add registers a dependency check (chainable). Order is preserved in the deps map.
func (p *DependencyProbe) Add(name string, check func(ctx context.Context) error) *DependencyProbe {
p.checks = append(p.checks, depCheck{name: name, check: check})
return p
}
// run executes every check concurrently under its own timeout, updates the gauges, and returns the
// aggregate readiness + a per-dep status map ("ok" or the error text).
func (p *DependencyProbe) run(ctx context.Context) (ready bool, deps map[string]string) {
deps = make(map[string]string, len(p.checks))
errs := make([]error, len(p.checks))
var wg sync.WaitGroup
for i, c := range p.checks {
wg.Add(1)
go func(i int, c depCheck) {
defer wg.Done()
cctx, cancel := context.WithTimeout(ctx, p.timeout)
defer cancel()
errs[i] = c.check(cctx)
}(i, c)
}
wg.Wait()
ready = true
for i, c := range p.checks {
up := errs[i] == nil
p.metrics.SetDependencyUp(c.name, up)
if up {
deps[c.name] = "ok"
} else {
ready = false
deps[c.name] = errs[i].Error()
}
}
return ready, deps
}
// Refresh runs the probe and updates gauges only (background tick — keeps dependency_up fresh
// without depending on a /readyz hit or a scrape).
func (p *DependencyProbe) Refresh(ctx context.Context) { p.run(ctx) }
// Readyz is the deep readiness handler: 200 {ready:true, deps:{...}} when every configured
// dependency is up, else 503 {ready:false, deps:{dep:err}}. It is mounted on the OUTER mux
// (wire.go), so it is not wrapped by the chi middleware — scrapes and probes never pollute the
// RED metrics. Bounded by the per-dep timeouts so a hung dependency can't stall a load balancer.
func (p *DependencyProbe) Readyz(w http.ResponseWriter, r *http.Request) {
ready, deps := p.run(r.Context())
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"ready": ready, "deps": deps})
}
// HTTPHealthCheck returns a dependency check that GETs url with the given client and treats any
// response below 500 as healthy (the endpoint answered), a transport error or a 5xx as down.
func HTTPHealthCheck(client *http.Client, url string) func(ctx context.Context) error {
return func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("status %d", resp.StatusCode)
}
return nil
}
}
- [ ] Step 2 — Build the probe + deep
/readyz+ tick in wire.go. Ingo/cmd/obscura-server/wire.go, immediately before themux := http.NewServeMux()line (:639), add:
// Deep dependency probe backing /readyz + the obscura_dependency_up gauges. Only deps actually
// configured for THIS deployment are added, so /readyz never 503s on a component the deployment
// does not run. Each check is bounded to 2s; the probe runs them concurrently.
depProbe := httpapi.NewDependencyProbe(metrics, 2*time.Second)
healthClient := &http.Client{Timeout: 2 * time.Second}
depProbe.Add("postgres", database.Ping)
depProbe.Add("minio", blobStore.Healthy)
depProbe.Add("engine", engine.Ready)
depProbe.Add("gotenberg", httpapi.HTTPHealthCheck(healthClient, strings.TrimRight(cfg.GotenbergURL, "/")+"/health"))
if cfg.Extract.Provider == "sidecar" {
depProbe.Add("extract", httpapi.HTTPHealthCheck(healthClient, strings.TrimRight(cfg.Extract.SidecarURL, "/")+"/healthz"))
}
if cfg.Embed.Provider == "sidecar" {
depProbe.Add("embed", httpapi.HTTPHealthCheck(healthClient, strings.TrimRight(cfg.Embed.SidecarURL, "/")+"/healthz"))
}
// AI is probed only when it is a real HTTP backend WITH a configured base URL (self-hosted
// OpenAI-compatible / custom endpoint). A managed provider with no base URL, or the mock, is
// in-process/undialable-by-default and is left unprobed — no new egress on the air-gapped path.
if (cfg.AI.Provider == "openai" || cfg.AI.Provider == "anthropic") && cfg.AI.BaseURL != "" {
depProbe.Add("ai", httpapi.HTTPHealthCheck(healthClient, strings.TrimRight(cfg.AI.BaseURL, "/")))
}
// Keep the dependency_up gauges fresh without depending on a /readyz hit or a scrape.
go loop(ctx, logger, "dependency-probe", 15*time.Second, func(ctx context.Context) error {
depProbe.Refresh(ctx)
return nil
})
Then replace the existing mux.HandleFunc("GET /readyz", …) block (:644-655) with:
mux.HandleFunc("GET /readyz", depProbe.Readyz)
Leave /healthz (static 200) and /metrics unchanged. Ensure strings is in wire.go's import block (add it if absent).
- [ ] Step 3 — Verify + commit.
cd go && go build ./... && go vet ./.... Commit:
git add go/internal/httpapi/dependency.go go/cmd/obscura-server/wire.go
git commit -m "feat(readyz): dependency-aware deep /readyz probe + dependency_up gauge tick"
Task 3 (controller-driven e2e — NOT a subagent): deploy + assert new metrics + /readyz 200/503
Prereqs: T1–T2 committed on main and building. docker compose stop <svc> / start <svc> only — NEVER down -v.
-
[ ] Step 1 — Deploy + baseline.
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. Dev-logindirector@obscura.local(:38080) →TOKEN;GET /api/v1/me→ assertenabled_modules == [ai, correspondence, esign, semantic, watermarking]. -
[ ] Step 2 — New metric series present.
curl -s :38080/metrics(the metrics endpoint is unauthenticated on the outer mux). Assert each of these appears at least once:obscura_db_pool_total_conns,obscura_db_pool_max_conns,obscura_db_pool_acquire_total,obscura_dependency_up{dep="postgres"},obscura_dependency_up{dep="minio"},obscura_dependency_up{dep="extract"},obscura_dependency_up{dep="embed"},obscura_dependency_up{dep="gotenberg"},obscura_rate_limit_rejected_total{limiter="user"},obscura_scheduler_runs_total(may be 0 until a task runs — trigger one below), and (if any AI usage exists)obscura_ai_tokens_total. Note:dependency_upappears once the first tick (≤15s) or first/readyzhits. -
[ ] Step 3 — Scheduler series populated. Trigger a scheduled task to prove the observer fires: the disposition reminder is triggered at boot, but to be deterministic call an admin trigger if available, or simply wait ~30s for the scheduler loop and re-
curl /metrics→ assertobscura_scheduler_runs_total{...,result="ok"}has a non-zero sample for at least one task andobscura_scheduler_run_duration_seconds_count> 0. -
[ ] Step 4 —
/readyz200 all-up.curl -s -o /tmp/ready.json -w '%{http_code}' :38080/readyz→200; body{"ready":true,"deps":{"postgres":"ok","minio":"ok","engine":"ok","gotenberg":"ok","extract":"ok","embed":"ok"}}(ai present only if configured with a base URL). Re-curl /metrics→ everyobscura_dependency_up{dep=...} == 1. -
[ ] Step 5 —
/readyz503 with a dep stopped.docker compose -f deploy/docker-compose.yml stop embed-sidecar(single-service pause — NOT down -v). Wait ~3s, thencurl -s -o /tmp/ready.json -w '%{http_code}' :38080/readyz→503; bodyready:falseanddeps.embedis a non-"ok" error string (e.g. connection refused). Re-curl /metrics→obscura_dependency_up{dep="embed"} == 0whilepostgres/miniostay1. Restore:docker compose -f deploy/docker-compose.yml start embed-sidecar; wait untilcurl :38080/readyz→200again (poll ≤30s). -
[ ] Step 6 — Cleanup + demo-intact assert. Confirm all services up (
docker compose -f deploy/docker-compose.yml ps— every servicerunning/healthy).GET /api/v1/me(dev-login) → 5 modules intact. Report which metric series and both/readyzcodes were observed. No commit unless an e2e-driven fix was needed (then commit it explicitly).
---
PHASE 2 — OpenTelemetry tracing (opt-in, air-gapped-safe)
Off by default. Spans are created + exported ONLY when OTEL_EXPORTER_OTLP_ENDPOINT is set. New Go deps land here.
Task 4: OTEL config + obs.SetupTracing + pgx tracer + db.Open option + wire shutdown
Files: go/go.mod, go/go.sum, go/internal/platform/config/config.go, go/internal/platform/obs/tracing.go (new), go/internal/platform/db/db.go, go/cmd/obscura-server/wire.go.
Interface produced (consumed by T5): the global OTel TracerProvider is installed (or no-op); obs.NewPgxTracer() for DB spans; cfg.OTEL.
- [ ] Step 1 — Add OpenTelemetry deps. From
go/, run (pin to a mutually-compatible set; use the latest patch of the v1 line these resolve to):
go get go.opentelemetry.io/otel@latest \
go.opentelemetry.io/otel/sdk@latest \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@latest
go mod tidy
go.opentelemetry.io/otel/trace, .../attribute, .../propagation, .../semconv/* are sub-packages of the otel/sdk modules (no extra go.mod line). Confirm go build ./... pulls them; the repo does NOT vendor. Commit go/go.mod + go/go.sum with Step 6.
- [ ] Step 2 — OTEL config (fail-closed only if endpoint set + malformed). In
go/internal/platform/config/config.go: add"strconv"to imports. Add the struct + aConfigfieldOTEL OTELConfig(place it next toLDAP LDAPConfig):
// OTELConfig configures opt-in OpenTelemetry tracing. Empty Endpoint = tracing OFF (the default,
// air-gapped-safe): no spans created or exported, nothing leaves the box. When set, spans export
// to the OTLP/HTTP collector at Endpoint.
type OTELConfig struct {
Endpoint string `env:"OTEL_EXPORTER_OTLP_ENDPOINT"`
Headers string `env:"OTEL_EXPORTER_OTLP_HEADERS"`
ServiceName string `env:"OTEL_SERVICE_NAME" envDefault:"obscura"`
Sampler string `env:"OTEL_TRACES_SAMPLER"`
SamplerArg string `env:"OTEL_TRACES_SAMPLER_ARG"`
}
In Validate(), before return nil, add if err := c.OTEL.validate(); err != nil { return err }, and add the helper:
func (o OTELConfig) validate() error {
if strings.TrimSpace(o.Endpoint) == "" {
return nil // tracing off — ignore the rest, never fail closed on an unset endpoint
}
if o.SamplerArg != "" {
if f, err := strconv.ParseFloat(o.SamplerArg, 64); err != nil || f < 0 || f > 1 {
return fmt.Errorf("config: invalid OTEL_TRACES_SAMPLER_ARG %q (want a ratio in [0,1])", o.SamplerArg)
}
}
switch o.Sampler {
case "", "always_on", "always_off", "traceidratio",
"parentbased_always_on", "parentbased_always_off", "parentbased_traceidratio":
default:
return fmt.Errorf("config: invalid OTEL_TRACES_SAMPLER %q", o.Sampler)
}
if o.Headers != "" && !strings.Contains(o.Headers, "=") {
return fmt.Errorf("config: invalid OTEL_EXPORTER_OTLP_HEADERS (want k=v[,k2=v2])")
}
return nil
}
- [ ] Step 3 —
obs.SetupTracing+ pgx tracer. Creatego/internal/platform/obs/tracing.go:
// Package obs wires opt-in OpenTelemetry tracing. It is a no-op unless an OTLP endpoint is
// configured, preserving Obscura's air-gapped-by-default posture: with no endpoint the global OTel
// TracerProvider stays the built-in no-op (zero span allocation, nothing leaves the box).
package obs
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
)
// Config is the parsed OTEL_* configuration (from platform/config). Endpoint empty = off.
type Config struct {
Endpoint string
Headers string
ServiceName string
Sampler string
SamplerArg string
}
func noopShutdown(context.Context) error { return nil }
// SetupTracing installs a global TracerProvider + W3C propagator when cfg.Endpoint is set, and
// returns a shutdown func that flushes pending spans on exit. When cfg.Endpoint is empty it
// installs nothing and returns a no-op shutdown. A setup error with an endpoint set is returned to
// the caller (which logs loudly and continues with the no-op provider) — tracing never blocks boot.
func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error, error) {
if strings.TrimSpace(cfg.Endpoint) == "" {
return noopShutdown, nil
}
var opts []otlptracehttp.Option
if strings.Contains(cfg.Endpoint, "://") {
opts = append(opts, otlptracehttp.WithEndpointURL(cfg.Endpoint))
if strings.HasPrefix(cfg.Endpoint, "http://") {
opts = append(opts, otlptracehttp.WithInsecure())
}
} else {
opts = append(opts, otlptracehttp.WithEndpoint(cfg.Endpoint), otlptracehttp.WithInsecure())
}
if h := parseHeaders(cfg.Headers); len(h) > 0 {
opts = append(opts, otlptracehttp.WithHeaders(h))
}
exp, err := otlptracehttp.New(ctx, opts...)
if err != nil {
return noopShutdown, fmt.Errorf("otel exporter: %w", err)
}
name := cfg.ServiceName
if name == "" {
name = "obscura"
}
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(name)))
if err != nil {
return noopShutdown, fmt.Errorf("otel resource: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
sdktrace.WithSampler(sampler(cfg.Sampler, cfg.SamplerArg)),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
return tp.Shutdown, nil
}
// sampler maps OTEL_TRACES_SAMPLER[/_ARG] to an sdktrace.Sampler. Default: parent-based, always
// sample the root (the OTel spec default).
func sampler(name, arg string) sdktrace.Sampler {
ratio := 1.0
if arg != "" {
if f, err := strconv.ParseFloat(arg, 64); err == nil {
ratio = f
}
}
switch name {
case "always_off":
return sdktrace.NeverSample()
case "always_on":
return sdktrace.AlwaysSample()
case "traceidratio":
return sdktrace.TraceIDRatioBased(ratio)
case "parentbased_always_off":
return sdktrace.ParentBased(sdktrace.NeverSample())
case "parentbased_traceidratio":
return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))
default: // "" or parentbased_always_on
return sdktrace.ParentBased(sdktrace.AlwaysSample())
}
}
func parseHeaders(s string) map[string]string {
out := map[string]string{}
for _, kv := range strings.Split(s, ",") {
kv = strings.TrimSpace(kv)
if kv == "" {
continue
}
if i := strings.IndexByte(kv, '='); i > 0 {
out[strings.TrimSpace(kv[:i])] = strings.TrimSpace(kv[i+1:])
}
}
return out
}
// --- pgx query tracer (DB spans) ---
type pgxSpanKey struct{}
type pgxTracer struct{ tracer trace.Tracer }
// NewPgxTracer returns a pgx.QueryTracer that emits one client span per query. Attach it to the
// pool ONLY when tracing is enabled (a nil tracer = zero overhead on the default air-gapped path).
func NewPgxTracer() pgx.QueryTracer { return &pgxTracer{tracer: otel.Tracer("obscura/pgx")} }
func (t *pgxTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context {
ctx, span := t.tracer.Start(ctx, "pg.query", trace.WithSpanKind(trace.SpanKindClient))
stmt := data.SQL
if len(stmt) > 512 {
stmt = stmt[:512]
}
span.SetAttributes(attribute.String("db.system", "postgresql"), attribute.String("db.statement", stmt))
return context.WithValue(ctx, pgxSpanKey{}, span)
}
func (t *pgxTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) {
span, ok := ctx.Value(pgxSpanKey{}).(trace.Span)
if !ok {
return
}
if data.Err != nil {
span.RecordError(data.Err)
}
span.End()
}
(If go mod tidy pulled a semconv version other than v1.26.0, adjust the semconv "…/semconv/vX.Y.Z" import to the version present under the module cache — semconv.ServiceName is stable across recent versions.)
- [ ] Step 4 —
db.Openvariadic option + tracer. Ingo/internal/platform/db/db.go, add aboveOpen:
// Option configures Open.
type Option func(*pgxpool.Config)
// WithQueryTracer attaches a pgx QueryTracer to every pooled connection (DB spans). A nil tracer is
// a no-op, so the default (tracing-off) path pays nothing.
func WithQueryTracer(t pgx.QueryTracer) Option {
return func(c *pgxpool.Config) {
if t != nil {
c.ConnConfig.Tracer = t
}
}
}
Replace Open with a ParseConfig + NewWithConfig form that applies options:
// Open connects and verifies the pool. Options (e.g. WithQueryTracer) tune the pool config before
// connect. The variadic signature keeps existing db.Open(ctx, dsn) callers (incl. tests) valid.
func Open(ctx context.Context, dsn string, opts ...Option) (*DB, error) {
poolCfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("db parse config: %w", err)
}
for _, o := range opts {
o(poolCfg)
}
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("db open: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("db ping: %w", err)
}
return &DB{pool: pool}, nil
}
pgx is already imported (github.com/jackc/pgx/v5); pgx.QueryTracer is in that package.
- [ ] Step 5 — Wire tracing setup + tracer + shutdown in wire.go. In
go/cmd/obscura-server/wire.go, add importsobs "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/obs"and"github.com/jackc/pgx/v5". At the TOP ofrun(beforedatabase, err := db.Open(...)at:106), insert:
// Opt-in OpenTelemetry tracing. OFF by default (no OTEL endpoint) → a no-op provider: nothing
// leaves the box. A setup error with an endpoint set is logged loudly; we continue with the
// no-op provider so a misconfigured collector never blocks boot.
traceShutdown, err := obs.SetupTracing(ctx, obs.Config{
Endpoint: cfg.OTEL.Endpoint,
Headers: cfg.OTEL.Headers,
ServiceName: cfg.OTEL.ServiceName,
Sampler: cfg.OTEL.Sampler,
SamplerArg: cfg.OTEL.SamplerArg,
})
if err != nil {
logger.Error("tracing setup failed; continuing without tracing", "err", err)
}
defer func() {
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = traceShutdown(sctx)
}()
var queryTracer pgx.QueryTracer
if cfg.OTEL.Endpoint != "" {
queryTracer = obs.NewPgxTracer()
}
Then change the DB open line (:106) to pass the tracer:
database, err := db.Open(ctx, cfg.DatabaseURL, db.WithQueryTracer(queryTracer))
Leave the existing if err != nil { … } / defer database.Close() lines as-is.
- [ ] Step 6 — Verify + commit.
cd go && go build ./... && go vet ./.... Commit:
git add go/go.mod go/go.sum go/internal/platform/config/config.go go/internal/platform/obs/tracing.go go/internal/platform/db/db.go go/cmd/obscura-server/wire.go
git commit -m "feat(tracing): opt-in OTel setup (no-op by default) + pgx query tracer + db.Open option"
Task 5: HTTP request span middleware + trace_id/span_id in the slog request log
Files: go/internal/httpapi/tracing.go (new), go/internal/httpapi/server.go, go/internal/httpapi/logging.go.
- [ ] Step 1 — HTTP span middleware. Create
go/internal/httpapi/tracing.go:
package httpapi
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
// tracing starts one server span per request. When tracing is disabled the global tracer is a
// no-op (a non-recording span with an invalid SpanContext), so this costs almost nothing. It runs
// OUTSIDE requestLog so the span id is already in ctx when the access line is written. The span is
// (re)named with the bounded chi route PATTERN — resolved only after routing — to keep span-name
// cardinality flat, mirroring the RED metrics.
func (s *Server) tracing(next http.Handler) http.Handler {
tracer := otel.Tracer("obscura/http")
prop := otel.GetTextMapPropagator()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := prop.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
ctx, span := tracer.Start(ctx, r.Method, trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
r = r.WithContext(ctx)
next.ServeHTTP(ww, r)
route := chi.RouteContext(ctx).RoutePattern()
if route == "" {
route = "other"
}
span.SetName(r.Method + " " + route)
span.SetAttributes(
attribute.String("http.request.method", r.Method),
attribute.String("http.route", route),
attribute.Int("http.response.status_code", ww.Status()),
)
if p, ok := PrincipalFrom(ctx); ok {
span.SetAttributes(attribute.String("user.id", string(p.UserID)))
}
})
}
- [ ] Step 2 — Insert the middleware. In
go/internal/httpapi/server.goHandler(), adds.tracingbetweenRequestIDandrequestLogso the span id is in ctx for logging:
r.Use(middleware.RequestID)
r.Use(s.tracing)
r.Use(requestLog(s.logger))
r.Use(s.metrics.instrument)
r.Use(middleware.Recoverer)
- [ ] Step 3 — Inject
trace_id/span_idinto the request log. Ingo/internal/httpapi/logging.go, add"go.opentelemetry.io/otel/trace"to imports and rewrite thelogger.LogAttrs(...)call to build a slice and append trace ids only when the span context is valid (tracing off ⇒ invalid ⇒ no extra fields):
attrs := []slog.Attr{
slog.String("method", r.Method),
slog.String("route", route),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int64("latency_ms", time.Since(start).Milliseconds()),
slog.String("request_id", reqID),
slog.String("remote", r.RemoteAddr),
}
if sc := trace.SpanContextFromContext(r.Context()); sc.IsValid() {
attrs = append(attrs,
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
logger.LogAttrs(r.Context(), slog.LevelInfo, "http_request", attrs...)
- [ ] Step 4 — Verify + commit.
cd go && go build ./... && go vet ./.... Commit:
git add go/internal/httpapi/tracing.go go/internal/httpapi/server.go go/internal/httpapi/logging.go
git commit -m "feat(tracing): HTTP server span middleware + trace_id/span_id log correlation"
Task 6 (controller-driven e2e — NOT a subagent): tracing off-by-default + on-with-collector
Prereqs: T4–T5 committed + building. Uses a throwaway docker run collector removed with docker rm -f. Ends with tracing OFF.
-
[ ] Step 1 — Deploy (tracing off) + baseline. Deploy from repo root (no
OTEL_*indeploy/mekari.env). Dev-login →TOKEN;GET /api/v1/me→ 5 modules. Drive a few requests.docker logs deploy-obscura-1 2>&1 | grep http_request | tail -1→ assert the line has notrace_idfield (no-op provider ⇒ invalid span context).curl -s :38080/metrics | grep -c obscura_http_requests_total→ non-zero (metrics still serve with tracing off). This is the air-gapped default assertion. -
[ ] Step 2 — Start a throwaway OTLP collector on the compose network. Write a minimal collector config exposing an OTLP/HTTP receiver + a debug exporter, then run it joined to the obscura compose network (find it via
docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{end}}' deploy-obscura-1, typicallydeploy_default):
cat > /tmp/otelcol.yaml <<'EOF'
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
exporters: [debug]
EOF
docker run -d --name otelcol --network <compose-net> -v /tmp/otelcol.yaml:/etc/otelcol/config.yaml otel/opentelemetry-collector:latest
-
[ ] Step 3 — Redeploy obscura with the OTEL endpoint set. Temporarily append to
deploy/mekari.env:OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318, thendocker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d obscura(recreates just obscura). Dev-login; drive several authed requests (GET /api/v1/me, list documents, etc.). -
[ ] Step 4 — Assert spans + log correlation. Primary:
docker logs deploy-obscura-1 2>&1 | grep http_request | tail -3→ each line now has atrace_idandspan_id(a real recording provider is installed). Secondary (export):docker logs otelcol 2>&1 | grep -iE 'Span|Trace ID' | head→ server spans (nameGET /api/v1/me) andpg.queryclient spans appear. Assert/api/v1/mestill returns 5 modules (tracing does not change behavior). -
[ ] Step 5 — Revert to off + cleanup. Remove the
OTEL_EXPORTER_OTLP_ENDPOINTline fromdeploy/mekari.env;docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d obscura(tracing off again).docker rm -f otelcol;rm -f /tmp/otelcol.yaml. Re-assert:docker logs deploy-obscura-1 | grep http_request | tail -1→ notrace_id;GET /api/v1/me→ 5 modules;curl :38080/metricsstill serves. Report the two log states + whether collector spans were seen. No commit unless an e2e fix was needed.
---
PHASE 3 — Admin-configurable rate limits
Migration 00086 (seeded with current consts), a tiny ratelimit context, live-apply via atomic.Pointer, admin API + OpenAPI + gen:api, and the Admin → Observability tab.
Task 7: migration 00086 + ratelimit domain/store + live-apply + admin API + OpenAPI
Files: go/migrations/00086_rate_limit_settings.sql (new), go/internal/ratelimit/domain/ratelimit.go (new), go/internal/ratelimit/adapters/pg.go (new), go/internal/httpapi/handlers_ratelimit.go (new), go/internal/httpapi/ratelimit.go, go/internal/httpapi/handlers_public_sign.go, go/internal/httpapi/server.go, go/cmd/obscura-server/wire.go, api/openapi.yaml, web/src/api/schema.ts.
- [ ] Step 1 — Migration
00086. Creatego/migrations/00086_rate_limit_settings.sql:
-- +goose Up
-- Admin-tunable rate limits (singleton row id=1). Seeded with today's hardcoded constants so
-- behavior is byte-unchanged until an admin edits. The in-memory limiters read these live via an
-- atomic.Pointer refreshed on save (no restart). A safety floor is enforced in app validation so an
-- admin can't lock everyone (including themselves) out, and the auth-IP brute-force floor can't be
-- disabled to 0. public_sign_per_min governs the OTP-SEND budget (the abuse-sensitive one shown in
-- the UI); the OTP-submit budget stays a fixed constant.
CREATE TABLE rate_limit_settings (
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
user_per_min int NOT NULL DEFAULT 600,
auth_ip_per_min int NOT NULL DEFAULT 60,
tsa_per_min int NOT NULL DEFAULT 600,
public_sign_per_min int NOT NULL DEFAULT 5,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO rate_limit_settings (id) VALUES (1);
-- +goose Down
DROP TABLE rate_limit_settings;
- [ ] Step 2 — Domain. Create
go/internal/ratelimit/domain/ratelimit.go:
// Package domain holds the rate-limit configuration value type shared by the httpapi limiters and
// the admin editor. The four limits mirror the four in-memory limiters; per-limit safety floors
// prevent an admin from locking everyone out.
package domain
import "fmt"
// Config is the live rate-limit configuration (requests per minute per key).
type Config struct {
UserPerMin int // protected API, per authed user
AuthIPPerMin int // unauthenticated credential routes, per client IP (brute-force floor)
TSAPerMin int // RFC3161 TSA, per external client IP (loopback self-call exempt)
PublicSignPerMin int // public OTP-SEND budget, per token+IP
}
// Per-limit safety floors + an upper ceiling. user/tsa are floored generously so the SPA and the
// signer keep working; auth-IP keeps a meaningful brute-force minimum; public-sign's floor is 1
// (its default is 5 and it is not an admin-lockout vector).
const (
FloorUser = 60
FloorAuthIP = 10
FloorTSA = 30
FloorPublicSign = 1
Ceil = 100000
)
// Defaults returns the seeded constants (matches migration 00086 + the pre-existing consts).
func Defaults() Config {
return Config{UserPerMin: 600, AuthIPPerMin: 60, TSAPerMin: 600, PublicSignPerMin: 5}
}
// Validate enforces the safety floors + ceiling. A rejected save returns a clear, admin-facing error.
func (c Config) Validate() error {
for _, f := range []struct {
name string
val int
floor int
}{
{"user_per_min", c.UserPerMin, FloorUser},
{"auth_ip_per_min", c.AuthIPPerMin, FloorAuthIP},
{"tsa_per_min", c.TSAPerMin, FloorTSA},
{"public_sign_per_min", c.PublicSignPerMin, FloorPublicSign},
} {
if f.val < f.floor {
return fmt.Errorf("%s must be at least %d (safety floor to prevent lockout)", f.name, f.floor)
}
if f.val > Ceil {
return fmt.Errorf("%s must be at most %d", f.name, Ceil)
}
}
return nil
}
- [ ] Step 3 — Store. Create
go/internal/ratelimit/adapters/pg.go:
// Package adapters persists the singleton rate_limit_settings row.
package adapters
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
"github.com/Virtue-Digital-Indonesia/obscura/internal/ratelimit/domain"
)
// Store reads/writes the singleton rate_limit_settings row.
type Store struct{ db *db.DB }
// NewStore builds the rate-limit store.
func NewStore(d *db.DB) *Store { return &Store{db: d} }
// Load reads the singleton config, falling back to seeded defaults when the row is missing or
// out-of-range (a corrupt row must never brick the limiters).
func (s *Store) Load(ctx context.Context) (domain.Config, error) {
var c domain.Config
err := s.db.Exec(ctx).QueryRow(ctx,
`SELECT user_per_min, auth_ip_per_min, tsa_per_min, public_sign_per_min FROM rate_limit_settings WHERE id = 1`).
Scan(&c.UserPerMin, &c.AuthIPPerMin, &c.TSAPerMin, &c.PublicSignPerMin)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Defaults(), nil
}
if err != nil {
return domain.Defaults(), fmt.Errorf("rate limit load: %w", err)
}
if c.Validate() != nil {
return domain.Defaults(), nil
}
return c, nil
}
// Save writes the singleton config.
func (s *Store) Save(ctx context.Context, c domain.Config) error {
if _, err := s.db.Exec(ctx).Exec(ctx,
`UPDATE rate_limit_settings
SET user_per_min = $1, auth_ip_per_min = $2, tsa_per_min = $3, public_sign_per_min = $4, updated_at = now()
WHERE id = 1`,
c.UserPerMin, c.AuthIPPerMin, c.TSAPerMin, c.PublicSignPerMin); err != nil {
return fmt.Errorf("rate limit save: %w", err)
}
return nil
}
- [ ] Step 4 — Server: store port + live config pointer + seed. In
go/internal/httpapi/server.go: add importratelimitdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/ratelimit/domain". Add the port (near theDepsstruct):
// RateLimitStore persists the admin-tunable rate limits (Phase 3). *ratelimitadapters.Store
// satisfies it.
type RateLimitStore interface {
Load(ctx context.Context) (ratelimitdomain.Config, error)
Save(ctx context.Context, c ratelimitdomain.Config) error
}
Add to Deps (after Config Config):
RateLimitStore RateLimitStore
RateLimits ratelimitdomain.Config // seed loaded at boot (wire.go has ctx)
Add to Server (after lic atomic.Pointer[licenseState]):
rateLimitStore RateLimitStore
limits atomic.Pointer[ratelimitdomain.Config]
In NewServer, before return s, seed the pointer:
s.rateLimitStore = d.RateLimitStore
seed := d.RateLimits
if seed == (ratelimitdomain.Config{}) {
seed = ratelimitdomain.Defaults()
}
s.limits.Store(&seed)
Add the two admin routes next to the LDAP/SCIM admin routes (:637-646):
r.With(s.requirePerm("rbac.admin")).Get("/admin/rate-limits", s.GetRateLimits)
r.With(s.requirePerm("rbac.admin")).Put("/admin/rate-limits", s.PutRateLimits)
- [ ] Step 5 — Limiters read the live config. In
go/internal/httpapi/ratelimit.go: add importratelimitdomain "…/internal/ratelimit/domain". ChangerateLimitfrommax intto a live selector:
func (s *Server) rateLimit(rl *rateLimiter, name string, keyFn func(*http.Request) (string, bool), pick func(ratelimitdomain.Config) int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, limited := keyFn(r)
if limited {
max := pick(*s.limits.Load())
if !rl.allow(key, max, time.Now()) {
s.metrics.RecordRateLimitRejected(name)
w.Header().Set("Retry-After", strconv.Itoa(int(rl.window.Seconds())))
writeProblem(w, &kernel.Error{Kind: kernel.ErrRateLimited, Code: "api.rate_limited", Message: "too many requests — please slow down"})
return
}
}
next.ServeHTTP(w, r)
})
}
}
Update the three call sites in server.go:
authRL := s.rateLimit(authLimiter, "auth_ip", ipKeyFn, func(c ratelimitdomain.Config) int { return c.AuthIPPerMin })
tsaRL := s.rateLimit(tsaLimiter, "tsa", tsaKeyFn, func(c ratelimitdomain.Config) int { return c.TSAPerMin })
r.Use(s.rateLimit(userLimiter, "user", principalKeyFn, func(c ratelimitdomain.Config) int { return c.UserPerMin }))
The apiRate*Max consts stay in ratelimit.go (still document the seeded defaults; unused package-level consts are allowed). In go/internal/httpapi/handlers_public_sign.go, change the SEND deny to read the live budget (submit stays on pubSignSubmitMax):
if !pubSignLimiter.allow("send|"+tokenHash+"|"+clientIP(r), s.limits.Load().PublicSignPerMin, time.Now()) {
- [ ] Step 6 — Admin handlers. Create
go/internal/httpapi/handlers_ratelimit.go:
package httpapi
import (
"encoding/json"
"net/http"
"github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
ratelimitdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/ratelimit/domain"
)
// rateLimitLimits is the four editable limits (shared by the request body + the view).
type rateLimitLimits struct {
UserPerMin int `json:"user_per_min"`
AuthIPPerMin int `json:"auth_ip_per_min"`
TSAPerMin int `json:"tsa_per_min"`
PublicSignPerMin int `json:"public_sign_per_min"`
}
// rateLimitView is GET/PUT /admin/rate-limits: current limits, the enforced floors, and live
// rejection counts per limiter (from the Phase-1 metric).
type rateLimitView struct {
Limits rateLimitLimits `json:"limits"`
Floors rateLimitLimits `json:"floors"`
Rejections map[string]int64 `json:"rejections"`
}
func (s *Server) rateLimitView(c ratelimitdomain.Config) rateLimitView {
return rateLimitView{
Limits: rateLimitLimits{c.UserPerMin, c.AuthIPPerMin, c.TSAPerMin, c.PublicSignPerMin},
Floors: rateLimitLimits{ratelimitdomain.FloorUser, ratelimitdomain.FloorAuthIP, ratelimitdomain.FloorTSA, ratelimitdomain.FloorPublicSign},
Rejections: s.metrics.RateLimitRejections(),
}
}
// GetRateLimits returns the current limits + floors + live rejection counts.
func (s *Server) GetRateLimits(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.rateLimitView(*s.limits.Load()))
}
// PutRateLimits validates against the safety floor, persists, and live-applies via the atomic
// pointer (no restart). A too-low value returns 400 with a clear message.
func (s *Server) PutRateLimits(w http.ResponseWriter, r *http.Request) {
var body rateLimitLimits
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "ratelimit.invalid", Message: "invalid request body"})
return
}
cfg := ratelimitdomain.Config{
UserPerMin: body.UserPerMin, AuthIPPerMin: body.AuthIPPerMin,
TSAPerMin: body.TSAPerMin, PublicSignPerMin: body.PublicSignPerMin,
}
if err := cfg.Validate(); err != nil {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "ratelimit.floor", Message: err.Error()})
return
}
if err := s.rateLimitStore.Save(r.Context(), cfg); err != nil {
writeProblem(w, err)
return
}
s.limits.Store(&cfg) // live-apply
writeJSON(w, http.StatusOK, s.rateLimitView(cfg))
}
- [ ] Step 7 — Wire the store + seed. In
go/cmd/obscura-server/wire.go, add importsratelimitadapters "…/internal/ratelimit/adapters"andratelimitdomain "…/internal/ratelimit/domain". Beforemetrics := httpapi.NewMetrics()(or anywhere afterdatabaseexists), add:
rateLimitStore := ratelimitadapters.NewStore(database)
rateLimits, err := rateLimitStore.Load(ctx)
if err != nil {
logger.Warn("rate-limit settings load failed; using defaults", "err", err)
rateLimits = ratelimitdomain.Defaults()
}
Add to the httpapi.Deps{…} literal (:429-475):
RateLimitStore: rateLimitStore,
RateLimits: rateLimits,
- [ ] Step 8 — OpenAPI + gen:api. In
api/openapi.yaml, add the path near the LDAP admin paths (:5460):
/api/v1/admin/rate-limits:
get:
operationId: getRateLimits
summary: Current rate limits + floors + live rejection counts
description: The admin-tunable in-memory rate limits, their safety floors, and live rejection counts per limiter. Requires rbac.admin.
tags: [admin]
responses:
'200':
description: Rate-limit settings view.
content:
application/json:
schema:
$ref: '#/components/schemas/RateLimitView'
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
put:
operationId: putRateLimits
summary: Update the rate limits
description: Set new per-minute limits (validated against the safety floor; too-low values are rejected). Applied live without a restart. Requires rbac.admin.
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RateLimitLimits'
responses:
'200':
description: The stored settings view.
content:
application/json:
schema:
$ref: '#/components/schemas/RateLimitView'
'400':
$ref: '#/components/responses/Problem'
'401':
$ref: '#/components/responses/Problem'
'403':
$ref: '#/components/responses/Problem'
Add the schemas under components.schemas (mirror the Limits/Floors/Rejections shape):
RateLimitLimits:
type: object
required: [user_per_min, auth_ip_per_min, tsa_per_min, public_sign_per_min]
properties:
user_per_min: { type: integer }
auth_ip_per_min: { type: integer }
tsa_per_min: { type: integer }
public_sign_per_min: { type: integer }
RateLimitView:
type: object
required: [limits, floors, rejections]
properties:
limits: { $ref: '#/components/schemas/RateLimitLimits' }
floors: { $ref: '#/components/schemas/RateLimitLimits' }
rejections:
type: object
additionalProperties: { type: integer, format: int64 }
Then from web/: npm run gen:api (regenerates web/src/api/schema.ts).
- [ ] Step 9 — Verify + commit.
cd go && go build ./... && go vet ./...;cd web && npx tsc --noEmit && npx vite build. Commit:
git add go/migrations/00086_rate_limit_settings.sql go/internal/ratelimit/domain/ratelimit.go go/internal/ratelimit/adapters/pg.go go/internal/httpapi/handlers_ratelimit.go go/internal/httpapi/ratelimit.go go/internal/httpapi/handlers_public_sign.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(ratelimit): admin-tunable limits (migration 00086) + live-apply + GET/PUT /admin/rate-limits"
Task 8: Web — Admin → Observability tab (rate-limit editors + rejection counts + dependency strip)
Files: web/src/features/admin/data.ts, web/src/features/admin/ObservabilityTab.tsx (new), web/src/features/admin/AdminPage.tsx, web/src/features/admin/i18n.ts, web/src/styles/app.css.
- [ ] Step 1 — Data hooks. In
web/src/features/admin/data.ts, append (types come from generatedschema.ts— reference theRateLimitLimitscomponent type; use the sameapi/okhelpers already imported):
import type { components } from '@/api/schema'
type RateLimitLimits = components['schemas']['RateLimitLimits']
export function useRateLimits() {
return useQuery({
queryKey: ['admin', 'rate-limits'],
queryFn: () => ok(api.GET('/api/v1/admin/rate-limits', {})),
refetchInterval: 5000, // live rejection counts
})
}
export function useSaveRateLimits() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: RateLimitLimits) => api.PUT('/api/v1/admin/rate-limits', { body }).then(ok),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'rate-limits'] }),
})
}
// /readyz is on the OUTER mux (not /api/v1), so it isn't in the typed client — fetch it directly.
// It returns 200 (all up) or 503 (a dep down) with the same JSON body either way.
export function useReadyz() {
return useQuery({
queryKey: ['readyz'],
queryFn: async () => {
const res = await fetch('/readyz')
return (await res.json()) as { ready: boolean; deps: Record<string, string> }
},
refetchInterval: 10000,
})
}
(If import type { components } already exists at the top of data.ts, reuse it rather than re-importing.)
- [ ] Step 2 — ObservabilityTab. Create
web/src/features/admin/ObservabilityTab.tsx:
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, NumberInput, InlineNotification } from '@carbon/react'
import { StatusTag } from '@/components/StatusTag'
import { useRateLimits, useSaveRateLimits, useReadyz } from './data'
type Limits = { user_per_min: number; auth_ip_per_min: number; tsa_per_min: number; public_sign_per_min: number }
const FIELDS: Array<{ key: keyof Limits; labelKey: string }> = [
{ key: 'user_per_min', labelKey: 'admin.observability.limits.user' },
{ key: 'auth_ip_per_min', labelKey: 'admin.observability.limits.authIp' },
{ key: 'tsa_per_min', labelKey: 'admin.observability.limits.tsa' },
{ key: 'public_sign_per_min', labelKey: 'admin.observability.limits.publicSign' },
]
const REJECT_KEYS: Array<{ metric: string; field: keyof Limits }> = [
{ metric: 'user', field: 'user_per_min' },
{ metric: 'auth_ip', field: 'auth_ip_per_min' },
{ metric: 'tsa', field: 'tsa_per_min' },
{ metric: 'public_sign', field: 'public_sign_per_min' },
]
export function ObservabilityTab() {
const { t } = useTranslation()
const { data, isError } = useRateLimits()
const save = useSaveRateLimits()
const ready = useReadyz()
const [draft, setDraft] = useState<Limits | null>(null)
const [err, setErr] = useState<string | null>(null)
// Seed the editable draft from the server once loaded (and whenever a save round-trips).
useEffect(() => {
if (data?.limits) setDraft(data.limits as Limits)
}, [data?.limits])
const floors = (data?.floors ?? {}) as Limits
const rejections = (data?.rejections ?? {}) as Record<string, number>
const onSave = () => {
if (!draft) return
setErr(null)
save.mutate(draft, { onError: (e) => setErr((e as Error).message) })
}
return (
<div className="admin-split__main">
<p className="page__lead muted">{t('admin.observability.lead')}</p>
{isError && <p className="muted">{t('admin.loadError')}</p>}
{/* Dependency-health strip (read-only, mirrors /readyz). */}
<h3 className="obs-heading">{t('admin.observability.deps.title')}</h3>
<div className="obs-deps">
{Object.entries(ready.data?.deps ?? {}).map(([dep, status]) => (
<div key={dep} className="obs-deps__item">
<StatusTag tone={status === 'ok' ? 'success' : 'error'} label={dep} />
{status !== 'ok' && <span className="muted mono">{status}</span>}
</div>
))}
{!ready.data && <span className="muted">{t('admin.observability.deps.loading')}</span>}
</div>
{/* Rate-limit editors. */}
<h3 className="obs-heading">{t('admin.observability.limits.title')}</h3>
<p className="muted">{t('admin.observability.limits.hint')}</p>
{err && <InlineNotification kind="error" lowContrast title={err} onCloseButtonClick={() => setErr(null)} />}
{draft && (
<div className="obs-limits">
{FIELDS.map((f) => (
<NumberInput
key={f.key}
id={`rl-${f.key}`}
label={t(f.labelKey)}
helperText={t('admin.observability.limits.floor', { n: floors[f.key] ?? 1 })}
min={floors[f.key] ?? 1}
value={draft[f.key]}
onChange={(_e, { value }) => setDraft({ ...draft, [f.key]: Number(value) })}
/>
))}
</div>
)}
<div className="obs-actions">
<Button onClick={onSave} disabled={!draft || save.isPending}>{t('admin.observability.limits.save')}</Button>
{save.isSuccess && !err && <span className="muted">{t('admin.observability.limits.saved')}</span>}
</div>
{/* Live rejection counts (from the Phase-1 metric). */}
<h3 className="obs-heading">{t('admin.observability.rejections.title')}</h3>
<div className="obs-rejections">
{REJECT_KEYS.map((r) => (
<div key={r.metric} className="obs-rejections__item">
<span>{t(FIELDS.find((f) => f.key === r.field)!.labelKey)}</span>
<span className="mono">{rejections[r.metric] ?? 0}</span>
</div>
))}
</div>
</div>
)
}
(Verify the StatusTag prop names against @/components/StatusTag — the AdminPage imports StatusTag, type StatusTone; use the same tone/label props it exposes, adjusting if the component takes children instead of a label prop.)
- [ ] Step 3 — Register the tab. In
web/src/features/admin/AdminPage.tsx: addimport { ObservabilityTab } from './ObservabilityTab'next to the other tab imports (:47). Add a<Tab>as the LAST tab (after the LDAP<Tab>at:117):
<Tab>{t('admin.tabs.observability')}</Tab>
Add a matching <TabPanel> as the LAST panel (after the LDAP <TabPanel> at :237-239):
{/* Observability ---------------------------------------------- */}
<TabPanel>
<ObservabilityTab />
</TabPanel>
- [ ] Step 4 — i18n (en + id). In
web/src/features/admin/i18n.ts, addobservability: 'Observability'to theentabsgroup andobservability: 'Observabilitas'to theidtabsgroup. Add anobservabilitycopy group to BOTHenandid(identical shape), e.g. foren:
observability: {
lead: 'Operational health: dependency status, request rate limits, and live rejection counts. Metrics are exposed at /metrics for a local Prometheus scrape; tracing is opt-in via the OTEL_* environment variables.',
deps: { title: 'Dependency health', loading: 'Checking…' },
limits: {
title: 'Rate limits',
hint: 'Requests per minute per key. Lowering a limit takes effect immediately (no restart). A safety floor prevents locking users — or yourself — out.',
user: 'Authenticated API (per user)',
authIp: 'Login attempts (per IP)',
tsa: 'Timestamp requests (per IP)',
publicSign: 'Public OTP sends (per link/IP)',
floor: 'Minimum {{n}}',
save: 'Save limits',
saved: 'Saved',
},
rejections: { title: 'Rejections (since start)' },
},
For id, mirror exactly with Indonesian copy (keep the same keys + {{n}} placeholder). After editing, npx tsc --noEmit; if smart quotes broke the file, rewrite it whole with Write.
- [ ] Step 5 — CSS. In
web/src/styles/app.css, near the.ldap-map__addblock, add:
.obs-heading { margin-top: 1.5rem; margin-bottom: 0.5rem; }
.obs-deps { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.5rem; }
.obs-deps__item { display: flex; align-items: center; gap: 0.5rem; }
.obs-limits { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.obs-actions { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.obs-rejections { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.5rem; }
.obs-rejections__item { display: flex; justify-content: space-between; padding: 0.25rem 0; border-bottom: 1px solid var(--cds-border-subtle); }
- [ ] Step 6 — Verify + commit.
cd web && npx tsc --noEmit && npx vite build. Commit:
git add web/src/features/admin/data.ts web/src/features/admin/ObservabilityTab.tsx web/src/features/admin/AdminPage.tsx web/src/features/admin/i18n.ts web/src/styles/app.css
git commit -m "feat(web): Admin → Observability tab (rate-limit editors + rejection counts + dependency strip)"
Task 9 (controller-driven e2e — NOT a subagent): rate-limit live-apply + floor + demo intact
Prereqs: T7–T8 committed + building. Ends with rate_limit_settings at defaults + tracing off.
-
[ ] Step 1 — Deploy + baseline. Deploy from repo root (migration
00086runs on boot → row seeded 600/60/600/5). Dev-login →TOKEN;GET /api/v1/me→ 5 modules.GET /api/v1/admin/rate-limits→limits {user_per_min:600, auth_ip_per_min:60, tsa_per_min:600, public_sign_per_min:5},floors {user_per_min:60, auth_ip_per_min:10, tsa_per_min:30, public_sign_per_min:1},rejections {...}. -
[ ] Step 2 — Floor rejects a too-low value.
PUT /api/v1/admin/rate-limitswith{"user_per_min":5,"auth_ip_per_min":60,"tsa_per_min":600,"public_sign_per_min":5}→400with a message naminguser_per_min+ the floor (60). ConfirmGETstill showsuser_per_min:600(unchanged). -
[ ] Step 3 — Lower a limit → 429 observed.
PUT{"user_per_min":60,"auth_ip_per_min":60,"tsa_per_min":600,"public_sign_per_min":5}(60 = the floor, applied live). Immediately hammer the protected API as the director: fire ~75 rapidGET /api/v1/mewith the same token in one minute; assert at least one returns429(Retry-After header set) andGET /api/v1/admin/rate-limitsshowsrejections.userincremented.curl :38080/metrics | grep 'obscura_rate_limit_rejected_total{limiter="user"}'→ non-zero. -
[ ] Step 4 — Raise → recovers.
PUTback to{"user_per_min":600,...}. Wait for the current minute window to roll (≤60s), then fire ~20GET /api/v1/me→ all200(no 429). Confirms live-apply without restart. -
[ ] Step 5 — Tracing off by default + modules intact.
docker logs deploy-obscura-1 | grep http_request | tail -1→ notrace_id(Phase-2 default).GET /api/v1/me→ 5 modules.curl :38080/metricsstill serves. -
[ ] Step 6 — Cleanup + demo-intact assert. Reset the singleton to seeded defaults:
PUT /api/v1/admin/rate-limits {"user_per_min":600,"auth_ip_per_min":60,"tsa_per_min":600,"public_sign_per_min":5}(ordocker exec deploy-postgres-1 psql -U obscura -d obscura -c "UPDATE rate_limit_settings SET user_per_min=600, auth_ip_per_min=60, tsa_per_min=600, public_sign_per_min=5 WHERE id=1;"). Re-GET /api/v1/admin/rate-limits→ defaults.GET /api/v1/me→ 5 modules. Report the 400 (floor), the 429 (throttle), the recovery, and demo-intact. No commit unless an e2e fix was needed.
Self-review notes (done at plan time)
- Spec coverage:
- §Phase 1 new series (DB pool custom collector reading
pool.Stat();dependency_up{dep};scheduler_runs_total{task,result}+scheduler_run_duration_seconds{task};rate_limit_rejected_total{limiter}on all four limiters;ai_tokens_total{feature,direction}scrape-time collector) → T1 (metrics.gofields +metrics_collectors.go+ schedulerSetObserver+rateLimitname/metric + public-sign bumps + wire registration). §Deep/readyz(DependencyProbe, per-dep ~2s timeout, postgres/minio/extract/embed/ai/gotenberg/engine,200 {ready,deps}/503, same probe feeds gauges + a periodic tick,/healthzstays static) → T2. §Phase-1 testing (curl/metricsasserts new series;/readyz200 all-up + 503 with adocker compose stopsidecar, then restart) → T3. - §Phase 2 (off by default; spans only when
OTEL_EXPORTER_OTLP_ENDPOINTset; no-op provider otherwise; OTLP/HTTP exporter;OTEL_SERVICE_NAME/OTEL_TRACES_SAMPLER[/_ARG]/OTEL_EXPORTER_OTLP_HEADERS; HTTP server span middleware w/ route+method+status+user attrs; pgxQueryTracerDB spans on the pool;trace_id/span_idin the slog request log;obs.SetupTracing(cfg)(shutdown,err)wired inmain; fail-closed only if endpoint set + malformed; shutdown flushes on exit) → T4 (config +obspackage +db.Openoption + wire) + T5 (HTTP middleware + log correlation). §Phase-2 testing (off ⇒ no spans +/metricsserves; on with a throwawayotel/opentelemetry-collectordebug receiver ⇒ spans ORtrace_idin logs; revert) → T6. - §Phase 3 (
rate_limit_settingssingleton id=1 CHECK, columns per-limit, seeded with today's constants; live-apply viaatomic.Pointer[rateLimitConfig]on save, no restart; safety floor validation preventing lockout + a sane auth-IP min;GET/PUT /api/v1/admin/rate-limitsrbac.admin+ OpenAPI + gen:api; Admin → Observability tab with NumberInputs+floor, live rejection counts, read-only dependency-health strip mirroring/readyz, en/id) → T7 (migration 00086 +ratelimitdomain/adapters + live pointer + handlers + OpenAPI) + T8 (web). §Phase-3 testing (PUT lowers a limit → 429 → raise → recovers; floor rejects too-low; 5 modules intact; tracing off) → T9. - §Error handling (readyz bounded per-dep timeout, a dep error marks it down never panics; tracing setup failure with endpoint set logs loudly + falls back to no-op, never blocks boot; rate-limit save validates + rejects out-of-range clearly; malformed stored row → seeded defaults) →
DependencyProbe.runper-checkcontext.WithTimeout(T2);SetupTracingreturns err → wire logs + continues with no-op (T4/T5);Config.Validate+Store.Loadfallback (T7). §Out-of-scope (metrics push/remote-write, log shipping, per-route custom limits, OTel metrics/logs signals, a bundled collector, alerting) → not built. - Type consistency:
ratelimit/domain.Config(+ floors/Defaults/Validate) ↔ratelimit/adapters.Store(Load/Save) ↔ httpapiRateLimitStoreport +Server.limits atomic.Pointer[ratelimitdomain.Config]+rateLimitselectorfunc(ratelimitdomain.Config) int↔handlers_ratelimit.gorateLimitLimits/rateLimitView↔ OpenAPIRateLimitLimits/RateLimitView→ regeneratedschema.ts→ webuseRateLimits/useSaveRateLimits.metrics_collectors.goexports (NewDBPoolCollector/NewAITokensCollector/AITokenQuery/AITokenRow) are EXPORTED becausewire.go(packagemain) constructs them.db.Stat(func() *pgxpool.Stat) is a method value passed toNewDBPoolCollector.blobStore.Healthy,database.Ping,engine.Readyall satisfyfunc(context.Context) errorforDependencyProbe.Add.obs.NewPgxTracer()returnspgx.QueryTracerfed todb.WithQueryTracer.SetupTracingshutdownfunc(context.Context) errordeferred in wire. - Placeholder scan: no TBD/TODO — full SQL for
00086; full Go for the two collectors,DependencyProbe,obstracing + pgx tracer,dbaccessor/option, config, theratelimitcontext, the admin handlers, and every edit (metrics fields/methods, scheduler observer, ratelimit middleware, public-sign bumps, server wiring, wire.go); full OpenAPI path+schemas; full TSX tab + hooks; full en/id i18n keys; full CSS. Run-time judgment items are called out inline (semconv version pin;StatusTagprop names to verify against the real component; the exact latest OTel patch fromgo mod tidy). - Build-order note: T1 compiles standalone (metrics/collectors/scheduler/ratelimit-name); the collectors are registered in wire.go same commit. T2 adds the probe + deep readyz (imports nothing new). T4 introduces the OTel deps +
obspackage +db.Openvariadic (keeps alldb.Open(ctx,dsn())test callers compiling) — this commit is wherego.mod/go.sumchange. T5 adds the middleware + log fields. T7'sratelimitcontext compiles standalone, wired into httpapi + wire.go in the same commit;apiRate*Maxconsts stay (allowed unused). Web verify (tsc+vite build) runs in T7 (aftergen:api) and T8. Each task's Go commit isgo build ./... && go vet ./...green. - Known judgment calls / spec assumptions (see report):
1. AI dependency probe is gated + reachability-only. Included ONLY whenAI_CHAT_PROVIDERisopenai/anthropicANDAI_CHAT_BASE_URLis set (self-hosted/OpenAI-compatible). A managed provider with no base URL, ormock, is left unprobed — nothing new leaves the box on the air-gapped default. The check treats any HTTP status < 500 as "up" (the endpoint answered). Deliberate deviation from the spec's literal "ai sidecar GET /healthz", driven by AI being an external API here, not a local sidecar.
2. Deep/readyzincludes only CONFIGURED deps and 503s if ANY is down (per spec + the e2e that stops a sidecar).extract/embedare added only when their provider issidecar;gotenberg/postgres/minio/enginealways. This makes/readyza strict "fully ready" signal;/healthzstays liveness-only. Operators who want soft deps to NOT gate the LB should scrapedependency_upinstead — noted for the operator guide (out of scope for this plan).
3.public_sign_per_mingoverns the OTP-SEND budget only (the abuse-sensitive, UI-shown one, default 5); the OTP-SUBMIT budget stays the fixedpubSignSubmitMax=10constant. The spec lists a single public-sign limit, so submit is intentionally not admin-tunable. Its floor is 1 (its default is below the general 10-floor).
4. Per-limit safety floors, not a uniform ≥10.user≥60(SPA burst safety — the strongest anti-lockout, incl. the admin's own session),auth_ip≥10(brute-force minimum, can't be disabled to 0),tsa≥30,public_sign≥1, ceiling≤100000. Honors the spec's "each ≥10 e.g. + a sane auth-IP min" intent while accommodating public-sign's default of 5.
5. Hand-rolled instrumentation, minimal deps. The HTTP span middleware and pgxQueryTracerare ~40 lines each rather than pullingotelhttp/otelpgx, using plainattribute.String("http.route", …)(nosemconvHTTP helpers, whose API churns across versions) — only three OTel modules land ingo.mod. The pgx tracer is attached to the pool ONLY when tracing is on (nil tracer = zero overhead on the default path).
6./metrics+/readyz+/healthzstay on the OUTERhttp.ServeMux(per the wiring gotcha) so they are NOT wrapped by the chitracing/requestLog/instrumentmiddleware — scrapes/probes don't pollute RED metrics and aren't traced. The three Phase-1/2 handler changes live inwire.go, not the chi router.
7.db.Openbecomes variadic (opts ...Option) rather than a positional param so the ~23db.Open(ctx, dsn())test call-sites keep compiling undergo vet(which compiles test files against the live-DB DSN but is nevergo test-run).