think
16px
820px

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 is cd go && go build ./... && go vet ./... only (vet compiles test files too, so keep existing tests compiling — notably the ~23 db.Open(ctx, dsn()) test call-sites stay valid because Open gains a variadic ...Option, a backward-compatible signature change).
  • Web verify: cd web && npx tsc --noEmit && npx vite build.
  • npm run gen:api after ANY api/openapi.yaml edit (regenerates web/src/api/schema.ts). Run it from web/. Commit the regenerated schema.ts with the task.
  • NO new npm dependencies (npm install is broken: npm11/node25 arborist crash). NEW GO DEPS ARE EXPECTED IN PHASE 2 (OpenTelemetry) — connected build host, same mechanism client_golang/go-ldap were added with: go get <pkg>@<ver> && go mod tidy from go/, commit go/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-login director@obscura.local, host port 38080).
  • Tracing is OFF by default (air-gapped): the deploy env sets no OTEL_* vars → no spans, /metrics still 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. NEVER git add -A (go/obscura-server is a tracked ELF binary) — always git add explicit paths.
  • i18n en/id parity (tsc-enforced): feature-co-located web/src/features/admin/i18n.ts; nested groups; en and id identical in shape. Smart-quote gotcha: the Edit tool can mangle /“”; after editing an i18n file verify npx tsc --noEmit and if quotes broke, rewrite the whole file with Write.
  • down -v is FORBIDDEN (a prior incident wiped the demo pgdata + miniodata). Never docker compose down -v. Single-service teardown for the e2e uses docker compose stop <service> then docker compose start <service> (NOT rm); the tracing e2e uses a throwaway docker run collector removed with docker rm -f.
  • e2e restores all state — restart any stopped service; reset rate_limit_settings to 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 private prometheus.NewRegistry() via f := promauto.With(reg) (:37-39), pre-touches known label values (:60-63). Handler() = promhttp.HandlerFor(m.reg, …) (:68). instrument RED 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 on m.reg.
  • db.go (go/internal/platform/db/db.go): DB{ pool *pgxpool.Pool } (:26-28). Open(ctx, dsn) at :31 uses pgxpool.New(ctx, dsn)no Stat() accessor exists; add one (func (d *DB) Stat() *pgxpool.Stat { return d.pool.Stat() }). Ping(ctx) at :47 (used by /readyz). Exec(ctx) Executor at :62; Do(ctx, fn) tx at :73. Only caller of db.Open outside tests is wire.go:106; ~23 _test.go files call db.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 via go loop(ctx, logger, name, interval, fn) at :614-636 (audit-chainer/outbox-relay/scheduler). engine := selectProtectionEngine(cfg, logger) at :609 (has engine.Ready(ctx) error). metrics := httpapi.NewMetrics() at :428; api := httpapi.NewServer(httpapi.Deps{…}) at :429-475. Outer mux at :639: GET /healthz static (:640-643), GET /readyz = DB Ping + 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 :74 loops registered handlers, claims via repo.ClaimDue, runs r.handler(ctx) at :87. Service{ repo; handlers map[string]registration; clock func() time.Time } (:35-39); clock is time.Now().UTC (:48). Add an onRun 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). Consts apiRateWindow=time.Minute, apiRateUserMax=600, apiRateAuthRouteMax=60, apiRateTSAMax=600 (:57-62). Singletons userLimiter/authLimiter/tsaLimiter (:67-71). func (s *Server) rateLimit(rl, keyFn, max) writes a 429 writeProblem on deny (:77-89). principalKeyFn/ipKeyFn/tsaKeyFn (:94-125). Call sites in server.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 } + singleton pubSignLimiter (:42); limWindow type defined here (:37, shared by ratelimit.go); consts pubSignWindow=time.Minute, pubSignSendMax=5, pubSignSubmitMax=10 (:44-48). Denies are in *Server methods PublicSendOTP (:163) and PublicSubmitOTP (:179) — both have s in scope.
  • config.go: Config struct + nested config structs with env:"…" envDefault:"…" tags; Validate() (:404) fail-closed switch pattern; per-provider validate() helpers (OIDCConfig.validate, LDAPConfig.validate, ESignPeruriConfig.validate). GotenbergURL default http://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_usage table (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: Deps struct (:73-108) + Server struct (:111-150, note lic atomic.Pointer[licenseState] at :149 — the hot-swap pattern to mirror). NewServer(d Deps) (:153-206) maps Deps→fields, defaults nil metrics/logger. MetricsHandler() (:210). Handler() (:215) middleware order: RequestIDrequestLog(s.logger)s.metrics.instrumentRecoverer (:221-224). Admin routes gated with r.With(s.requirePerm("rbac.admin")).Get/Put/… (:621-646, e.g. /admin/ldap/status, /admin/scim/token).
  • logging.go: requestLog(logger) (:20) emits one logger.LogAttrs(ctx, LevelInfo, "http_request", slog.String("method",…), …, slog.String("remote", r.RemoteAddr)) line (:34-42) — inject trace_id/span_id here.
  • blob/minio.go: MinIOStore{ client *minio.Client; bucket; cipher }; NewMinIOStore (:57) uses client.BucketExists(ctx, cfg.Bucket) (:66). File imports fmt. Add Healthy(ctx) error = BucketExists round-trip.
  • Sidecars expose /healthz; Gotenberg exposes /health — confirmed in deploy/docker-compose.yml: embed-sidecar + extract-sidecar healthchecks GET http://localhost:8000/healthz (:48,:70); in-network they are http://embed-sidecar:8000 / http://extract-sidecar:8000 (EMBED_SIDECAR_URL/EXTRACT_SIDECAR_URL, :120/:128); GOTENBERG_URL: http://gotenberg:3000 (:115).
  • go.mod: has github.com/jackc/pgx/v5 v5.10.0 (:16), github.com/prometheus/client_golang v1.23.2 (:20). No go.opentelemetry.io/otel — added in T4.
  • AdminPage.tsx: tab labels <Tab>{t('admin.tabs.X')}</Tab> (:107-117, last is ldap :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 use api,ok from @/api/client and useQuery/useMutation/useQueryClient (already imported); e.g. api.GET('/api/v1/admin/users', {…}).then(ok) (:78,:126).
  • admin/i18n.ts: export const en = {…} then export const id: typeof en = {…}; admin.tabs.* group (:12), admin.tabs.ldap:'Directory' (:23) / id (:545); ldap:{…}/scim:{…} copy groups (en :465/:492, id counterparts). Add tabs.observability + an observability:{…} group in both.
  • app.css: reusable .ldap-map__add flex 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. In go/internal/platform/db/db.go, after Ping (: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. In go/internal/platform/blob/minio.go, after Encrypted() (: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 to Metrics (after loginTotal):
    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 to Service (after clock):
    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, change rateLimit to take a name and bump the metric on deny (keep max int for 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 after metrics := 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. In go/cmd/obscura-server/wire.go, immediately before the mux := 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-login director@obscura.local (:38080) → TOKEN; GET /api/v1/me → assert enabled_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_up appears once the first tick (≤15s) or first /readyz hits.

  • [ ] 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 → assert obscura_scheduler_runs_total{...,result="ok"} has a non-zero sample for at least one task and obscura_scheduler_run_duration_seconds_count > 0.

  • [ ] Step 4 — /readyz 200 all-up. curl -s -o /tmp/ready.json -w '%{http_code}' :38080/readyz200; 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 → every obscura_dependency_up{dep=...} == 1.

  • [ ] Step 5 — /readyz 503 with a dep stopped. docker compose -f deploy/docker-compose.yml stop embed-sidecar (single-service pause — NOT down -v). Wait ~3s, then curl -s -o /tmp/ready.json -w '%{http_code}' :38080/readyz503; body ready:false and deps.embed is a non-"ok" error string (e.g. connection refused). Re-curl /metricsobscura_dependency_up{dep="embed"} == 0 while postgres/minio stay 1. Restore: docker compose -f deploy/docker-compose.yml start embed-sidecar; wait until curl :38080/readyz200 again (poll ≤30s).

  • [ ] Step 6 — Cleanup + demo-intact assert. Confirm all services up (docker compose -f deploy/docker-compose.yml ps — every service running/healthy). GET /api/v1/me (dev-login) → 5 modules intact. Report which metric series and both /readyz codes 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 + a Config field OTEL OTELConfig (place it next to LDAP 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. Create go/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.Open variadic option + tracer. In go/internal/platform/db/db.go, add above Open:
// 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 imports obs "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/obs" and "github.com/jackc/pgx/v5". At the TOP of run (before database, 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.go Handler(), add s.tracing between RequestID and requestLog so 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_id into the request log. In go/internal/httpapi/logging.go, add "go.opentelemetry.io/otel/trace" to imports and rewrite the logger.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_* in deploy/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 no trace_id field (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, typically deploy_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, then docker 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 a trace_id and span_id (a real recording provider is installed). Secondary (export): docker logs otelcol 2>&1 | grep -iE 'Span|Trace ID' | head → server spans (name GET /api/v1/me) and pg.query client spans appear. Assert /api/v1/me still returns 5 modules (tracing does not change behavior).

  • [ ] Step 5 — Revert to off + cleanup. Remove the OTEL_EXPORTER_OTLP_ENDPOINT line from deploy/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 → no trace_id; GET /api/v1/me → 5 modules; curl :38080/metrics still 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. Create go/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 import ratelimitdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/ratelimit/domain". Add the port (near the Deps struct):
// 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 import ratelimitdomain "…/internal/ratelimit/domain". Change rateLimit from max int to 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 imports ratelimitadapters "…/internal/ratelimit/adapters" and ratelimitdomain "…/internal/ratelimit/domain". Before metrics := httpapi.NewMetrics() (or anywhere after database exists), 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 generated schema.ts — reference the RateLimitLimits component type; use the same api/ok helpers 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: add import { 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, add observability: 'Observability' to the en tabs group and observability: 'Observabilitas' to the id tabs group. Add an observability copy group to BOTH en and id (identical shape), e.g. for en:
    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__add block, 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 00086 runs on boot → row seeded 600/60/600/5). Dev-login → TOKEN; GET /api/v1/me → 5 modules. GET /api/v1/admin/rate-limitslimits {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-limits with {"user_per_min":5,"auth_ip_per_min":60,"tsa_per_min":600,"public_sign_per_min":5}400 with a message naming user_per_min + the floor (60). Confirm GET still shows user_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 rapid GET /api/v1/me with the same token in one minute; assert at least one returns 429 (Retry-After header set) and GET /api/v1/admin/rate-limits shows rejections.user incremented. curl :38080/metrics | grep 'obscura_rate_limit_rejected_total{limiter="user"}' → non-zero.

  • [ ] Step 4 — Raise → recovers. PUT back to {"user_per_min":600,...}. Wait for the current minute window to roll (≤60s), then fire ~20 GET /api/v1/me → all 200 (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 → no trace_id (Phase-2 default). GET /api/v1/me → 5 modules. curl :38080/metrics still 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} (or docker 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.go fields + metrics_collectors.go + scheduler SetObserver + rateLimit name/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, /healthz stays static) → T2. §Phase-1 testing (curl /metrics asserts new series; /readyz 200 all-up + 503 with a docker compose stop sidecar, then restart) → T3.
  • §Phase 2 (off by default; spans only when OTEL_EXPORTER_OTLP_ENDPOINT set; 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; pgx QueryTracer DB spans on the pool; trace_id/span_id in the slog request log; obs.SetupTracing(cfg)(shutdown,err) wired in main; fail-closed only if endpoint set + malformed; shutdown flushes on exit) → T4 (config + obs package + db.Open option + wire) + T5 (HTTP middleware + log correlation). §Phase-2 testing (off ⇒ no spans + /metrics serves; on with a throwaway otel/opentelemetry-collector debug receiver ⇒ spans OR trace_id in logs; revert) → T6.
  • §Phase 3 (rate_limit_settings singleton id=1 CHECK, columns per-limit, seeded with today's constants; live-apply via atomic.Pointer[rateLimitConfig] on save, no restart; safety floor validation preventing lockout + a sane auth-IP min; GET/PUT /api/v1/admin/rate-limits rbac.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 + ratelimit domain/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.run per-check context.WithTimeout (T2); SetupTracing returns err → wire logs + continues with no-op (T4/T5); Config.Validate + Store.Load fallback (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) ↔ httpapi RateLimitStore port + Server.limits atomic.Pointer[ratelimitdomain.Config] + rateLimit selector func(ratelimitdomain.Config) inthandlers_ratelimit.go rateLimitLimits/rateLimitView ↔ OpenAPI RateLimitLimits/RateLimitView → regenerated schema.ts → web useRateLimits/useSaveRateLimits. metrics_collectors.go exports (NewDBPoolCollector/NewAITokensCollector/AITokenQuery/AITokenRow) are EXPORTED because wire.go (package main) constructs them. db.Stat (func() *pgxpool.Stat) is a method value passed to NewDBPoolCollector. blobStore.Healthy, database.Ping, engine.Ready all satisfy func(context.Context) error for DependencyProbe.Add. obs.NewPgxTracer() returns pgx.QueryTracer fed to db.WithQueryTracer. SetupTracing shutdown func(context.Context) error deferred in wire.
  • Placeholder scan: no TBD/TODO — full SQL for 00086; full Go for the two collectors, DependencyProbe, obs tracing + pgx tracer, db accessor/option, config, the ratelimit context, 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; StatusTag prop names to verify against the real component; the exact latest OTel patch from go 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 + obs package + db.Open variadic (keeps all db.Open(ctx,dsn()) test callers compiling) — this commit is where go.mod/go.sum change. T5 adds the middleware + log fields. T7's ratelimit context compiles standalone, wired into httpapi + wire.go in the same commit; apiRate*Max consts stay (allowed unused). Web verify (tsc+vite build) runs in T7 (after gen:api) and T8. Each task's Go commit is go build ./... && go vet ./... green.
  • Known judgment calls / spec assumptions (see report):
    1. AI dependency probe is gated + reachability-only. Included ONLY when AI_CHAT_PROVIDER is openai/anthropic AND AI_CHAT_BASE_URL is set (self-hosted/OpenAI-compatible). A managed provider with no base URL, or mock, 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 /readyz includes only CONFIGURED deps and 503s if ANY is down (per spec + the e2e that stops a sidecar). extract/embed are added only when their provider is sidecar; gotenberg/postgres/minio/engine always. This makes /readyz a strict "fully ready" signal; /healthz stays liveness-only. Operators who want soft deps to NOT gate the LB should scrape dependency_up instead — noted for the operator guide (out of scope for this plan).
    3. public_sign_per_min governs the OTP-SEND budget only (the abuse-sensitive, UI-shown one, default 5); the OTP-SUBMIT budget stays the fixed pubSignSubmitMax=10 constant. 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 pgx QueryTracer are ~40 lines each rather than pulling otelhttp/otelpgx, using plain attribute.String("http.route", …) (no semconv HTTP helpers, whose API churns across versions) — only three OTel modules land in go.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 + /healthz stay on the OUTER http.ServeMux (per the wiring gotcha) so they are NOT wrapped by the chi tracing/requestLog/instrument middleware — scrapes/probes don't pollute RED metrics and aren't traced. The three Phase-1/2 handler changes live in wire.go, not the chi router.
    7. db.Open becomes variadic (opts ...Option) rather than a positional param so the ~23 db.Open(ctx, dsn()) test call-sites keep compiling under go vet (which compiles test files against the live-DB DSN but is never go test-run).