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.

The Three Pipelines

Three pipelines into PostHog: errors via captureError and onRequestError, OTLP logs to PostHog Logs, and source maps via withPostHogConfig at build time.
PipelineSurfaceWhat it captures
ErrorscaptureError (browser), captureServerError (Node), onRequestError (auto)JS exceptions, server route/render/action errors, manually reported errors
Logslog.{trace,debug,info,warn,error,fatal} from @syntaxkit/analytics/loggerStructured server logs via OTLP, sent to ${NEXT_PUBLIC_POSTHOG_HOST}/i/v1/logs
Source mapswithPostHogConfig in apps/web/next.config.tsProduction 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:

  1. Extracts the PostHog distinctId and sessionId from the request cookie via getPostHogContext(cookieString), so the exception ties back to the right user and session replay.
  2. Calls log.error with full request and route context (path, method, router kind, route type, error name, truncated stack).
  3. 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 },
});
LevelUse for
traceHighest-frequency, lowest-importance traces (rarely used).
debugDevelopment-time tracing; quiet in production.
infoNormal operations: webhooks processed, tasks completed.
warnRecoverable problems: rate-limit hit, retryable failure.
errorServer-side errors that produced an end-user-visible failure.
fatalUnrecoverable 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:

EndpointPurposeUsed by
/api/healthzLiveness. Always returns 200 if the server is responding.Lightweight liveness probes
/api/healthReadiness. 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

VariableWhen requiredWhat it controls
NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_UI_HOSTAlways (or omit all three to disable)Client init, server SDK, log exporter URL
POSTHOG_PROXY_INGEST_HOST, POSTHOG_PROXY_ASSET_HOSTOptionalSame-origin reverse proxy via /ingest/*
POSTHOG_API_KEY, POSTHOG_PROJECT_IDOptional, Vercel/CI build onlySource-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

On this page