Observability
Langfuse for LLM traces, Sentry for errors, PostHog for product, Axiom for logs.
SOMA is instrumented with four independent observability stacks. Each answers a different question. All are opt-in via env vars — missing a key is a no-op, never a crash.
| Stack | Purpose | Where in code |
|---|---|---|
| Langfuse | LLM trace tree per agent invocation | packages/agent/src/observability/langfuse.ts |
| Sentry | Uncaught errors + source maps | apps/web/src/lib/sentry.ts, apps/web/src/instrumentation*.ts |
| PostHog | Product analytics (page views, feature usage) | apps/web/src/components/analytics/posthog-provider.tsx |
| Axiom | Structured pino logs | packages/agent/src/logger.ts |
Langfuse
Every agent path calls startTrace({ name, userId, sessionId, input, tags }), passes the trace handle through the stream, and calls trace.end({ output }) on finish. Tags encode the surface: ['web', 'chat'], ['bot', 'message'], ['workflow', 'gmail.ingest'], ['workflow', 'fact-extract'].
flushLangfuse() is called eagerly after every stream finishes. Vercel serverless functions freeze between invocations, so unflushed events are lost. Always flush before returning.
Sentry
Sentry's Next SDK has a long history of accidentally importing pages-router code paths into App Router bundles. We work around that with a lazy wrapper at apps/web/src/lib/sentry.ts:
export function captureException(err: unknown, ctx?: { tags?: Record<string, string> }): void {
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) return;
void import('@sentry/nextjs').then((Sentry) => Sentry.captureException(err, ctx)).catch(() => {});
}All nine server-action / route-handler call sites use this wrapper. The error-boundary components (error.tsx, global-error.tsx) use dynamic import('@sentry/nextjs') inline.
PostHog
EU-hosted (eu.i.posthog.com). Initialized in a client provider inside apps/web/src/app/layout.tsx. Captures history_change page views + submit/click autocaptures. Inited only when NEXT_PUBLIC_POSTHOG_KEY is set.
Axiom / pino
Structured JSON logs via pino. Axiom is an optional transport — when AXIOM_TOKEN + AXIOM_DATASET are set, logs mirror to a dataset.
:::caution
Pino transports spawn a worker thread via thread-stream. Inside Next.js runtime, Turbopack rewrites absolute module paths and the worker can't find its entry file. packages/agent/src/logger.ts detects process.env.NEXT_RUNTIME and falls back to sync stdout JSON. Never re-enable transports inside Next.
:::
What to check when something breaks
- "Why did the agent reply that?" → Langfuse. Filter by
tags: 'chat'and sort by timestamp. - "Why did the request 500?" → Sentry. Issues grouped by stack trace.
- "What are users actually using?" → PostHog. Funnels on
/app/*. - "What did the ingestion workflow log?" → Axiom. Query
soma-logswithapp:agentfilter.