Self-hosted Grafana stack on Railway. Observes the live pagent API via metrics, traces, and logs.
- Traces — every HTTP request and every postgres query, auto-instrumented.
- Metrics — RED (rate / errors / duration) per route, plus four Pagent counters/histograms for temporary pages (created / submitted / abandoned / submit latency).
- Logs — structured pino logs, exported via OTLP, queryable in Loki with
trace_idcorrelation back to Tempo.
pagent-api ──OTLP/HTTP──▶ pagent-observability (Railway service)
└─ grafana/otel-lgtm (Grafana :3000, OTLP :4318)
One Railway service runs the bundled grafana/otel-lgtm image. The API
exports OTLP/HTTP over Railway's private network. Grafana itself is
exposed publicly with admin auth.
Grafana's admin account is infrastructure-only. The authenticated Pagent
workspace surface at /admin is where workspace administrators review page
metadata and governance controls. It does not grant private presentation
content or viewer-level analytics without an explicit product permission.
Two paths — pick one.
From the repo root, with railway login already done and the right project
linked:
# 1. Create the empty service
railway add --service pagent-observability
# 2. Set the admin password (generate a strong one) and the deploy envs.
# Each of these is required — without them the service either won't be
# reachable, will leak admin access, or won't load our dashboards.
# GF_SERVER_HTTP_ADDR=0.0.0.0 — image binds to localhost otherwise;
# Railway's edge sees 502.
# GF_AUTH_ANONYMOUS_ENABLED=false — image defaults to anon-admin.
# PORT=3000 — Railway uses this for both routing and the default port.
# GF_PATHS_PROVISIONING=/etc/grafana/provisioning — image sets
# GF_PATHS_HOME=/data/grafana, which
# re-roots provisioning under /data/...;
# this points it back at our baked-in
# /etc/grafana/provisioning tree.
PASS=$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")
for kv in \
"GF_SECURITY_ADMIN_PASSWORD=$PASS" \
"GF_SERVER_HTTP_ADDR=0.0.0.0" \
"GF_AUTH_ANONYMOUS_ENABLED=false" \
"PORT=3000" \
"GF_PATHS_PROVISIONING=/etc/grafana/provisioning"; do
railway variable set "$kv" --service pagent-observability --skip-deploys
done
# 3. Attach a 1 GB volume at /data (link the service first)
railway service link pagent-observability
railway volume add --mount-path /data
# 4. Deploy the Dockerfile (path-as-root makes infra/observability the
# service's source root, so railway.json's "Dockerfile" path resolves)
railway up ./infra/observability --path-as-root \
--service pagent-observability --ci
# 5. Expose port 3000 publicly (Grafana UI). 4318 stays private.
railway domain --service pagent-observability --port 3000- Project → New service → Empty service.
- Settings → Source → Connect GitHub repo, root directory
infra/observability. - Variables (all required):
GF_SECURITY_ADMIN_PASSWORD— strong random value (Railway's "Generate" works).GF_SERVER_HTTP_ADDR=0.0.0.0— the image binds tolocalhostby default; Railway's edge can't reach it without this.GF_AUTH_ANONYMOUS_ENABLED=false— image defaults to enabling anonymous admin access (intended for local dev).PORT=3000— Railway uses this for routing.GF_PATHS_PROVISIONING=/etc/grafana/provisioning— image setsGF_PATHS_HOME=/data/grafana, which re-roots provisioning under/data/...; this points Grafana back at our baked-in tree so the Pagent dashboards load.
- Volumes: attach a 1 GB volume mounted at
/data. - Networking: expose port
3000publicly. Ports4317/4318stay private. - Deploy.
After deploy, copy the service's internal address
(pagent-observability.railway.internal) and the public Grafana URL.
Notes on Railway-specific behavior:
- No
VOLUMEdirective in the Dockerfile. Railway rejects it with "docker VOLUME at Line N is not supported, use Railway Volumes." Persistence is configured via the Railway Volume API (step 3 above).GF_SERVER_HTTP_ADDR=0.0.0.0is required. The bundledgrafana.iniingrafana/otel-lgtmbinds Grafana tolocalhost, so Railway's reverse-proxy returns502 Application failed to respondeven though the container is up.- First boot with a fresh volume takes ~2.5 minutes (the image normally cold-starts in ~10 s without a mount; persisting
/dataslows initial dir creation). Subsequent boots are fast. We omithealthcheckPathfor that reason — pairing it with a 2-minute window makes cold starts flap. Without a healthcheck, Railway falls back to TCP-on-the-public-port, which is sufficient.
On the existing pagent-api service, set:
| Variable | Value |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
http://pagent-observability.railway.internal:4318 |
OTEL_SERVICE_NAME |
pagent-api (default) |
Redeploy. The API boot log should show:
[tracing] enabled — service=pagent-api signals=traces,metrics,logs endpoint=http://pagent-observability.railway.internal:4318
- URL: the public URL Railway assigned the observability service.
- User:
admin - Password: the value of
GF_SECURITY_ADMIN_PASSWORD.
- Service status turns red if no requests have arrived in the last 2 min.
- Request rate by route — RPS, broken out per matched route pattern
(
/,/:id,/:id/result,/health, etc.). - Error rate (%) by route — 4xx vs 5xx as separate series. Use this to spot a single endpoint regressing in isolation.
- Latency p50 / p95 / p99 by route — read p99 first when investigating slowness; if p50 is fine but p99 spiked, look for tail-latency causes (DB contention, retries).
- Rate-limit hits (429/s) — non-zero only when someone is hammering
POST /new. - Recent error logs — Loki panel showing the last 50 error/warn lines.
Each line's
trace_idis a clickable link into the Tempo explorer. - DB query p95 — derived from postgres spans via Tempo's span metrics.
- These panels describe temporary interactive/document page activity. Durable
presentation engagement is product data shown in the authenticated Pagent
page analytics UI and returned by MCP
read; it is not inferred from these operational counters. - Pages created (1h / 24h / 7d) — top-of-funnel.
- Submitted vs abandoned (24h) — stacked. The abandonment view.
- Submission rate — submissions per second over time.
- Time-to-submit distribution — heatmap. Reveals whether users fill the form quickly or hesitate.
- Conversion (24h) —
submitted / (submitted + abandoned)as a percent. - In-flight (rough) — pages alive but neither submitted nor expired yet. Treat as approximate; not a real-time gauge.
- Hit any endpoint, e.g.
curl -i https://api.pagent.link/health. - Note the
x-trace-idresponse header. - In Grafana, Explore → Tempo → search by trace ID → paste the value.
- The trace shows the full HTTP span and any DB queries.
- Click a span → "Logs for this span" to see the pino lines correlated to that request.
Edit infra/observability/dashboards/*.json and redeploy. Grafana picks up
file changes within ~30s on running deploys; on cold start it provisions
from the image.
Defaults inside grafana/otel-lgtm retain ~1 week. To extend, set on the
observability service:
TEMPO_RETENTION=336h # 14d for traces
LOKI_RETENTION=336h # 14d for logs
PROM_RETENTION=30d # for metrics
(The image consumes these via its bundled configs.)
OTLP exporters and the pino transport retry silently with exponential backoff. The API does not block on telemetry — request handling stays hot even when the observability service is down. In-memory buffers cap at a few MB per signal; sustained outages cause the oldest data to be dropped, but nothing leaks back to the user.
- Boot log says
[tracing] disabled.OTEL_EXPORTER_OTLP_ENDPOINTisn't set on the API service. Confirm the variable is onpagent-api, notpagent-observability. - Dashboards empty.
The API can't reach the LGTM service. From the API service shell, run
curl -v http://pagent-observability.railway.internal:4318— should return405 Method Not Allowed, which means the collector is alive. If the connection refuses, check the observability service is up. - Logs missing trace_id.
PinoInstrumentationrequirestracing.tsto be imported beforelogger.ts.server.tsalready does this. If you change the import order, that's likely the cause. x-trace-idheader is absent. Either tracing is disabled (see above) or you're hitting an endpoint before the request middleware runs (none should exist — every route is behind the middleware).- Volume filling up. Increase the Railway volume size, or tune retention (above).