Observability
How we see what production is doing — logs, metrics, traces, and the playbook for chasing a 500 back to its root cause.
Stack Inventory
Section titled “Stack Inventory”What is actually wired in, per service, with file evidence.
| Service | Logs | Metrics | Traces | Errors | Product analytics |
|---|---|---|---|---|---|
| sirloin (Go) | zerolog shipped via the OTLP log bridge in apps/sirloin/internal/pkg/tracing/zerologotelwriter.go |
OpenTelemetry metrics → OTLP HTTP (go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp) |
OpenTelemetry traces → OTLP HTTP, with gRPC + HTTP middleware (otelgrpc, otelhttp); BUN auto-instrumented (bunotel, otelsql) |
Structured error logs and worker panic stacks → Axiom | PostHog (github.com/posthog/posthog-go in apps/sirloin/go.mod) |
| brain (NestJS) | pino + nestjs-pino + pino-http + pino-pretty; correlated through @opentelemetry/instrumentation-pino |
OpenTelemetry metrics → OTLP proto exporter; endpoint set via BRAIN_OTEL_URL; process-memory gauges in apps/brain/src/common/otel/metrics.ts |
OpenTelemetry Node SDK with native HTTP, Express, Nest, and Prisma instrumentation | Structured service and HTTP exception logs → Axiom | None observed |
| brisket (Next.js) | Server Pino → OTLP; browser reports → same-origin /api/telemetry → server Pino |
OpenTelemetry metrics → Axiom | Node SDK + auto-instrumentation and explicit tRPC spans; browser performance timings are logs | Browser, render-boundary, and Next.js request errors → Axiom | posthog-js and posthog-node |
| fennec (React) | Browser console only (no log shipper in apps/fennec/package.json); SPA backed by nginx (apps/fennec/nginx.conf) |
None observed | None observed | No exporter observed | None — no posthog-* in apps/fennec/package.json |
| flank (TS) | console / stdout (UI host only — the engine and its custom logger were removed) |
None observed | None observed | No exporter observed | None — no posthog-* in apps/flank/package.json |
| round (Go ML) | zerolog (apps/round/cmd/app/main.go: setupLogger returns zerolog.Logger) |
None observed in go.mod |
None observed | None observed | None observed |
| strip (Go SSR) | zerolog (apps/strip/cmd/app/main.go uses log.Info().Msg(...)) |
None observed | None observed | None observed | None observed |
| chuck (Strapi) | Strapi 5 default logger only — no logger override in apps/chuck/meat/config/ |
None — no metrics deps in apps/chuck/meat/package.json |
None — no OTel deps in apps/chuck/meat/package.json |
No exporter observed | None — no posthog-* in apps/chuck/meat/package.json |
| shank (email) | n/a — build-time export only | n/a | n/a | n/a | n/a |
Backends: OTLP traces/metrics/logs are exported to Axiom. The endpoint
URL is configured per service via env: SIRLOIN_AXIOM_TOKEN / _DATASET /
_METRICS_DATASET, BRAIN_OTEL_URL /
BRAIN_OTEL_METRICS_HEADERS / BRAIN_OTEL_TRACES_LOGS_HEADERS
and BRISKET_OTEL_URL / BRISKET_OTEL_METRICS_HEADERS
/ BRISKET_OTEL_TRACES_LOGS_HEADERS. Axiom is the error destination for
sirloin, brain, and brisket. PostHog handles product analytics for sirloin and
brisket. Brisket also uses PostHog feature flags.
Log Levels and Conventions
Section titled “Log Levels and Conventions”Go services (sirloin, round, strip)
Section titled “Go services (sirloin, round, strip)”- sirloin uses
zerologwith structured fields and an OTLP log bridge. - round and strip use
zerolog(apps/round/cmd/app/main.go,apps/strip/cmd/app/main.go). Levels:Debug,Info,Warn,Error,Fatal. - Log level is configurable via env (round:
setupLogger(level string)). - Convention:
log.Error().Err(err).Msg("failed to X")— error inerrfield, human message inMsg.
TS services (brain, brisket, flank, fennec)
Section titled “TS services (brain, brisket, flank, fennec)”- brain uses
pinovianestjs-pino/pino-http. Prod = JSON, dev =pino-pretty. Auto-instrumented by OpenTelemetry so each log line carriestrace_id/span_id. - brisket uses Next.js + OpenTelemetry. Server logs ship via OTLP to Axiom with
BRISKET_OTEL_TRACES_LOGS_HEADERS; browser reports use/api/telemetryand the same server export path. - Levels:
trace,debug,info,warn,error,fatal(pino defaults). TODO(@pawel): production minimum log level per service (brain pino config).
Brain’s caught exceptions use the existing service logger with sanitized error details and diagnostic identifiers. HTTP logs omit request bodies, query values, and parameter objects. HTTP 4xx failures remain warnings; 5xx and unexpected failures use error severity. Fatal process errors trigger a bounded two-second telemetry flush before exit status 1. Native HTTP, Express, Nest, Prisma, and Pino instrumentation retain request trace correlation.
Browser error diagnostics
Section titled “Browser error diagnostics”For the operator and agent lookup procedure, dataset selection, and copy-paste APL queries, see the Brisket client-log runbook.
Brisket reports manual captures, uncaught browser errors, unhandled promise
rejections, and checkout/render boundary failures through /api/telemetry.
The Node route validates bounded same-origin batches and writes structured Pino
logs. Axiom credentials remain on the server. The route derives user_id from
Clerk and attaches source=browser, runtime=browser, environment, and release.
The separate browser_release label identifies the client build, which can differ
from the ingestion server after a deployment.
Client buffers and context clear when the signed-in account changes.
Start an investigation with service, release, path, exception name, and stack.
For a Next.js render failure, correlate its digest with the server error log.
Raw browser stacks are available; automatic browser source-map symbolication
is not provided. Brisket retains private server source maps, but structured
Error.stack values can still contain compiled frames because Next.js controls
stack formatting. Use the matching release artifacts for manual symbolication.
Sampled navigation and request timings are logs, not distributed
browser traces. These signals do not provide session replay or profiling.
Delivery is best-effort. Client budgets, server request limits, invalid payloads, offline clients, and exporter failures can drop reports. Timing reports have a separate budget so they cannot consume the error allowance. Telemetry requests are excluded from capture to prevent reporting loops. Known analytics and media cancellations remain filtered. Validate delivery and alert routing in staging or a controlled cutover; the repository does not provision Axiom monitors or notification destinations. Keep rollback Sentry credentials available until cutover verification and the agreed observation window finish. Before removing them, record the deployed revision, representative Axiom events, cross-service trace continuity, monitor health, and notification delivery in the rollout ticket. Brisket also observes background process errors while preserving Next.js exit behavior. An immediate process exit can prevent its buffered OTLP log from arriving; the Pino record remains in service stdout.
Metric Naming
Section titled “Metric Naming”TODO(@law): no documented convention found in code. Recommend OTel
semantic conventions for HTTP/gRPC (http.server.duration,
rpc.server.duration) and domain prefixes for custom metrics
(sirloin.billing.invoice.failed, brain.generation.queued). File a
follow-up if you want this enforced.
Brain exports these process-memory gauges through the same metrics datastream
when BRAIN_OTEL_METRICS_HEADERS is configured:
process.memory.rss— resident set size for the Node process.process.memory.external— native/external memory tracked by Node.process.memory.array_buffers— ArrayBuffer and Buffer backing memory.process.memory.heap_used— V8 heap used bytes.process.memory.heap_total— total V8 heap allocated bytes.process.memory.unaccounted— RSS not explained by V8 heap total plus Node-tracked external memory.
Brain also exports portable V8 and Sharp memory diagnostics:
v8.heap.used_heap_size,v8.heap.total_heap_size,v8.heap.total_physical_size,v8.heap.total_available_size,v8.heap.heap_size_limit,v8.heap.malloced_memory, andv8.heap.peak_malloced_memory.v8.heap_space.used_size,v8.heap_space.space_size,v8.heap_space.physical_size, andv8.heap_space.available_size, grouped byv8.space.name.v8.code.code_and_metadata_sizeandv8.code.bytecode_and_metadata_size.v8.cpp_heap.used_sizeandv8.cpp_heap.committed_sizewhen Node exposes C++ heap statistics.brain.sharp.queue,brain.sharp.process,brain.sharp.cache.memory,brain.sharp.cache.files, andbrain.sharp.cache.items.
Brain also exports Node-specific runtime diagnostics:
brain.runtime.active_resources— active resources fromprocess.getActiveResourcesInfo(), grouped bynode.resource.type.
Brain also exports file I/O limiter gauges from
apps/brain/src/common/otel/metrics.ts, backed by
apps/brain/src/modules/application/storage/services/file-io-limiter.service.ts:
brain.file_io_limiter.active— active work items bylimiter.name.brain.file_io_limiter.queued— queued work items bylimiter.name.brain.file_io_limiter.limit— configured concurrency limit bylimiter.name.brain.file_io_limiter.oldest_queued_ms— oldest queued wait bylimiter.name.
Current limiter names are storage_uploads, remote_downloads,
video_transforms, provider_output_downloads, and provider_preuploads.
Brain also exports mediaflows BullMQ job-state gauges from the generation
module. Each gauge uses queue.name=mediaflows:
brain.queue.jobs.active— jobs claimed by Brain from BullMQ; downstream provider work may still be pending.brain.queue.jobs.waiting— jobs waiting in BullMQ.brain.queue.jobs.delayed— jobs delayed in BullMQ.brain.queue.jobs.failed— jobs failed in BullMQ.
For FOXY-202 native-memory leak checks, start with three Axiom chart groups in the brain metrics dataset:
- RSS shape:
process.memory.rss,process.memory.unaccounted,process.memory.external,process.memory.array_buffers, andprocess.memory.heap_total. - V8/native runtime:
v8.heap.total_physical_size,v8.heap.malloced_memory,v8.heap.peak_malloced_memory,v8.cpp_heap.used_size,v8.code.code_and_metadata_size, andbrain.runtime.active_resources. - Workload pressure:
generation.media.total,round.infer.total,brain.file_io_limiter.active,brain.file_io_limiter.queued,brain.sharp.queue, andbrain.sharp.process.
RSS/unaccounted growth while V8 heap, V8 malloced memory, and Sharp counters are flat points away from ordinary JS heap growth and toward native allocator, provider SDK, ffmpeg/gRPC, or platform-level RSS retention.
Trace Propagation
Section titled “Trace Propagation”Trace IDs cross service boundaries via standard W3C traceparent headers.
Evidence (grep traceparent across apps/sirloin, apps/brain/src):
- TS code reads/writes
traceparentandtracestate(e.g.,normalizeTraceContextHeaderValue(headers.traceparent)). - gRPC:
otelgrpcinterceptors on Sirloin and Brain’s Round client propagate across gRPC boundaries. Standard HTTP instrumentation covers Sirloin → Brain. - Correlation ID fallback:
x-correlation-idis read across NestJS / Fastify layers (req.headers['x-correlation-id'] ?? uuidv4()) and surfaced to logs ascorrelationId/req.id.
So a request hitting Brisket gets a traceparent, propagates over gRPC into
Sirloin and then over HTTP to Brain. The trace terminates at the Sirloin/Brain
→ Round boundary:
apps/round/go.mod declares no go.opentelemetry.io/* dependencies, and no
OTel imports exist under apps/round/cmd or apps/round/internal.
Dashboards
Section titled “Dashboards”TODO(@law): catalogue of Axiom dashboards and PostHog dashboards (URLs and ownership). Suggested sections to fill:
- Axiom: per-service traces dashboard, error-rate dashboard, slow-query view.
- PostHog: funnels for signup → first generation → paid conversion.
Alerts and SLOs
Section titled “Alerts and SLOs”Alerts live in the Axiom UI as monitors on the beef-prod dataset, not as
code. The ones below are the ones this doc owns; the rest are listed in Axiom
under Monitors.
| Monitor | Owner | Destination | Threshold | Why |
|---|---|---|---|---|
| Sirloin examples statements slow - PROD | @law | #log_beef-alerts-prod |
20 or more Sirloin db spans on media.examples over 500 ms in 15 min, checked every 5 min |
A statement that returns tens of thousands of example rows per call is slow before it is expensive. The bounded search and the hourly catalog load stay under this; the pre-fix search alone tripped it. See FOXY-1204. |
Attributing Neon data transfer
Section titled “Attributing Neon data transfer”Neon bills egress, and pg_stat_statements on the project hides query
and queryid from the read-only roles. To find what is moving bytes:
- Order
pg_stat_statementsbyrows, keepcalls,wal_bytes(writes have it, reads do not), andmean_exec_time. - Size a row with
avg(pg_column_size(t.*))on the suspect table, or atablesamplefor large tables. Bytes per day is rows per call times width times calls per day. - Match the statement to its text in Axiom: Sirloin db spans carry
attributes.db.statement, so group by the statement prefix and compare call rate and mean duration with thepg_stat_statementsrow. Brain emits no db spans; useattributes.http.routecounts instead.
Full-table reads of media.examples were the 2026-09 case; the fix and the
numbers are in Linear FOXY-1204.
Expected failures that are deliberately not errors
Section titled “Expected failures that are deliberately not errors”Some spans look like failures but are the normal answer to a poll or a probe. Suppress these at narrow instrumentation boundaries so unrelated failures remain visible.
| Signal | Why it is expected | Where it is suppressed |
|---|---|---|
Brain GET /api/media/:id, HTTP 404 |
Sirloin polls this every 5s (job registered in apps/sirloin/internal/app/worker/worker.go) from dispatch until the workflow produces the media, so “not found” means “not ready yet”; the caller handles it in checkmediageneration.go. |
Expected404SpanProcessor downgrades the span status to UNSET (apps/brain/src/common/otel/expected-404-span-processor.ts). Scoped to GET on that route with a 404. |
S3 HeadObject, HTTP 404 |
Storage existence probes (fileExists / getObjectEtag) treat 404 as the answer, but the aws-sdk instrumentation still marks the span ERROR. Note that getObjectEtag swallows every error, not only a 404. |
Same processor, same file. Scoped to a HeadObject call, or a HEAD whose host ends in a known S3 domain or carries any label starting with s3. That host match is deliberately broad, so an unrelated host such as s3-not-storage.example.com is also covered. |
| Brisket OTLP export request | Tracing telemetry export would recursively generate telemetry about itself. | ignoreOutgoingRequestHook skips the configured OTLP host (apps/brisket/otel.server.config.ts, predicate in apps/brisket/src/otel-runtime-config.ts). |
A sustained exporter failure can drop reports. Check exporter diagnostics, credentials, ingestion limits, and the last received event when a service’s telemetry volume falls unexpectedly. Export suppression is not delivery proof.
Before adding a rule here, prefer fixing the caller. Suppress only where the failure is genuinely the expected answer, and keep the predicate narrow enough that a real regression on the same route still turns the span red.
Debugging Playbook — “I see a 500 in production”
Section titled “Debugging Playbook — “I see a 500 in production””Start with an Axiom alert or a user report.
- Axiom: find the error. Filter by service, environment, and a narrow time window. Read the error stack, release, and available correlation identifiers.
- Axiom — pivot on
trace_id. Search across services for that trace ID. Inspect the request span tree: Brisket → Sirloin (gRPC) → Brain (HTTP). Identify the failing span; Round itself has no exporter. - Logs in Axiom. Filter logs by
trace_idwhere present (Brain/Brisket Pino → OTel logs). Sirloin’s zerolog bridge does not automatically attach active span context. Correlate timestamps with the failing span. Fallback: filter bycorrelationId/x-correlation-id. - PostHog — user impact. Look up the affected user / session by
distinct_idto confirm severity and reproduction path. - Reproduce locally. Capture the request payload from the trace, replay
against a local stack (
make dev-up-d). For billing or payment paths see the relevant ADRs in Decisions — many bugs in those flows are state-machine issues that need the right preconditions.
If the trace dead-ends at a service with no OTel (round, strip — currently),
fall back to that service’s zerolog output by container/pod log search.
Sensitive Data in Logs
Section titled “Sensitive Data in Logs”- Never log raw Chargebee card data, Primer tokens, or full Clerk JWTs.
- Email addresses currently appear in
Sirloin.SubsAll.emailand brainUser.email. No global PII redaction layer exists today (see security-model.md → Logging hygiene for the partial sanitisers inapps/sirloin/internal/app/foxy360/server.go:526andapps/sirloin/internal/app/services/media/listmediaexamples.go:53). - See the Security Model page for the authoritative redaction rules.
Open follow-ups
Section titled “Open follow-ups”- Production minimum log level per service (TODO(@pawel) for brain).
- Metric naming convention enforcement (TODO(@law)).
- Dashboard inventory: Axiom / PostHog URLs (TODO(@law)).
- Alerts / SLOs / on-call ownership (TODO(@law)).
- PII masking policy in logs (TODO(@law)).