Monitoring
Logs, error tracking, and health visibility.
Monitoring 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;Source-map upload runs at next build time, and in this kit that means Vercel or CI only. Both keys are read during the build, so they're only useful where the build itself runs with them present: the Vercel build (set them in the Environment Variables panel) or a CI build such as GitHub Actions (set them as CI secrets — the kit's Docker/GHCR image build in .github/workflows/docker.yml is where you'd wire an upload). They are personal API keys, so they are deliberately not accepted as Docker build args — apps/Dockerfile has no POSTHOG_API_KEY build arg, and there won't be one (baking a personal key into image build args or layers is a leak risk). On self-hosted Docker targets (Docker Compose, Fly, Render, GHCR images) the image build therefore does not upload source maps, and production stack traces stay minified unless you add your own upload step to your build pipeline.
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. Each is a plain Next.js route handler (apps/web/app/api/health*/route.ts) — a small GET that returns JSON, not an oRPC procedure:
| Endpoint | Purpose | Used by |
|---|---|---|
/api/healthz | Liveness. Always returns 200 if the server is responding. | Lightweight liveness probes |
/api/health | Readiness. Returns 200 only when the app booted with valid config and the database is reachable; returns 503 otherwise. | Docker Compose healthcheck, Fly [[http_service.checks]], Render healthCheckPath |
/api/health is the deploy gate. Because the app no longer hard-crashes on invalid configuration (it renders a branded "Configuration error" page instead — see Deployment), the readiness probe is what fails a bad deploy: it returns 503 when isMisconfigured() is true or the DB probe fails, so a health-gated platform (Fly, Render) marks the release unhealthy and rolls back instead of sending traffic to a misconfigured deploy. Point your external uptime monitor at /api/health too, and alert on a sustained 503.
See the API page for how the health handlers fit alongside the webhook route.
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, Vercel/CI build only | Source-map upload at next build. Not accepted as Docker build args (personal keys), so self-hosted image builds skip the upload. |
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.