Skip to content
Operate And Ship

Monitoring

Error tracking, OpenTelemetry logs, and source maps on the same PostHog project as Analytics. Enabled when NEXT_PUBLIC_POSTHOG_KEY is set; host vars default to US Cloud. Health probes for uptime monitors.

Last updated on

5 min read

On This Page

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 to ${NEXT_PUBLIC_POSTHOG_HOST}/i/v1/logs
Source mapswithPostHogConfig in apps/web/next.config.tsProduction source maps uploaded at build, then deleted locally

Product events, identify, and session replay live on Analytics. This page is the ops half of the same PostHog project.

Error Tracking

Three surfaces feed PostHog error tracking: two you call, one automatic.

Client. Use captureError from @syntaxkit/analytics/client in any error boundary. The kit's global boundary at apps/web/app/global-error.tsx does 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 links each exception to the active session replay, so a dashboard error comes with the stack and the recent user activity that led to it.

Server. Use captureServerError(error, distinctId?, context?) from @syntaxkit/analytics/server when you catch an error and still want PostHog to see it. It awaits a flush so serverless invocations do not 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 runs on every unhandled server render, route handler, or server action error. You do not wrap those paths in try/catch for capture to work.

You do not need a Sentry-style wrapper for unhandled server exceptions. Next's onRequestError covers those boundaries. Use manual captureServerError only when you already handled the exception and still want PostHog to know.

What the onRequestError hook does

On each error it runs three steps in order:

  1. Reads the PostHog distinctId and sessionId from the request cookie with getPostHogContext(cookieString), so the exception ties to the right user and session replay.
  2. Calls log.error with request and route context (path, method, router kind, route path/type, error name, truncated stack).
  3. Calls posthog.captureException(err, distinctId) and force-flushes the OTel logger so logs reach PostHog before the runtime exits.
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,
        error_stack: err.stack?.slice(0, 500) ?? "",
        request_path: request.path,
        request_method: request.method,
        router_kind: context.routerKind,
        route_path: context.routePath,
        route_type: context.routeType,
      },
    });

    await posthog.captureException(err, ctx.distinctId);
    await loggerProvider?.forceFlush();
  }
};

Structured Logging

Use log from @syntaxkit/analytics/logger. It wraps an OpenTelemetry logger and ships 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 tracing. Quiet in production.
infoNormal operations: webhooks processed, tasks completed.
warnRecoverable problems: rate-limit hit, retryable failure.
errorServer errors that produced an end-user-visible failure.
fatalUnrecoverable failures that need immediate attention.

Pass posthogDistinctId and sessionId when you have them. They link a log line to the user who triggered it. attributes is freeform structured data; PostHog Logs shows those keys as filterable columns.

The LoggerProvider lives in apps/web/instrumentation.ts with a BatchLogRecordProcessor, so logs ship in batches. The Stripe webhook handler and the auto error hook are the reference call sites.

How logs are batched and flushed

apps/web/instrumentation.ts builds the provider only when analytics env is set. Route handlers that must not lose a log (webhooks, onRequestError) call loggerProvider?.forceFlush() after the critical write.

Source Maps

When POSTHOG_API_KEY and POSTHOG_PROJECT_ID are set, @posthog/nextjs-config wraps the Next config in apps/web/next.config.ts. At next build it uploads source maps, then deletes them locally 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 only at runtime. Pass them as build-time vars on Vercel (Environment Variables), Fly ([build.args] in fly.toml), Render (envVars in render.yaml), or Docker (--build-arg). If they are runtime-only, the upload silently no-ops and PostHog stack traces stay minified.

The Reverse Proxy

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 your 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 [];
},

Same-origin requests avoid many ad-blockers, and the kit's strict CSP does not need posthog.com in connect-src. The proxy is optional. Without the two env vars, rewrites no-op and the client uses NEXT_PUBLIC_POSTHOG_HOST directly (CSP must allow that origin).

Health Probes

Uptime monitors should hit the plain Next routes. Matching oRPC procedures (readinessCheck, livenessCheck) exist on /rpc for in-app checks; monitors that do not speak RPC use the HTTP paths below.

EndpointPurposeUsed by
/api/healthzLiveness. Always returns 200 if the process is up.Manual / custom probes
/api/healthReadiness. Returns 503 when the app is misconfigured or the database ping fails.Docker Compose healthcheck, Fly [[http_service.checks]], Render healthCheckPath

See API for procedure-level detail.

Env Vars At A Glance

VariableWhen requiredWhat it controls
NEXT_PUBLIC_POSTHOG_KEYSet to enable; omit to disableTurns analytics and monitoring on. Host vars alone do nothing.
NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_UI_HOSTOptionalIngest and UI hosts. Default to US Cloud when unset.
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

Also useful: Analytics for product events on the same PostHog project, and Deployment for where build-time POSTHOG_API_KEY and POSTHOG_PROJECT_ID are set per host.

Was this page helpful?

On this page