Subchapter 93.8
references/nodejs.mdMarkdown12 KBView on GitHub
AI agents: this is one page from PostHog’s docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt (opens in a new tab)
Canonical URL: https://posthog.com/docs/distributed-tracing/installation/nodejs (opens in a new tab)
Cloud region: This page uses US Cloud hosts. For an EU Cloud project, use
https://eu.i.posthog.cominstead ofhttps://us.i.posthog.com(ingestion host), and usehttps://eu.posthog.cominstead ofhttps://app.posthog.com(app host for links).
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 | Optional PostHog context processor |
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.
Minimum version:
posthog-node@5.52.0or later.
1
Required
npm install posthog-node --saveyarn add posthog-nodepnpm add posthog-nodebun add posthog-node2
Required
Tracing is off until you set the traces option. There’s no OpenTelemetry dependency to add.
import { PostHog } from 'posthog-node'
export const posthog = new PostHog('<ph_project_token>', {
host: 'https://us.i.posthog.com',
traces: {
serviceName: 'checkout-api'
3
Required
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.
await posthog.withSpan('POST /checkout', { kind: 'server' }, async (span) => {
span.setAttribute('plan', user.plan)
4
Recommended
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.
posthog.withContext({ distinctId: user.id, sessionId }, async () => {
await posthog.withSpan('POST /checkout', () => processOrder())
})If you use Express, the PostHog middleware sets this up for every request, and reads the and headers that sends from the browser.
5
Recommended
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.
export const handler = async () => {
await posthog.withSpan('handler', () => doWork())
await posthog.flush()
1
Required
For the complete SDK reference, see the OpenTelemetry JavaScript docs (opens in a new tab).
npm install @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/resources @opentelemetry/semantic-conventions @opentelemetry/exporter-trace-otlp-proto@opentelemetry/exporter-trace-otlp-proto is the OTLP HTTP/protobuf trace exporter. The similarly named -otlp-http package sends HTTP/JSON and -otlp-grpc sends gRPC, so pick -proto to match this guide.
2
Required
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.
Important: Use your project token which starts with
phc_. Do not use a personal API key (which starts withphx_).
You can find your project token in Project settings (opens in a new tab).
3
Required
Set up the OpenTelemetry SDK to export spans to PostHog over OTLP HTTP.
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import
4
Required
Wrap the operations you want to measure in spans, and attach attributes for context.
import { trace, SpanStatusCode } from '@opentelemetry/api'
const tracer = trace.getTracer('my-service')
function chargeCustomer(customerId) {
Checkpoint
Confirm spans are reaching PostHog
Whichever route you took:
Checkpoint
What you can do with your traces
| Action | Description |
|---|---|
| Why you need distributed tracing (opens in a new tab) | What a trace shows you that nothing else does |
| Explore traces | Read a trace as a waterfall to see where time goes |
| Filter spans | Narrow down by service, status, duration, and attributes |
| Propagate context | Pass trace context across services so spans join the same trace |
| 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 (opens in a new tab).
See the Node.js SDK docs (opens in a new tab) for batching, queue, and span-limit options, and beforeSpanSend (opens in a new tab) for scrubbing attributes or dropping spans before they’re exported.
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 (opens in a new tab) for the full span API and for continuing a trace across services with W3C traceparent headers.
X-POSTHOG-DISTINCT-IDX-POSTHOG-SESSION-IDIn 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.
Alternatively, configure the exporter with environment variables:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://us.i.posthog.com/i/v1/traces"
OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer <ph_project_token>"
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf"
OTEL_SERVICE_NAME="my-service"Note: Pass the full
/i/v1/tracespath to the traces endpoint. Don’t use the baseOTEL_EXPORTER_OTLP_ENDPOINTvariable, which appends its own/v1/traces.
To link spans to people and sessions, add the optional PostHog context processor to your existing provider. Use your existing PostHog client as posthog:
import { PostHogContextSpanProcessor } from 'posthog-node/otel'
const provider = new NodeTracerProvider({
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'my-service' }),
spanProcessors: [
new PostHogContextSpanProcessor(posthog),
new BatchSpanProcessor(exporter),
],
})Use this configuration in place of the provider configuration above. Keep your existing exporter and sampler. Register the processor before requests start.
For Express, put setupExpressRequestContext (opens in a new tab) before your routes. It reads the browser tracing headers and adds missing posthogDistinctId and sessionId attributes to the active request span. The processor adds the same attributes to child spans when they start. Configure tracing_headers (opens in a new tab) for your backend hostname in the browser SDK.
For a custom integration, call enrichCurrentSpan(posthog) from posthog-node/otel after you set the IDs inside posthog.withContext(). The helper is also exported from the main posthog-node entry without loading OpenTelemetry. It requires the registered processor. It does not manage another framework’s request lifetime.
At explicit request entry, the processor also fills missing IDs on the nearest recording server ancestor through parent span objects it has observed. This covers HTTP spans that start before framework middleware. If a sampler drops an intermediate span, that parent chain cannot be verified, so the earlier server span stays unchanged. New recording spans inside the request still receive context.
Existing sessionId and posthogDistinctId span attributes take precedence. Set authenticated application identity with posthogDistinctId. Missing IDs stay absent. Express context stays active for work that the handler starts, including promises and timers that run after the response ends. A background job that needs its own IDs should start a fresh context with fresh: true. To end a context at a fixed point, pass ContextOptions.signal and abort it. The processor does not create a session, recording, trace provider, or exporter.