From dc5b3655c6bbf2db3927c0973c1b91d57275b831 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 31 Aug 2026 09:45:57 -0400 Subject: [PATCH 01/12] docs(node): document distributed tracing Covers withSpan/startSpan/getActiveSpan, the traces option, W3C trace context propagation, and the person/session join. --- contents/docs/libraries/node/index.mdx | 142 +++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index 04eba8693f4b..2b2afe4002c3 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -314,6 +314,148 @@ Properties and `distinctId` passed directly to `capture` take precedence over re If you're using [PostHog JS](/docs/libraries/js) on the frontend, configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your Express backend hostname so browser requests include `X-POSTHOG-SESSION-ID` and `X-POSTHOG-DISTINCT-ID`, which the Express middleware reads automatically. +## Distributed tracing + +> Requires `posthog-node` version >= 5.52.0. + +Tracing records **spans** — timed units of work — so you can see where time went in a request and how work fans out across your services. Spans created inside a request context automatically carry the person and session they belong to, so a slow trace links back to the user who experienced it. + +Tracing is off until you set the `traces` option. No OpenTelemetry dependency is required. + +```ts file=server.ts +import { PostHog } from 'posthog-node' + +const posthog = new PostHog('', { + host: '', + traces: { + serviceName: 'checkout-api', + }, +}) +``` + +Set `serviceName` — PostHog groups operations by service and span name. + +### Creating spans + +`withSpan` runs a callback with a span active for its duration and ends the span for you: at return for a synchronous callback, when the promise settles for an async one. Spans created inside the callback nest underneath it automatically. + +```ts +await posthog.withSpan('POST /checkout', async (span) => { + span.setAttribute('plan', user.plan) + + const order = await posthog.withSpan('create-order', () => createOrder(cart)) + await posthog.withSpan('charge-card', () => stripe.charge(order)) + + return order +}) +``` + +If the callback throws or rejects, the span records the exception, its status is set to `error`, and your original error is rethrown unchanged. + +Use `startSpan` for work that can't wrap a callback. **`startSpan` does not make the span active**, so spans created afterwards are not its children unless you pass `parent` explicitly — and you must call `end()` yourself. + +```ts +const span = posthog.startSpan('background-sync', { attributes: { queue: 'emails' } }) + +// Explicitly parent a child to a span that isn't active. +const child = posthog.startSpan('send-batch', { parent: span }) +child.end() + +span.end() +``` + +`getActiveSpan()` returns the span currently active on this execution path, or `null` outside any `withSpan` callback. + +### Span names and attributes + +Span names should be low-cardinality operation names — `GET /users/:id`, not `GET /users/123`. Variable values belong in attributes, which accept strings, numbers, booleans, bigints, and arrays of those. + +```ts +await posthog.withSpan('GET /users/:id', { kind: 'server' }, async (span) => { + span.setAttributes({ 'user.id': id, 'db.rows': rows.length }) + span.addEvent('cache-miss') + + if (rows.length === 0) { + span.setStatus('error', 'user not found') + } +}) +``` + +| Method | Description | +|---|---| +| `setAttribute(key, value)` | Set a single attribute | +| `setAttributes(attributes)` | Merge several attributes at once | +| `addEvent(name, attributes?, timestamp?)` | Record a timestamped event within the span | +| `setStatus(status, message?)` | Set the outcome: `'ok'` or `'error'` | +| `recordException(error)` | Attach an exception event and set status to `error` | +| `updateName(name)` | Replace the span name, e.g. once a route template resolves | +| `traceparent()` | This span's W3C `traceparent` header value | +| `end(endTime?)` | End the span and queue it for export | + +Set `kind` to describe what the work is: `'internal'` (default), `'server'` for an inbound request, `'client'` for an outbound call, `'producer'` or `'consumer'` for queue work. + +### Tracing across services + +Spans use [W3C Trace Context](https://www.w3.org/TR/trace-context/), so a trace can span several services. Pass an inbound `traceparent` header as `parent` to continue a trace another service started, and send `span.traceparent()` onward when you call out. + +```ts file=server.ts +app.post('/checkout', async (req, res) => { + await posthog.withSpan('POST /checkout', { kind: 'server', parent: req.get('traceparent') }, async (span) => { + const traceparent = span.traceparent() + + await fetch('https://payments.internal/charge', { + method: 'POST', + headers: traceparent ? { traceparent } : {}, + }) + + res.json({ status: 'ok' }) + }) +}) +``` + +A missing or malformed `traceparent` starts a new trace rather than throwing. + +### Linking traces to people and sessions + +Spans created inside a PostHog request context automatically carry `posthogDistinctId` and `sessionId` attributes, which is what makes a trace reachable from a person or a session replay. Use the [Express middleware](#add-request-context-to-express) or [`withContext`](#contexts): + +```ts +posthog.withContext({ distinctId: user.id, sessionId }, async () => { + await posthog.withSpan('POST /checkout', () => processOrder()) +}) +``` + +Spans created outside a request context simply omit those attributes. + +### Configuration + +| Option | Default | Description | +|---|---|---| +| `serviceName` | – | Name of the service producing spans. Set this | +| `serviceVersion` | – | Version of the service | +| `environment` | – | Deployment environment, e.g. `production` | +| `resourceAttributes` | – | Extra OTLP resource attributes. Takes precedence over the fields above | +| `flushIntervalMs` | `5000` | How often queued spans are exported | +| `maxExportBatchSize` | `512` | Maximum spans per request | +| `maxQueueSize` | `2048` | Maximum spans held in memory. Spans beyond this are dropped | + +### Shutdown and short-lived processes + +`shutdown()` exports spans that have already ended. Spans still open at that point are discarded, so end your spans before shutting down — `withSpan` does this for you. + +```ts +export const handler = async () => { + await posthog.withSpan('handler', () => doWork()) + await posthog.shutdown() +} +``` + + + +On edge runtimes, spans nest across `await` only when you pass `parent` explicitly. The Node runtime tracks the active span with `AsyncLocalStorage`; the edge build cannot, so a span created after an `await` starts a new trace unless it is given a parent. + + + ## Feature flags import FeatureFlagsLibsIntro from "../_snippets/feature-flags-libs-intro.mdx" From b98df6734bd4aaebee025e7a52660c271e1d25db Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 31 Aug 2026 14:02:03 -0400 Subject: [PATCH 02/12] docs(node): document flush() as a span drain --- contents/docs/libraries/node/index.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index 2b2afe4002c3..be8a4205400c 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -441,12 +441,14 @@ Spans created outside a request context simply omit those attributes. ### Shutdown and short-lived processes -`shutdown()` exports spans that have already ended. Spans still open at that point are discarded, so end your spans before shutting down — `withSpan` does this for you. +Both `flush()` and `shutdown()` export spans that have already ended. Spans still open at that point are discarded, so end your spans before either call — `withSpan` does this for you. + +In a serverless handler, call `flush()`: the container is reused across invocations, so `shutdown()` would throw away the connection pool and the flag cache. Events and spans are flushed concurrently, so it costs one round trip, not two. ```ts export const handler = async () => { await posthog.withSpan('handler', () => doWork()) - await posthog.shutdown() + await posthog.flush() } ``` From 08f7df04f38f71eb182d784a85abf3642ffe2108 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 31 Aug 2026 14:50:53 -0400 Subject: [PATCH 03/12] docs(node): list tracing in the library comparison Adds a tracing key to the node page's features block and a Tracing column to the SDK comparison table. --- contents/docs/libraries/node/index.mdx | 7 ++++--- src/components/LibraryComparison/index.tsx | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index be8a4205400c..2629d00e7e22 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -13,6 +13,7 @@ features: surveys: false aiObservability: true errorTracking: true + tracing: true --- If you're working with Node.js (versions 20+), the official `posthog-node` library is the simplest way to integrate your software with PostHog. This library uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your web app or other server-side application that needs performance. And in addition to event capture, [feature flags](/docs/feature-flags) are supported as well. @@ -320,7 +321,7 @@ If you're using [PostHog JS](/docs/libraries/js) on the frontend, configure [`tr Tracing records **spans** — timed units of work — so you can see where time went in a request and how work fans out across your services. Spans created inside a request context automatically carry the person and session they belong to, so a slow trace links back to the user who experienced it. -Tracing is off until you set the `traces` option. No OpenTelemetry dependency is required. +Tracing is off until you set the `traces` option. No OpenTelemetry dependency is required. For what you can do with spans once they arrive, see [Distributed tracing](/docs/distributed-tracing/start-here). ```ts file=server.ts import { PostHog } from 'posthog-node' @@ -350,7 +351,7 @@ await posthog.withSpan('POST /checkout', async (span) => { }) ``` -If the callback throws or rejects, the span records the exception, its status is set to `error`, and your original error is rethrown unchanged. +If the callback throws or rejects, the span records the exception, its status is set to `error`, and your original error propagates unchanged. Use `startSpan` for work that can't wrap a callback. **`startSpan` does not make the span active**, so spans created afterwards are not its children unless you pass `parent` explicitly — and you must call `end()` yourself. @@ -417,7 +418,7 @@ A missing or malformed `traceparent` starts a new trace rather than throwing. ### Linking traces to people and sessions -Spans created inside a PostHog request context automatically carry `posthogDistinctId` and `sessionId` attributes, which is what makes a trace reachable from a person or a session replay. Use the [Express middleware](#add-request-context-to-express) or [`withContext`](#contexts): +Spans created inside a PostHog request context automatically carry `posthogDistinctId` and `sessionId` attributes, which is what makes a trace reachable from a person or a Session Replay recording. Use the [Express middleware](#add-request-context-to-express) or [`withContext`](#contexts): ```ts posthog.withContext({ distinctId: user.id, sessionId }, async () => { diff --git a/src/components/LibraryComparison/index.tsx b/src/components/LibraryComparison/index.tsx index cd2f7b84a7f0..b9d76a38a0ee 100644 --- a/src/components/LibraryComparison/index.tsx +++ b/src/components/LibraryComparison/index.tsx @@ -26,6 +26,7 @@ type LibraryFeatures = { aiObservability?: boolean errorTracking?: boolean logs?: boolean + tracing?: boolean } export const LibraryComparison = () => { @@ -59,6 +60,7 @@ export const LibraryComparison = () => { groupAnalytics errorTracking logs + tracing } } } @@ -81,6 +83,7 @@ export const LibraryComparison = () => { { name: 'Group analytics', width: '1fr', align: 'center' as const }, { name: 'Error tracking', width: '1fr', align: 'center' as const }, { name: 'Logs', width: '1fr', align: 'center' as const }, + { name: 'Tracing', width: '1fr', align: 'center' as const }, ] const rows = sdks.nodes @@ -119,6 +122,9 @@ export const LibraryComparison = () => { { content: renderAvailability(lib.frontmatter.features?.logs), }, + { + content: renderAvailability(lib.frontmatter.features?.tracing), + }, ], })) From ac32cc82ce9d7f4e5236d9daadfb69d7132bb685 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 31 Aug 2026 14:50:55 -0400 Subject: [PATCH 04/12] docs(tracing): add the posthog-node route to the install guides The Node.js and Next.js guides now cover posthog-node alongside OpenTelemetry, and the pages that claimed no PostHog packages are needed say where that no longer holds. --- .../installation/index.mdx | 4 +- .../installation/nextjs.mdx | 129 +++++++++++++++++- .../installation/nodejs.mdx | 128 ++++++++++++++++- .../docs/distributed-tracing/start-here.mdx | 6 +- 4 files changed, 251 insertions(+), 16 deletions(-) diff --git a/contents/docs/distributed-tracing/installation/index.mdx b/contents/docs/distributed-tracing/installation/index.mdx index fb8d223b8d4b..e8f0c7638aac 100644 --- a/contents/docs/distributed-tracing/installation/index.mdx +++ b/contents/docs/distributed-tracing/installation/index.mdx @@ -4,7 +4,9 @@ title: Install OpenTelemetry tracing import TracingInstallationPlatforms from './_snippets/installation-platforms' -PostHog Tracing works with any OpenTelemetry-compatible client. You don't need any PostHog-specific packages – use standard OpenTelemetry libraries to export your spans. +PostHog Tracing works with any OpenTelemetry-compatible client – point its OTLP trace exporter at PostHog and you're done, with no PostHog-specific packages. + +On Node.js, `posthog-node` can also create and export spans on its own, with no OpenTelemetry dependency. See the [Node.js guide](/docs/distributed-tracing/installation/nodejs) for both routes. ## Platforms diff --git a/contents/docs/distributed-tracing/installation/nextjs.mdx b/contents/docs/distributed-tracing/installation/nextjs.mdx index cc17b066fc63..e018f74b8758 100644 --- a/contents/docs/distributed-tracing/installation/nextjs.mdx +++ b/contents/docs/distributed-tracing/installation/nextjs.mdx @@ -7,9 +7,120 @@ showStepsToc: true import { Steps, Step } from 'components/Docs/Steps' import TracingNextSteps from './_snippets/tracing-next-steps.mdx' +There are two ways to send spans from a Next.js app. + +| | `@posthog/next` | OpenTelemetry | +| --------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------- | +| **Packages** | The SDK you already use for analytics | Six `@opentelemetry/*` packages | +| **Instrumentation** | Manual – you wrap the operations you care about | Manual, plus auto-instrumentation for HTTP, Express, databases, and more | +| **Person and session join** | Automatic – the server client resolves the request identity | Set the attributes yourself | +| **Setup** | Your existing server module | An `instrumentation.ts` file and, before Next.js 15, a config change | + +Use `@posthog/next` if PostHog is the only place your spans go. Use OpenTelemetry if you already run it, or if you want auto-instrumentation of the libraries you call. + +## With `@posthog/next` + +> **Minimum version:** `posthog-node@5.52.0` or later. `@posthog/next` builds on it, so this applies to both. + - + + +If you haven't set up `@posthog/next` yet, follow the [Next.js installation guide](/docs/libraries/next-js) first. Then add the `traces` option to your shared server module. Tracing is off until you set it, and there's no OpenTelemetry dependency to add. + +```typescript file=lib/posthog.ts +import 'server-only' +import { createPostHog } from '@posthog/next' +import { auth } from '@/auth' + +export const { getPostHog } = createPostHog({ + getDistinctId: async () => (await auth())?.user?.id, + options: { + traces: { + serviceName: 'my-nextjs-app', + environment: process.env.VERCEL_ENV ?? 'development', + }, + }, +}) +``` + +Using `posthog-node` directly rather than `@posthog/next`? The setup is the same – see the [Node.js guide](/docs/distributed-tracing/installation/nodejs). + + + + + +`withSpan` runs a callback with a span active for its duration and ends the span for you, including when it throws. Spans created inside the callback nest underneath it automatically. + +```typescript file=app/api/checkout/route.ts +import { after } from 'next/server' +import { getPostHog } from '@/lib/posthog' + +export async function POST() { + const posthog = await getPostHog() + + const order = await posthog.withSpan('POST /api/checkout', { kind: 'server' }, async (span) => { + span.setAttribute('cart.items', cart.length) + + const created = await posthog.withSpan('create-order', () => createOrder(cart)) + await posthog.withSpan('charge-card', () => stripe.charge(created)) + + return created + }) + + // Flush before the function freezes + after(async () => { + await posthog.flush() + }) + + return Response.json(order) +} +``` + +Because these spans come from the request-scoped client, they carry the person and session that `getPostHog()` resolved, which is what makes a trace reachable from a person or a Session Replay recording. + +Span names should be low-cardinality operation names – `POST /api/checkout`, not a name with an order ID in it. Variable values belong in attributes. See the [Node.js SDK docs](/docs/libraries/node#distributed-tracing) for the full span API. + + + + + +On a serverless platform, the function can freeze before queued spans are exported. `@posthog/next` registers a `waitUntil` flush when you capture an event, but creating a span doesn't trigger one, so call `flush()` at the end of a handler that produces spans. + +```typescript +import { after } from 'next/server' + +after(async () => { + await posthog.flush() +}) +``` + +`after()` is stable in Next.js 15.1+ (available as `unstable_after` in 15.0). On Next.js 14 and earlier, `await posthog.flush()` before returning the response instead. + +Call `flush()`, not `shutdown()` – the container is reused across invocations, so `shutdown()` would throw away the connection pool and the flag cache. + + + + + +The Node.js runtime tracks the active span with `AsyncLocalStorage`, so nested `withSpan` calls parent themselves. The edge runtime – middleware, and routes with `export const runtime = 'edge'` – can't, so a span created after an `await` starts a new trace unless you pass `parent` explicitly: + +```typescript +const parent = posthog.startSpan('handler', { kind: 'server' }) +const child = posthog.startSpan('fetch-config', { parent }) +child.end() +parent.end() +``` + + + + + +## With OpenTelemetry + + + + For the complete SDK reference, see the [OpenTelemetry JavaScript docs](https://opentelemetry.io/docs/languages/js/). @@ -21,7 +132,7 @@ npm install @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk- - + You'll need your PostHog project token to authenticate trace requests. This is the same token you use for capturing events with the PostHog SDK. @@ -31,7 +142,7 @@ You can find your project token in [Project settings](https://app.posthog.com/se - + > **Note:** This step is only needed on Next.js 13.2–14.x. For Next.js 15 and later, `instrumentation.ts` is enabled by default and the `experimental.instrumentationHook` option is deprecated — remove it from your config if it's set. @@ -50,7 +161,7 @@ module.exports = nextConfig - + Create an `instrumentation.ts` (or `instrumentation.js`) file in the root of your project (or inside `src/` if you use that folder). @@ -99,7 +210,7 @@ new OTLPTraceExporter({ - + Wrap the operations you want to measure in spans, and attach attributes for context. Then flush the provider before the serverless function freezes. @@ -146,9 +257,13 @@ export async function GET() { - + + + + + -Once everything is configured, confirm spans are reaching PostHog: +Whichever route you took: 1. Run your application and trigger the instrumented code 2. Open the PostHog Tracing interface diff --git a/contents/docs/distributed-tracing/installation/nodejs.mdx b/contents/docs/distributed-tracing/installation/nodejs.mdx index f1ee06a74e67..82af6674524f 100644 --- a/contents/docs/distributed-tracing/installation/nodejs.mdx +++ b/contents/docs/distributed-tracing/installation/nodejs.mdx @@ -5,11 +5,121 @@ showStepsToc: true --- import { Steps, Step } from 'components/Docs/Steps' +import InstallNodePackageManagers from '../../integrate/_snippets/install-node-package-managers.mdx' import TracingNextSteps from './_snippets/tracing-next-steps.mdx' +There are two ways to send spans from Node.js. + +| | `posthog-node` | OpenTelemetry | +| ------------------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------- | +| **Packages** | The SDK you already use for analytics | Six `@opentelemetry/*` packages | +| **Instrumentation** | Manual – you wrap the operations you care about | Manual, plus auto-instrumentation for HTTP, Express, databases, and more | +| **Person and session join** | Automatic inside a PostHog request context | Set the attributes yourself | + +Use `posthog-node` if PostHog is the only place your spans go. Use OpenTelemetry if you already run it, or if you want auto-instrumentation of the libraries you call. + +Both routes send OTLP spans to the same endpoint, so you can start with one and switch later without losing your traces. + +## With `posthog-node` + +> **Minimum version:** `posthog-node@5.52.0` or later. + + + + + + + + + + + +Tracing is off until you set the `traces` option. There's no OpenTelemetry dependency to add. + +```javascript +import { PostHog } from 'posthog-node' + +export const posthog = new PostHog('', { + host: '', + traces: { + serviceName: 'checkout-api', + environment: 'production', + }, +}) +``` + +| Option | Description | +| -------------------- | -------------------------------------------------------------------- | +| `serviceName` | Identifies the service in the Tracing UI. Maps to `service.name` | +| `serviceVersion` | Release version. Maps to `service.version` | +| `environment` | Deployment environment, e.g. `production`. Maps to `deployment.environment` | +| `resourceAttributes` | Additional OpenTelemetry resource attributes | + +Use your **project token** (the same one you use for capturing events), not a [personal API key](/docs/api#authentication). + +See the [Node.js SDK docs](/docs/libraries/node#configuration) for batching and queue options. + + + + + +`withSpan` runs a callback with a span active for its duration and ends the span for you. Spans created inside the callback nest underneath it automatically. + +```javascript +await posthog.withSpan('POST /checkout', { kind: 'server' }, async (span) => { + span.setAttribute('plan', user.plan) + + const order = await posthog.withSpan('create-order', () => createOrder(cart)) + await posthog.withSpan('charge-card', () => stripe.charge(order)) + + return order +}) +``` + +If the callback throws or rejects, the span records the exception, its status is set to `error`, and your original error propagates unchanged. + +Span names should be low-cardinality operation names – `GET /users/:id`, not `GET /users/123`. Variable values belong in attributes. + +For work that can't wrap a callback, `startSpan` returns a span you end yourself. See the [Node.js SDK docs](/docs/libraries/node#distributed-tracing) for the full span API and for continuing a trace across services with W3C `traceparent` headers. + + + + + +Spans created inside a PostHog request context carry `posthogDistinctId` and `sessionId` attributes, which is what makes a trace reachable from a person or a Session Replay recording. + +```javascript +posthog.withContext({ distinctId: user.id, sessionId }, async () => { + await posthog.withSpan('POST /checkout', () => processOrder()) +}) +``` + +If you use Express, the [PostHog middleware](/docs/libraries/node#add-request-context-to-express) sets this up for every request, and reads the `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers that [`tracing_headers`](/docs/libraries/js/config#tracing-headers) sends from the browser. + + + + + +Queued spans are exported on an interval, so a short-lived process can exit before they're sent. Both `flush()` and `shutdown()` export spans that have already ended. + +```javascript +export const handler = async () => { + await posthog.withSpan('handler', () => doWork()) + await posthog.flush() +} +``` + +In a serverless handler, call `flush()` rather than `shutdown()`: the container is reused across invocations, so `shutdown()` would throw away the connection pool and the flag cache. Call `shutdown()` when the process is genuinely exiting. + + + + + +## With OpenTelemetry + - + For the complete SDK reference, see the [OpenTelemetry JavaScript docs](https://opentelemetry.io/docs/languages/js/). @@ -21,7 +131,7 @@ npm install @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk- - + You'll need your PostHog project token to authenticate trace requests. This is the same token you use for capturing events with the PostHog SDK. @@ -31,7 +141,7 @@ You can find your project token in [Project settings](https://app.posthog.com/se - + Set up the OpenTelemetry SDK to export spans to PostHog over OTLP HTTP. @@ -72,7 +182,7 @@ OTEL_SERVICE_NAME="my-service" - + Wrap the operations you want to measure in spans, and attach attributes for context. @@ -98,11 +208,17 @@ function chargeCustomer(customerId) { } ``` +To join these spans to a person or a Session Replay recording, set `posthogDistinctId` and `sessionId` attributes yourself, from the `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers that [`tracing_headers`](/docs/libraries/js/config#tracing-headers) sends from the browser. + - + + + + + -Once everything is configured, confirm spans are reaching PostHog: +Whichever route you took: 1. Run your application and trigger the instrumented code 2. Open the PostHog Tracing interface diff --git a/contents/docs/distributed-tracing/start-here.mdx b/contents/docs/distributed-tracing/start-here.mdx index ebb40f470bbf..08f2d93bb514 100644 --- a/contents/docs/distributed-tracing/start-here.mdx +++ b/contents/docs/distributed-tracing/start-here.mdx @@ -11,12 +11,14 @@ import { QuestLog, QuestLogItem } from "components/Docs/QuestLog"; -PostHog Distributed Tracing works with any OpenTelemetry client. No PostHog-specific packages are required. Use the OTel SDKs you already have, point your trace exporter at PostHog's HTTP endpoint, and add your project token. +PostHog Distributed Tracing works with any OpenTelemetry client. Use the OTel SDKs you already have, point your trace exporter at PostHog's HTTP endpoint, and add your project token. + +On Node.js you can skip OpenTelemetry entirely: `posthog-node` creates and exports spans itself, and spans made inside a request context carry the person and session they belong to. See the [Node.js guide](/docs/distributed-tracing/installation/nodejs). Set these on your OpenTelemetry SDK: From 9b39286be51b46f4281ab329ebd190dc0b6b56e6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 31 Aug 2026 15:07:30 -0400 Subject: [PATCH 05/12] docs(tracing): retitle the install section now that Node has a non-OTel route --- contents/docs/distributed-tracing/installation/index.mdx | 2 +- src/navs/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contents/docs/distributed-tracing/installation/index.mdx b/contents/docs/distributed-tracing/installation/index.mdx index e8f0c7638aac..6f13f897d21c 100644 --- a/contents/docs/distributed-tracing/installation/index.mdx +++ b/contents/docs/distributed-tracing/installation/index.mdx @@ -1,5 +1,5 @@ --- -title: Install OpenTelemetry tracing +title: Install tracing --- import TracingInstallationPlatforms from './_snippets/installation-platforms' diff --git a/src/navs/index.js b/src/navs/index.js index 2d204f7b4a02..c12633a3414a 100644 --- a/src/navs/index.js +++ b/src/navs/index.js @@ -8334,7 +8334,7 @@ export const docsMenu = { featured: true, }, { - name: 'Install OpenTelemetry tracing', + name: 'Install tracing', url: '/docs/distributed-tracing/installation', icon: 'IconCode', color: 'blue', From 0fb4076fe85f88be0d80f7aa99f5071e0878b128 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 31 Aug 2026 15:39:13 -0400 Subject: [PATCH 06/12] docs(tracing): say spans are server-only and fix the route-choice guidance --- contents/docs/distributed-tracing/installation/nextjs.mdx | 8 +++++--- contents/docs/distributed-tracing/installation/nodejs.mdx | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/contents/docs/distributed-tracing/installation/nextjs.mdx b/contents/docs/distributed-tracing/installation/nextjs.mdx index e018f74b8758..244a4db5e87a 100644 --- a/contents/docs/distributed-tracing/installation/nextjs.mdx +++ b/contents/docs/distributed-tracing/installation/nextjs.mdx @@ -12,15 +12,17 @@ There are two ways to send spans from a Next.js app. | | `@posthog/next` | OpenTelemetry | | --------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------- | | **Packages** | The SDK you already use for analytics | Six `@opentelemetry/*` packages | -| **Instrumentation** | Manual – you wrap the operations you care about | Manual, plus auto-instrumentation for HTTP, Express, databases, and more | +| **Instrumentation** | Manual – you wrap the operations you care about | Manual, plus auto-instrumentation for HTTP, databases, and more | | **Person and session join** | Automatic – the server client resolves the request identity | Set the attributes yourself | | **Setup** | Your existing server module | An `instrumentation.ts` file and, before Next.js 15, a config change | -Use `@posthog/next` if PostHog is the only place your spans go. Use OpenTelemetry if you already run it, or if you want auto-instrumentation of the libraries you call. +Pick OpenTelemetry if you already run it, or if you want spans from your HTTP and database calls without writing them yourself. Pick `@posthog/next` if PostHog is your only tracing backend – the tracing API is `posthog-node`'s, reached through the server client you already have, so there's nothing new to install. ## With `@posthog/next` -> **Minimum version:** `posthog-node@5.52.0` or later. `@posthog/next` builds on it, so this applies to both. +> **Server-side only.** Spans come from `posthog-node`, which `@posthog/next` uses for its server client. There is no browser span API, so Client Components, `instrumentation-client.ts`, and anything else running in the browser can't create spans. + +> **Minimum version:** an `@posthog/next` release built on `posthog-node` 5.52.0 or later. Run `npm ls posthog-node` to see which version you resolve. diff --git a/contents/docs/distributed-tracing/installation/nodejs.mdx b/contents/docs/distributed-tracing/installation/nodejs.mdx index 82af6674524f..0bbc59602a45 100644 --- a/contents/docs/distributed-tracing/installation/nodejs.mdx +++ b/contents/docs/distributed-tracing/installation/nodejs.mdx @@ -16,7 +16,7 @@ There are two ways to send spans from Node.js. | **Instrumentation** | Manual – you wrap the operations you care about | Manual, plus auto-instrumentation for HTTP, Express, databases, and more | | **Person and session join** | Automatic inside a PostHog request context | Set the attributes yourself | -Use `posthog-node` if PostHog is the only place your spans go. Use OpenTelemetry if you already run it, or if you want auto-instrumentation of the libraries you call. +Pick OpenTelemetry if you already run it, or if you want spans from your HTTP server and database driver without writing them yourself. Pick `posthog-node` if PostHog is your only tracing backend and you'd rather instrument a handful of operations by hand than add an exporter pipeline. Both routes send OTLP spans to the same endpoint, so you can start with one and switch later without losing your traces. From dbe06d305638a5174e0107f40599dcb36717f710 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 31 Aug 2026 15:40:38 -0400 Subject: [PATCH 07/12] docs(tracing): drop the @posthog/next route, point at the Node guide The SDK PR scopes tracing to posthog-node, so the Next.js page keeps its OpenTelemetry steps and only notes that server-side code can use the Node span API. --- .../installation/nextjs.mdx | 131 ++---------------- 1 file changed, 8 insertions(+), 123 deletions(-) diff --git a/contents/docs/distributed-tracing/installation/nextjs.mdx b/contents/docs/distributed-tracing/installation/nextjs.mdx index 244a4db5e87a..14e9b571b2cf 100644 --- a/contents/docs/distributed-tracing/installation/nextjs.mdx +++ b/contents/docs/distributed-tracing/installation/nextjs.mdx @@ -7,122 +7,11 @@ showStepsToc: true import { Steps, Step } from 'components/Docs/Steps' import TracingNextSteps from './_snippets/tracing-next-steps.mdx' -There are two ways to send spans from a Next.js app. - -| | `@posthog/next` | OpenTelemetry | -| --------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------- | -| **Packages** | The SDK you already use for analytics | Six `@opentelemetry/*` packages | -| **Instrumentation** | Manual – you wrap the operations you care about | Manual, plus auto-instrumentation for HTTP, databases, and more | -| **Person and session join** | Automatic – the server client resolves the request identity | Set the attributes yourself | -| **Setup** | Your existing server module | An `instrumentation.ts` file and, before Next.js 15, a config change | - -Pick OpenTelemetry if you already run it, or if you want spans from your HTTP and database calls without writing them yourself. Pick `@posthog/next` if PostHog is your only tracing backend – the tracing API is `posthog-node`'s, reached through the server client you already have, so there's nothing new to install. - -## With `@posthog/next` - -> **Server-side only.** Spans come from `posthog-node`, which `@posthog/next` uses for its server client. There is no browser span API, so Client Components, `instrumentation-client.ts`, and anything else running in the browser can't create spans. - -> **Minimum version:** an `@posthog/next` release built on `posthog-node` 5.52.0 or later. Run `npm ls posthog-node` to see which version you resolve. +> **Already using `posthog-node` on the server?** Route handlers, server actions, and server components can create spans with its own span API instead of OpenTelemetry, with no extra packages – see the [Node.js guide](/docs/distributed-tracing/installation/nodejs). Either way, spans are server-side only: there is no browser span API. - - -If you haven't set up `@posthog/next` yet, follow the [Next.js installation guide](/docs/libraries/next-js) first. Then add the `traces` option to your shared server module. Tracing is off until you set it, and there's no OpenTelemetry dependency to add. - -```typescript file=lib/posthog.ts -import 'server-only' -import { createPostHog } from '@posthog/next' -import { auth } from '@/auth' - -export const { getPostHog } = createPostHog({ - getDistinctId: async () => (await auth())?.user?.id, - options: { - traces: { - serviceName: 'my-nextjs-app', - environment: process.env.VERCEL_ENV ?? 'development', - }, - }, -}) -``` - -Using `posthog-node` directly rather than `@posthog/next`? The setup is the same – see the [Node.js guide](/docs/distributed-tracing/installation/nodejs). - - - - - -`withSpan` runs a callback with a span active for its duration and ends the span for you, including when it throws. Spans created inside the callback nest underneath it automatically. - -```typescript file=app/api/checkout/route.ts -import { after } from 'next/server' -import { getPostHog } from '@/lib/posthog' - -export async function POST() { - const posthog = await getPostHog() - - const order = await posthog.withSpan('POST /api/checkout', { kind: 'server' }, async (span) => { - span.setAttribute('cart.items', cart.length) - - const created = await posthog.withSpan('create-order', () => createOrder(cart)) - await posthog.withSpan('charge-card', () => stripe.charge(created)) - - return created - }) - - // Flush before the function freezes - after(async () => { - await posthog.flush() - }) - - return Response.json(order) -} -``` - -Because these spans come from the request-scoped client, they carry the person and session that `getPostHog()` resolved, which is what makes a trace reachable from a person or a Session Replay recording. - -Span names should be low-cardinality operation names – `POST /api/checkout`, not a name with an order ID in it. Variable values belong in attributes. See the [Node.js SDK docs](/docs/libraries/node#distributed-tracing) for the full span API. - - - - - -On a serverless platform, the function can freeze before queued spans are exported. `@posthog/next` registers a `waitUntil` flush when you capture an event, but creating a span doesn't trigger one, so call `flush()` at the end of a handler that produces spans. - -```typescript -import { after } from 'next/server' - -after(async () => { - await posthog.flush() -}) -``` - -`after()` is stable in Next.js 15.1+ (available as `unstable_after` in 15.0). On Next.js 14 and earlier, `await posthog.flush()` before returning the response instead. - -Call `flush()`, not `shutdown()` – the container is reused across invocations, so `shutdown()` would throw away the connection pool and the flag cache. - - - - - -The Node.js runtime tracks the active span with `AsyncLocalStorage`, so nested `withSpan` calls parent themselves. The edge runtime – middleware, and routes with `export const runtime = 'edge'` – can't, so a span created after an `await` starts a new trace unless you pass `parent` explicitly: - -```typescript -const parent = posthog.startSpan('handler', { kind: 'server' }) -const child = posthog.startSpan('fetch-config', { parent }) -child.end() -parent.end() -``` - - - - - -## With OpenTelemetry - - - - + For the complete SDK reference, see the [OpenTelemetry JavaScript docs](https://opentelemetry.io/docs/languages/js/). @@ -134,7 +23,7 @@ npm install @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk- - + You'll need your PostHog project token to authenticate trace requests. This is the same token you use for capturing events with the PostHog SDK. @@ -144,7 +33,7 @@ You can find your project token in [Project settings](https://app.posthog.com/se - + > **Note:** This step is only needed on Next.js 13.2–14.x. For Next.js 15 and later, `instrumentation.ts` is enabled by default and the `experimental.instrumentationHook` option is deprecated — remove it from your config if it's set. @@ -163,7 +52,7 @@ module.exports = nextConfig - + Create an `instrumentation.ts` (or `instrumentation.js`) file in the root of your project (or inside `src/` if you use that folder). @@ -212,7 +101,7 @@ new OTLPTraceExporter({ - + Wrap the operations you want to measure in spans, and attach attributes for context. Then flush the provider before the serverless function freezes. @@ -259,13 +148,9 @@ export async function GET() { - - - - - + -Whichever route you took: +Once everything is configured, confirm spans are reaching PostHog: 1. Run your application and trigger the instrumented code 2. Open the PostHog Tracing interface From 2def3aa3d4d6111ac469730c88e55f5a65971bda Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 2 Sep 2026 10:21:29 -0400 Subject: [PATCH 08/12] docs(node): document beforeSpanSend and span limits Adds the scrubbing hook and per-span cap sections, and the config rows for the limits and the live-span bounds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0138vKReknFeXLbvMyUX7fPZ --- .../installation/nodejs.mdx | 2 +- contents/docs/libraries/node/index.mdx | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/contents/docs/distributed-tracing/installation/nodejs.mdx b/contents/docs/distributed-tracing/installation/nodejs.mdx index 0bbc59602a45..61cbdea5f9e6 100644 --- a/contents/docs/distributed-tracing/installation/nodejs.mdx +++ b/contents/docs/distributed-tracing/installation/nodejs.mdx @@ -57,7 +57,7 @@ export const posthog = new PostHog('', { Use your **project token** (the same one you use for capturing events), not a [personal API key](/docs/api#authentication). -See the [Node.js SDK docs](/docs/libraries/node#configuration) for batching and queue options. +See the [Node.js SDK docs](/docs/libraries/node#configuration) for batching, queue and span-limit options, and [`beforeSpanSend`](/docs/libraries/node#scrubbing-and-dropping-spans) for scrubbing attributes or dropping spans before they're exported. diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index 2629d00e7e22..a77aec484a50 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -388,7 +388,7 @@ await posthog.withSpan('GET /users/:id', { kind: 'server' }, async (span) => { | `setAttributes(attributes)` | Merge several attributes at once | | `addEvent(name, attributes?, timestamp?)` | Record a timestamped event within the span | | `setStatus(status, message?)` | Set the outcome: `'ok'` or `'error'` | -| `recordException(error)` | Attach an exception event and set status to `error` | +| `recordException(error)` | Attach an exception event carrying the type, message and stack, and set status to `error` | | `updateName(name)` | Replace the span name, e.g. once a route template resolves | | `traceparent()` | This span's W3C `traceparent` header value | | `end(endTime?)` | End the span and queue it for export | @@ -428,6 +428,39 @@ posthog.withContext({ distinctId: user.id, sessionId }, async () => { Spans created outside a request context simply omit those attributes. +### Scrubbing and dropping spans + +`beforeSpanSend` runs on every finished span before it's queued for export. Edit the span in place to strip attributes you don't want leaving your process, or return `null` to drop the span entirely. + +```ts +const posthog = new PostHog('', { + host: '', + traces: { + serviceName: 'checkout-api', + beforeSpanSend: (span) => { + if (span.attributes['http.route'] === '/health') return null + + delete span.attributes['http.request.header.authorization'] + return span + }, + }, +}) +``` + +The hook sees plain values rather than the OTLP wire encoding, so `span.attributes.userId` reads as `42`, not `{ intValue: '42' }`. It runs after PostHog attaches `posthogDistinctId` and `sessionId`, so those are visible to the hook and can be scrubbed too. + +- `traceId`, `spanId` and `parentSpanId` are read-only. Rewriting them would orphan child spans that have already been exported, so assignments are ignored. +- A hook that throws drops the span rather than exporting it unscrubbed. +- Pass an array to run several hooks left to right. The first one to return `null` stops the chain. + +### Span limits + +A span is capped at 128 attributes and 128 events, and each string attribute value at 8192 characters. The endpoint rejects a span that's too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there. + +Past the cap, the earliest attributes and events are kept and the number dropped is reported alongside the span, so a truncated span reads as truncated rather than as quietly incomplete. The attributes PostHog attaches itself — `posthogDistinctId` and `sessionId` — don't count toward the cap and are never dropped, so a span at the limit still links back to its person and session. + +The length bound reaches inside a value, including strings nested in arrays and objects, and applies to `exception.stacktrace` like any other attribute. All three caps are re-applied after `beforeSpanSend`, so a hook that enriches a span can't push it back over. + ### Configuration | Option | Default | Description | @@ -439,6 +472,12 @@ Spans created outside a request context simply omit those attributes. | `flushIntervalMs` | `5000` | How often queued spans are exported | | `maxExportBatchSize` | `512` | Maximum spans per request | | `maxQueueSize` | `2048` | Maximum spans held in memory. Spans beyond this are dropped | +| `maxLiveSpans` | `10000` | Maximum spans open at once. At the limit `startSpan` returns an inert handle | +| `maxSpanAgeMs` | `3600000` | A span still open after this is treated as leaked and never exported | +| `beforeSpanSend` | – | Edit or drop each finished span before export. Return `null` to drop it | +| `maxAttributesPerSpan` | `128` | Maximum attributes you set on one span | +| `maxEventsPerSpan` | `128` | Maximum events on one span | +| `maxAttributeValueLength` | `8192` | Maximum characters in a string attribute value | ### Shutdown and short-lived processes From a65b4906ab92ebbc9861b003eb3f9ce95bf06df0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 2 Sep 2026 10:35:59 -0400 Subject: [PATCH 09/12] docs(node): note the exception reserve beyond the event cap Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0138vKReknFeXLbvMyUX7fPZ --- contents/docs/libraries/node/index.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index a77aec484a50..87e535c5ded1 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -459,6 +459,8 @@ A span is capped at 128 attributes and 128 events, and each string attribute val Past the cap, the earliest attributes and events are kept and the number dropped is reported alongside the span, so a truncated span reads as truncated rather than as quietly incomplete. The attributes PostHog attaches itself — `posthogDistinctId` and `sessionId` — don't count toward the cap and are never dropped, so a span at the limit still links back to its person and session. +Exception events are the exception: a span that fills its events and then throws would otherwise lose the only record of why it failed, so `recordException` keeps a small reserve of its own beyond the event cap. + The length bound reaches inside a value, including strings nested in arrays and objects, and applies to `exception.stacktrace` like any other attribute. All three caps are re-applied after `beforeSpanSend`, so a hook that enriches a span can't push it back over. ### Configuration From 6b126352ccd41443938ea40c137e2a253bd71157 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 2 Sep 2026 11:14:25 -0400 Subject: [PATCH 10/12] docs(node): correct the exception reserve and warn about stack contents Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0138vKReknFeXLbvMyUX7fPZ --- contents/docs/libraries/node/index.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index 87e535c5ded1..33dffcdb99cb 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -353,6 +353,8 @@ await posthog.withSpan('POST /checkout', async (span) => { If the callback throws or rejects, the span records the exception, its status is set to `error`, and your original error propagates unchanged. +The recorded exception includes the stack trace, which contains file paths from your server. If you'd rather those didn't leave your process, delete `exception.stacktrace` in [`beforeSpanSend`](#scrubbing-and-dropping-spans). + Use `startSpan` for work that can't wrap a callback. **`startSpan` does not make the span active**, so spans created afterwards are not its children unless you pass `parent` explicitly — and you must call `end()` yourself. ```ts @@ -459,7 +461,7 @@ A span is capped at 128 attributes and 128 events, and each string attribute val Past the cap, the earliest attributes and events are kept and the number dropped is reported alongside the span, so a truncated span reads as truncated rather than as quietly incomplete. The attributes PostHog attaches itself — `posthogDistinctId` and `sessionId` — don't count toward the cap and are never dropped, so a span at the limit still links back to its person and session. -Exception events are the exception: a span that fills its events and then throws would otherwise lose the only record of why it failed, so `recordException` keeps a small reserve of its own beyond the event cap. +Exception events get one concession: once a span has spent its event cap, a small reserve stays available to them, so a span that fills its events and then throws still carries the exception rather than only an `error` status. Below the cap an exception is an ordinary event. The length bound reaches inside a value, including strings nested in arrays and objects, and applies to `exception.stacktrace` like any other attribute. All three caps are re-applied after `beforeSpanSend`, so a hook that enriches a span can't push it back over. From fd7f813d8992f96e032e762a88a5ab8c4090bc42 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 4 Sep 2026 17:09:26 -0400 Subject: [PATCH 11/12] docs(node): catch tracing up to the merged SDK behavior The event cap turned out to be absolute, and `maxAttributesPerEvent`, `tracestate()`, the start options and sampled-flag propagation were missing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013sbvfpzMFXrtwcz5S5zhtt --- contents/docs/libraries/node/index.mdx | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index 33dffcdb99cb..d776126b4ed5 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -319,6 +319,12 @@ If you're using [PostHog JS](/docs/libraries/js) on the frontend, configure [`tr > Requires `posthog-node` version >= 5.52.0. + + +Tracing is new in `posthog-node` and its API can still change in a minor release. Spans you send are kept — it's the SDK surface that isn't frozen yet. + + + Tracing records **spans** — timed units of work — so you can see where time went in a request and how work fans out across your services. Spans created inside a request context automatically carry the person and session they belong to, so a slow trace links back to the user who experienced it. Tracing is off until you set the `traces` option. No OpenTelemetry dependency is required. For what you can do with spans once they arrive, see [Distributed tracing](/docs/distributed-tracing/start-here). @@ -393,9 +399,18 @@ await posthog.withSpan('GET /users/:id', { kind: 'server' }, async (span) => { | `recordException(error)` | Attach an exception event carrying the type, message and stack, and set status to `error` | | `updateName(name)` | Replace the span name, e.g. once a route template resolves | | `traceparent()` | This span's W3C `traceparent` header value | +| `tracestate()` | This span's W3C `tracestate` value, or `null` when it has none | | `end(endTime?)` | End the span and queue it for export | -Set `kind` to describe what the work is: `'internal'` (default), `'server'` for an inbound request, `'client'` for an outbound call, `'producer'` or `'consumer'` for queue work. +Both `withSpan` and `startSpan` take the same options: + +| Option | Description | +|---|---| +| `kind` | What the work is: `'internal'` (default), `'server'` for an inbound request, `'client'` for an outbound call, `'producer'` or `'consumer'` for queue work | +| `attributes` | Attributes to set at span start | +| `parent` | A span handle, or an inbound W3C `traceparent` string to continue a trace another service started | +| `tracestate` | The W3C `tracestate` accompanying a `traceparent` string. Ignored when `parent` is a span handle, which inherits its parent's | +| `startTime` | Backdate the span's start, as a millisecond epoch or a `Date`. A start more than 24 hours old is warned about, because the server clamps it to receive time | ### Tracing across services @@ -418,6 +433,8 @@ app.post('/checkout', async (req, res) => { A missing or malformed `traceparent` starts a new trace rather than throwing. +A continued trace propagates the sampled flag it was handed, so a downstream sampler sees the decision the head service made. PostHog itself doesn't sample — a span is recorded and exported whichever way that flag is set. + ### Linking traces to people and sessions Spans created inside a PostHog request context automatically carry `posthogDistinctId` and `sessionId` attributes, which is what makes a trace reachable from a person or a Session Replay recording. Use the [Express middleware](#add-request-context-to-express) or [`withContext`](#contexts): @@ -457,13 +474,13 @@ The hook sees plain values rather than the OTLP wire encoding, so `span.attribut ### Span limits -A span is capped at 128 attributes and 128 events, and each string attribute value at 8192 characters. The endpoint rejects a span that's too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there. +A span is capped at 128 attributes and 128 events, each event at 128 attributes, and each string attribute value at 8192 characters. The endpoint rejects a span that's too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there. Past the cap, the earliest attributes and events are kept and the number dropped is reported alongside the span, so a truncated span reads as truncated rather than as quietly incomplete. The attributes PostHog attaches itself — `posthogDistinctId` and `sessionId` — don't count toward the cap and are never dropped, so a span at the limit still links back to its person and session. -Exception events get one concession: once a span has spent its event cap, a small reserve stays available to them, so a span that fills its events and then throws still carries the exception rather than only an `error` status. Below the cap an exception is an ordinary event. +The event cap is absolute: an `exception` event the SDK records for you spends an ordinary slot like any other. A span that fills its events and then throws keeps its `error` status but not the exception detail, and reports the loss in `droppedEventsCount`. Raise `maxEventsPerSpan` on spans that record many events and can also fail. -The length bound reaches inside a value, including strings nested in arrays and objects, and applies to `exception.stacktrace` like any other attribute. All three caps are re-applied after `beforeSpanSend`, so a hook that enriches a span can't push it back over. +The length bound reaches inside a value, including strings nested in arrays and objects, and applies to `exception.stacktrace` like any other attribute. All four caps are re-applied after `beforeSpanSend`, so a hook that enriches a span can't push it back over. ### Configuration @@ -481,6 +498,7 @@ The length bound reaches inside a value, including strings nested in arrays and | `beforeSpanSend` | – | Edit or drop each finished span before export. Return `null` to drop it | | `maxAttributesPerSpan` | `128` | Maximum attributes you set on one span | | `maxEventsPerSpan` | `128` | Maximum events on one span | +| `maxAttributesPerEvent` | `128` | Maximum attributes on one span event, including the `exception.*` attributes the SDK records for you | | `maxAttributeValueLength` | `8192` | Maximum characters in a string attribute value | ### Shutdown and short-lived processes From 53f37cbe3934058267206f51734d7e78f08d3252 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 10 Sep 2026 14:06:17 -0400 Subject: [PATCH 12/12] docs(node): correct tracing flush, config and edge details Drops maxAttributesPerEvent (not an option), and fixes what flush() does with open spans, the debug-only backdating warning and the edge parent example. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015L9zQoGEbFSQDe25Mzz2wJ --- contents/docs/libraries/node/index.mdx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/contents/docs/libraries/node/index.mdx b/contents/docs/libraries/node/index.mdx index d776126b4ed5..4b3f51c5f936 100644 --- a/contents/docs/libraries/node/index.mdx +++ b/contents/docs/libraries/node/index.mdx @@ -410,7 +410,7 @@ Both `withSpan` and `startSpan` take the same options: | `attributes` | Attributes to set at span start | | `parent` | A span handle, or an inbound W3C `traceparent` string to continue a trace another service started | | `tracestate` | The W3C `tracestate` accompanying a `traceparent` string. Ignored when `parent` is a span handle, which inherits its parent's | -| `startTime` | Backdate the span's start, as a millisecond epoch or a `Date`. A start more than 24 hours old is warned about, because the server clamps it to receive time | +| `startTime` | Backdate the span's start, as a millisecond epoch or a `Date`. The server clamps a start more than 24 hours old to receive time; with `debug` on, the SDK warns when you pass one | ### Tracing across services @@ -498,12 +498,11 @@ The length bound reaches inside a value, including strings nested in arrays and | `beforeSpanSend` | – | Edit or drop each finished span before export. Return `null` to drop it | | `maxAttributesPerSpan` | `128` | Maximum attributes you set on one span | | `maxEventsPerSpan` | `128` | Maximum events on one span | -| `maxAttributesPerEvent` | `128` | Maximum attributes on one span event, including the `exception.*` attributes the SDK records for you | | `maxAttributeValueLength` | `8192` | Maximum characters in a string attribute value | ### Shutdown and short-lived processes -Both `flush()` and `shutdown()` export spans that have already ended. Spans still open at that point are discarded, so end your spans before either call — `withSpan` does this for you. +Both `flush()` and `shutdown()` export spans that have already ended. A span still open at `flush()` is exported once it ends; a span still open at `shutdown()` is discarded, so end your spans before shutting down — `withSpan` does this for you. In a serverless handler, call `flush()`: the container is reused across invocations, so `shutdown()` would throw away the connection pool and the flag cache. Events and spans are flushed concurrently, so it costs one round trip, not two. @@ -516,7 +515,14 @@ export const handler = async () => { -On edge runtimes, spans nest across `await` only when you pass `parent` explicitly. The Node runtime tracks the active span with `AsyncLocalStorage`; the edge build cannot, so a span created after an `await` starts a new trace unless it is given a parent. +On edge runtimes, spans nest across `await` only when you pass `parent` explicitly. The Node runtime tracks the active span with `AsyncLocalStorage`; the edge build cannot, so after an `await`, `getActiveSpan()` returns `null` and a new span starts a new trace. Pass the span your callback receives instead: + +```ts +await posthog.withSpan('handler', async (span) => { + const user = await loadUser() + await posthog.withSpan('render', { parent: span }, () => render(user)) +}) +```