Monitoring
Logs, error tracking, and health visibility.
Last updated on
4 min readMonitoring runs on the same PostHog backbone as Analytics: error tracking on client and server, OpenTelemetry-backed structured logs sent to PostHog Logs, and source maps uploaded at build so stack traces de-minify in the dashboard. Every piece gates on the NEXT_PUBLIC_POSTHOG_* env vars, so a fresh clone ships zero monitoring code until you configure them. Then it all turns on at once.
Error tracking
Two helpers you call, plus an automatic hook that needs no wiring.
Structured logging
Six severity levels emitted over OTLP to PostHog Logs.
Source maps
Uploaded at build time so stack traces de-minify.
Health probes
Liveness and readiness endpoints for uptime monitors.
The Three Pipelines
| Pipeline | Surface | What it captures |
|---|---|---|
| Errors | captureError (browser), captureServerError (Node), onRequestError (auto) | JS exceptions, server route/render/action errors, manually reported errors |
| Logs | log.{trace,debug,info,warn,error,fatal} from @syntaxkit/analytics/logger | Structured server logs via OTLP, sent to ${NEXT_PUBLIC_POSTHOG_HOST}/i/v1/logs |
| Source maps | withPostHogConfig in apps/web/next.config.ts | Production source maps uploaded at build, deleted locally after upload |
Error Tracking
Three surfaces feed PostHog error tracking: two you call, one automatic.
Client. Call captureError from @syntaxkit/analytics/client in any error boundary. The kit's global boundary at apps/web/app/global-error.tsx does exactly this:
"use client";
import { useEffect } from "react";
import { captureError } from "@syntaxkit/analytics/client";
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
captureError(error);
}, [error]);
// ...render fallback UI
}PostHog merges each exception with the active session replay, so every dashboard error pairs the stack trace with the 30 seconds of user activity that produced it.
Server. captureServerError(error, distinctId?, context?) from @syntaxkit/analytics/server is the awaitable manual equivalent, so serverless invocations don't drop the capture:
import { captureServerError } from "@syntaxkit/analytics/server";
try {
await doSomethingRisky();
} catch (error) {
await captureServerError(error as Error, userId, { route: "/api/risky" });
throw error;
}Automatic. The onRequestError hook in apps/web/instrumentation.ts fires on every server render, route handler, or server action error, so unhandled exceptions are captured without wrapping anything in try/catch.
You don't need a Sentry-style wrapper for unhandled exceptions. Next's onRequestError hook covers every server boundary. Reach for manual captureServerError only when you've already handled an exception but still want PostHog to know about it.
What the onRequestError hook does
On each error it runs three steps in order:
- Extracts the PostHog
distinctIdandsessionIdfrom the request cookie viagetPostHogContext(cookieString), so the exception ties back to the right user and session replay. - Calls
log.errorwith full request and route context (path, method, router kind, route type, error name, truncated stack). - Calls
posthog.captureException(err, distinctId)and force-flushes the OTel logger so logs reach PostHog before the runtime terminates.
export const onRequestError = async (err, request, context) => {
if (analyticsEnabled && process.env.NEXT_RUNTIME === "nodejs") {
const ctx = getPostHogContext(cookieString);
log.error(`Server request error: ${err.message}`, {
posthogDistinctId: ctx.distinctId,
sessionId: ctx.sessionId,
attributes: {
error_name: err.name,
request_path: request.path,
request_method: request.method,
route_path: context.routePath,
route_type: context.routeType,
},
});
await posthog.captureException(err, ctx.distinctId);
await loggerProvider?.forceFlush();
}
};Structured Logging
The log helper at packages/analytics/src/logger.ts wraps an OpenTelemetry LoggerProvider and emits OTLP records to PostHog Logs (six severity levels, three optional fields):
import { log } from "@syntaxkit/analytics/logger";
log.info("Stripe webhook processed", {
posthogDistinctId,
sessionId,
attributes: { eventId, eventType, outcome },
});| Level | Use for |
|---|---|
trace | Highest-frequency, lowest-importance traces (rarely used). |
debug | Development-time tracing; quiet in production. |
info | Normal operations: webhooks processed, tasks completed. |
warn | Recoverable problems: rate-limit hit, retryable failure. |
error | Server-side errors that produced an end-user-visible failure. |
fatal | Unrecoverable failures requiring immediate attention. |
Pass posthogDistinctId and sessionId whenever you have them: they link a log line to the user who triggered it. attributes is freeform structured data that shows up as filterable columns in the PostHog Logs UI.
How logs are batched and flushed
The LoggerProvider is constructed in apps/web/instrumentation.ts with a BatchLogRecordProcessor, so logs ship in batches rather than one network request per call. The Stripe webhook handler and the auto error hook are the canonical reference call sites.
Source Maps
When POSTHOG_API_KEY and POSTHOG_PROJECT_ID are set, the @posthog/nextjs-config wrapper in apps/web/next.config.ts uploads source maps at next build time and deletes them locally afterward, so they never ship in the runtime image:
export default process.env.POSTHOG_API_KEY && process.env.POSTHOG_PROJECT_ID
? withPostHogConfig(composedConfig, {
personalApiKey: process.env.POSTHOG_API_KEY,
projectId: process.env.POSTHOG_PROJECT_ID,
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
sourcemaps: { enabled: true, deleteAfterUpload: true },
})
: composedConfig;These two env vars must be present at next build time, not runtime. Pass them as build-time vars on Vercel (Environment Variables panel), Fly ([build.args] in fly.toml), Render (envVars in render.yaml), or Docker (--build-arg). Set only at runtime, the upload silently no-ops and stack traces in PostHog stay minified.
The Reverse Proxy
The client init uses api_host: "/ingest" instead of the public PostHog host. When POSTHOG_PROXY_INGEST_HOST and POSTHOG_PROXY_ASSET_HOST are set, apps/web/next.config.ts rewrites those paths to the configured PostHog endpoints:
async rewrites() {
if (
process.env.POSTHOG_PROXY_INGEST_HOST &&
process.env.POSTHOG_PROXY_ASSET_HOST
) {
return [
{
source: "/ingest/static/:path*",
destination: `${process.env.POSTHOG_PROXY_ASSET_HOST}/static/:path*`,
},
{
source: "/ingest/:path*",
destination: `${process.env.POSTHOG_PROXY_INGEST_HOST}/:path*`,
},
];
}
return [];
},It matters for two reasons: ad-blockers don't block same-origin requests, and the kit's strict CSP doesn't have to allow posthog.com in connect-src. It's optional: without the two env vars the rewrites no-op and PostHog calls go to whatever NEXT_PUBLIC_POSTHOG_HOST resolves to (CSP must then allow that origin).
Health Probes
Two endpoints feed external uptime monitors. They are oRPC procedures wrapped as Next route handlers:
| Endpoint | Purpose | Used by |
|---|---|---|
/api/healthz | Liveness. Always returns 200 if the server is responding. | Docker Compose healthcheck |
/api/health | Readiness. Also pings the database. | Fly [[http_service.checks]], Render healthCheckPath |
See the API page for the procedure-level details.
Env Vars At A Glance
| Variable | When required | What it controls |
|---|---|---|
NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_UI_HOST | Always (or omit all three to disable) | Client init, server SDK, log exporter URL |
POSTHOG_PROXY_INGEST_HOST, POSTHOG_PROXY_ASSET_HOST | Optional | Same-origin reverse proxy via /ingest/* |
POSTHOG_API_KEY, POSTHOG_PROJECT_ID | Optional, build-time | Source-map upload at next build |
Where To Go Next
Analytics
The product-events half of the same PostHog backbone: track, identify, setGroup, the typed event catalog.
Security
The abuse-protection layer that flows results through the same log helper documented here.
API
The health endpoints in context, plus the Stripe webhook handler that's the canonical structured-logging reference.
Deployment
Where the build-time POSTHOG_API_KEY and POSTHOG_PROJECT_ID actually get set per host.
