Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Operate And Ship

Monitoring

Logs, error tracking, and health visibility.

Last updated on

4 min read

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;

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:

EndpointPurposeUsed by
/api/healthzLiveness. Always returns 200 if the server is responding.Docker Compose healthcheck
/api/healthReadiness. Also pings the database.Fly [[http_service.checks]], Render healthCheckPath

See the API page for the procedure-level details.

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, build-timeSource-map upload at next build

Where To Go Next

Was this page helpful?

On this page