diff --git a/contents/docs/distributed-tracing/installation/dotnet.mdx b/contents/docs/distributed-tracing/installation/dotnet.mdx
index c6e5b40acf41..a78be191ac7f 100644
--- a/contents/docs/distributed-tracing/installation/dotnet.mdx
+++ b/contents/docs/distributed-tracing/installation/dotnet.mdx
@@ -91,6 +91,25 @@ public void ProcessOrder(string orderId)
+
+
+For ASP.NET Core on .NET 8 or later, use the [PostHog request-context integration](/docs/libraries/dotnet#request-context). Add PostHog to your existing host builder and put the middleware before your routes:
+
+```csharp
+builder.AddPostHog();
+
+var app = builder.Build();
+app.UsePostHogRequestContext();
+```
+
+Configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your backend hostname in the browser SDK. The middleware adds missing `sessionId` and `posthogDistinctId` tags to the active request `Activity`. The host-owned listener adds those tags to child activities when they start. Keep your existing OpenTelemetry provider, exporter, and sampling configuration.
+
+Existing `sessionId` and `posthogDistinctId` tags take precedence. Set authenticated application identity with `posthogDistinctId`. Missing IDs stay absent. Tracing headers are untrusted analytics context. Use authenticated application identity for access decisions.
+
+Activities that already started keep their tags after the request ends. New activities in detached work do not receive the ended request's IDs. Background jobs need their own context transfer. The core PostHog SDK does not provide a native span API or a trace exporter.
+
+
+
Once everything is configured, confirm spans are reaching PostHog:
diff --git a/contents/docs/distributed-tracing/installation/go.mdx b/contents/docs/distributed-tracing/installation/go.mdx
index c1cb5cd1992b..1e06f6d462eb 100644
--- a/contents/docs/distributed-tracing/installation/go.mdx
+++ b/contents/docs/distributed-tracing/installation/go.mdx
@@ -117,6 +117,31 @@ func chargeCustomer(ctx context.Context, customerID string) error {
+
+
+Install the optional PostHog OpenTelemetry module:
+
+```bash
+go get github.com/posthog/posthog-go/otel
+```
+
+Register the context processor on your existing provider before requests start, then wrap your HTTP handler:
+
+```go
+import posthogotel "github.com/posthog/posthog-go/otel"
+
+tp.RegisterSpanProcessor(posthogotel.NewContextSpanProcessor(client))
+handler := posthogotel.NewRequestContextMiddleware(client, existingHandler)
+```
+
+The wrapper accepts the [core request middleware options](/docs/libraries/go#request-context). Configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your backend hostname in the browser SDK. Put OpenTelemetry HTTP middleware outside the PostHog wrapper to enrich its existing request span. Create child spans with `r.Context()` so the processor can read the request IDs.
+
+The bridge adds missing `sessionId` and `posthogDistinctId` attributes. Existing `sessionId` and `posthogDistinctId` attributes take precedence. Set authenticated application identity with `posthogDistinctId`. Missing IDs stay absent. The optional module keeps your existing provider, exporter, and sampler. It does not change the core SDK dependencies.
+
+Request context expires when the handler returns or panics. Spans that already started keep their attributes. New spans from an ended request context omit its IDs. For a separate background job, start from a fresh context, such as `context.Background()` with `posthog.WithFreshRequestContext`, and set the job's IDs explicitly. Manual `WithRequestContext` use has no automatic HTTP lifetime management.
+
+
+
Once everything is configured, confirm spans are reaching PostHog:
diff --git a/contents/docs/distributed-tracing/installation/nodejs.mdx b/contents/docs/distributed-tracing/installation/nodejs.mdx
index 5b90b8a68ef1..fc2734734eb2 100644
--- a/contents/docs/distributed-tracing/installation/nodejs.mdx
+++ b/contents/docs/distributed-tracing/installation/nodejs.mdx
@@ -14,7 +14,7 @@ There are two ways to send spans from Node.js.
| ------------------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------- |
| **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 |
+| **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.
@@ -208,7 +208,29 @@ 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.
+To link spans to people and sessions, add the optional PostHog context processor to your existing provider. Use your existing `PostHog` client as `posthog`:
+
+```javascript
+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`](/docs/libraries/node#add-request-context-to-express) 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`](/docs/libraries/js/config#tracing-headers) 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.
diff --git a/contents/docs/distributed-tracing/installation/php.mdx b/contents/docs/distributed-tracing/installation/php.mdx
index 7e5c49acc303..638fb2bfaf12 100644
--- a/contents/docs/distributed-tracing/installation/php.mdx
+++ b/contents/docs/distributed-tracing/installation/php.mdx
@@ -100,6 +100,31 @@ try {
+
+
+After your existing PostHog client is initialized, add the optional processor to your existing OpenTelemetry provider builder alongside its exporter:
+
+```php
+use PostHog\OpenTelemetry\ContextSpanProcessor;
+use PostHog\PostHog;
+
+$tracerProvider = TracerProvider::builder()
+ ->addSpanProcessor(new ContextSpanProcessor(PostHog::getClient()))
+ ->addSpanProcessor(new BatchSpanProcessor(new SpanExporter($transport)))
+ ->setResource($resource)
+ ->build();
+```
+
+Use this configuration in place of the builder above. Keep your existing provider, exporter, and sampler. Register the processor before requests start. OpenTelemetry SDK 1.0.0 or later is supported and remains an optional application dependency.
+
+The processor reads this client's PostHog context when each span starts. The [Laravel request middleware](/docs/libraries/laravel) sets that context with `PostHog::withContext()`. With the processor registered, entering a context also enriches the active request span in normal PHP execution. Configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your backend hostname in the browser SDK.
+
+Existing `sessionId` and `posthogDistinctId` span attributes take precedence. Missing IDs stay absent. The scope closes after success or failure, while spans that already started keep their attributes.
+
+**Fiber limit:** Incoming spans that started before `withContext()` inside a Fiber are skipped. OpenTelemetry has no safe public check for initialized Fiber storage. Child spans can receive IDs from Fiber-local PostHog context when the application's OpenTelemetry Fiber context is initialized. New Fibers and queue jobs need explicit context transfer.
+
+
+
Once everything is configured, confirm spans are reaching PostHog:
diff --git a/contents/docs/distributed-tracing/installation/python.mdx b/contents/docs/distributed-tracing/installation/python.mdx
index 0b27e2f1e4f6..c554cb4a93b8 100644
--- a/contents/docs/distributed-tracing/installation/python.mdx
+++ b/contents/docs/distributed-tracing/installation/python.mdx
@@ -13,7 +13,7 @@ There are two ways to send spans from Python.
| ------------------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------- |
| **Packages** | The SDK you already use for analytics | `opentelemetry-sdk` and an OTLP exporter |
| **Instrumentation** | Manual – you wrap the operations you care about | Manual, plus auto-instrumentation for Django, Flask, FastAPI, databases, and more |
-| **Person and session join** | Automatic inside a PostHog context that identifies them | Set the attributes yourself |
+| **Person and session join** | Automatic inside a PostHog context that identifies them | Optional PostHog context processor |
Pick OpenTelemetry if you already run it, if you use `asyncio` with `AsyncPosthog`, or if you want spans from your web framework and database driver without writing them yourself. Pick `posthog` if PostHog is your only tracing backend and you'd rather instrument a handful of operations by hand than add an exporter pipeline.
@@ -199,7 +199,23 @@ def checkout(order_id, amount):
return "confirmed"
```
-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.
+To link spans to people and sessions, add the optional PostHog context processor to your existing provider before requests start:
+
+```python
+from posthog.integrations.otel import PostHogContextSpanProcessor
+
+provider.add_span_processor(PostHogContextSpanProcessor())
+```
+
+Keep your existing provider, exporter, and sampler. This integration supports OpenTelemetry SDK 1.20.0 or later. The processor adds missing `posthogDistinctId` and `sessionId` attributes from the active PostHog context when each span starts. Explicit attributes take precedence. If the span has too little attribute capacity, enrichment is skipped to preserve its existing attributes.
+
+The PostHog Django, Flask, and ASGI integrations also enrich an active request span after they read the request context. Put the OpenTelemetry request middleware outside the PostHog middleware. Configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) in the browser SDK. The [PostHog framework integrations](/docs/libraries/python#django) capture these headers by default with Python SDK 7.71.0 or later. Set `capture_tracing_headers=False`, or `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` in Django, to disable extraction. These headers are untrusted analytics context.
+
+For Celery, initialize the PostHog Celery integration before OpenTelemetry Celery instrumentation. This order also preserves task context for eager or nested execution.
+
+For a custom integration, call `enrich_current_span()` from `posthog.integrations.otel` after you set the request IDs. The processor handles child spans started later in that context.
+
+Missing IDs stay absent. Spans that already started keep their attributes after the context ends. New spans in detached tasks do not receive the ended context's IDs. Set explicit IDs in a new `new_context()` scope for background work. The bridge does not transfer context to another process or thread. Error-response spans created outside the PostHog middleware scope, including outer FastAPI error handling, can omit request IDs.
diff --git a/contents/docs/integrate/_snippets/install-svelte-server.mdx b/contents/docs/integrate/_snippets/install-svelte-server.mdx
index 77cec5966fcf..c2a9388e83af 100644
--- a/contents/docs/integrate/_snippets/install-svelte-server.mdx
+++ b/contents/docs/integrate/_snippets/install-svelte-server.mdx
@@ -4,21 +4,29 @@ import InstallNodePackageManagers from "./install-node-package-managers.mdx"
-Then, initialize the PostHog Node client where you'd like to use it on the server side. For example, in a [load function](https://kit.svelte.dev/docs/load#page-data):
+Create one client in a server-only module. Import this client from your server hooks, load functions, and tracing setup:
-```js file=routes/+page.server.js
+```js file=src/lib/server/posthog.js
import { PostHog } from 'posthog-node';
-export async function load() {
- const posthog = new PostHog('', { host: '' });
+export const posthog = new PostHog('', {
+ host: '',
+});
+```
+
+For example, capture an event from a [load function](https://svelte.dev/docs/kit/load):
+```js file=src/routes/+page.server.js
+import { posthog } from '$lib/server/posthog';
+
+export async function load() {
posthog.capture({
distinctId: 'distinct_id_of_the_user',
event: 'event_name',
- })
+ });
- await posthog.shutdown()
+ await posthog.flush();
}
```
-> **Note:** Make sure to always call `posthog.shutdown()` after capturing events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
\ No newline at end of file
+Flush queued events before your platform can stop server work. Call `posthog.shutdown()` once when the server stops.
diff --git a/contents/docs/libraries/astro.mdx b/contents/docs/libraries/astro.mdx
index 9da26756a488..b4e51e5e30d8 100644
--- a/contents/docs/libraries/astro.mdx
+++ b/contents/docs/libraries/astro.mdx
@@ -21,6 +21,64 @@ import AstroInstall from "../integrate/_snippets/install-astro.mdx"
+## Node.js server context and traces
+
+For on-demand rendering with `@astrojs/node` in middleware mode, use the shared Node SDK on your Express server. The browser setup above does not add server context.
+
+Reuse one server `posthog-node` client. Add `PostHogContextSpanProcessor` from `posthog-node/otel` to your application's existing OpenTelemetry provider. Keep your exporter, sampler, and other processors. Load your tracing setup, including HTTP and Express instrumentation, before importing the server modules below.
+
+Configure the Node adapter for middleware mode:
+
+```js
+import { defineConfig } from 'astro/config'
+import node from '@astrojs/node'
+
+export default defineConfig({
+ output: 'server',
+ adapter: node({ mode: 'middleware' }),
+})
+```
+
+Place the request middleware before the generated Astro handler. Pass the same client through the adapter's public `locals` argument so pages, endpoints, actions, and Astro middleware can reuse it:
+
+```js
+import express from 'express'
+import { handler as ssrHandler } from './dist/server/entry.mjs'
+import { setupExpressRequestContext } from 'posthog-node'
+import posthog from './posthog.server.mjs'
+
+const app = express()
+app.use(express.static('dist/client'))
+setupExpressRequestContext(posthog, app)
+app.use((req, res, next) => ssrHandler(req, res, next, { posthog }))
+app.listen(4321)
+```
+
+The middleware reads PostHog tracing headers. An existing application identity takes precedence over the browser fallback. The headers are untrusted analytics context and must not be used for authentication or authorization. Only requests that carry the headers receive browser IDs; standard browser document navigations do not gain fetch/XHR-added tracing headers.
+
+The optional processor adds `sessionId` and `posthogDistinctId` to recording application spans. Explicit canonical attributes keep their values. At request entry, it also fills missing IDs on the nearest recording HTTP server ancestor through parent objects it has observed. A sampler-dropped intermediate span breaks that verified chain, so the earlier server span stays unchanged. New recording spans inside the request still receive context.
+
+Express owns the request context. It stays active for work that the request starts, including while a response body streams and after the response ends. A background job that needs its own IDs must use a new explicit context.
+
+Astro can handle errors before they reach Express's error middleware. To capture errors thrown by `next()`, use the same client from your existing Astro middleware chain:
+
+```js
+import { defineMiddleware } from 'astro:middleware'
+
+export const onRequest = defineMiddleware(async ({ locals }, next) => {
+ try {
+ return await next()
+ } catch (error) {
+ locals.posthog.captureException(error)
+ throw error
+ }
+})
+```
+
+Keep your other middleware when adding error capture. Capture errors from later streaming work where they occur, because `next()` may already have returned.
+
+This setup covers the Node adapter's middleware mode. Prerendered pages and static assets use the browser integration; standalone and edge deployments need their own verified context and response lifetime setup. See [Astro's Node adapter guide](https://docs.astro.build/en/guides/integrations-guide/node/#middleware) and the [Node.js tracing guide](/docs/distributed-tracing/installation/nodejs).
+
## Identifying users
import IdentifyFrontendCallout from '../_snippets/identify-frontend-callout.mdx'
diff --git a/contents/docs/libraries/cloudflare-workers.mdx b/contents/docs/libraries/cloudflare-workers.mdx
index c64ab93c8ddd..2e387a322653 100644
--- a/contents/docs/libraries/cloudflare-workers.mdx
+++ b/contents/docs/libraries/cloudflare-workers.mdx
@@ -84,8 +84,8 @@ export default {
You'll notice two different usage patterns here:
-1. We use `ctx.waitUntil()` with `captureImmediate()` to capture an event. This doesn't block the **response**, but does ensure data is captured before the worker shuts down.
-2. We `await` flags which blocks the response until we get the [feature flag](/docs/feature-flags) data we need.
+1. We use `ctx.waitUntil()` with `captureImmediate()` to capture an event. This doesn't block the **response**, but does ensure data is captured before the worker shuts down.
+2. We `await` flags which blocks the response until we get the [feature flag](/docs/feature-flags) data we need.
### Error tracking
@@ -93,6 +93,49 @@ You can capture errors in Cloudflare Workers like you would in other Node applic
For more details and a method for automatically capturing errors, see our [error tracking installation docs](/docs/error-tracking/installation).
+### Request and span context
+
+The standard `workerd` export uses the edge client. It has no implicit request context: `withContext()` runs the callback, and `getContext()` returns `undefined`. Enabling `nodejs_compat` does not add context support to this entrypoint. Node request middleware and `PostHogContextSpanProcessor` cannot supply automatic request IDs here.
+
+Pass the IDs explicitly to each capture. Use a verified application ID when your app has one. Browser tracing headers are untrusted analytics data and must not authorize access. This example reads the headers only for analytics, removes control characters, trims whitespace, omits blanks, and limits each ID to 1,000 characters:
+
+```js file=src/index.js
+import { createPostHogClient } from './posthog.js';
+
+function tracingId(value) {
+ return value?.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim().slice(0, 1000) || undefined;
+}
+
+export default {
+ async fetch(request, env, ctx) {
+ const posthog = createPostHogClient(env);
+ const distinctId = tracingId(request.headers.get('x-posthog-distinct-id'));
+ const sessionId = tracingId(request.headers.get('x-posthog-session-id'));
+ const properties = sessionId ? { $session_id: sessionId } : {};
+
+ ctx.waitUntil((async () => {
+ try {
+ await posthog.captureImmediate({
+ distinctId,
+ event: 'request_received',
+ properties,
+ });
+ } finally {
+ await posthog.shutdown();
+ }
+ })());
+
+ return new Response('OK');
+ },
+};
+```
+
+Without a distinct ID, each capture gets a new SDK-generated anonymous ID. Those captures do not share person context. Without a session ID, they have no browser session link. Use a verified application ID when you need related captures to share a person.
+
+For an error, pass the same explicit values to `posthog.captureException(error, distinctId, properties)` and keep the flush or shutdown promise in `ctx.waitUntil()`. Values in these callbacks come from the current request's local variables. Keep request IDs out of global variables. [waitUntil](https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil) extends execution for the registered promise; it does not add identity context to other work.
+
+Cloudflare has its own [native custom spans API](https://developers.cloudflare.com/workers/observability/traces/custom-spans/). The shared Node processor does not connect to it. The native span API has no documented attribute reader for filling only missing IDs. Automatic missing-attribute enrichment, preservation of pre-existing native attributes, and SDK exception links to native trace IDs are therefore outside this setup. An application can set `posthogDistinctId` and `sessionId` when it creates its own custom spans, using explicit IDs on each span that needs them. Platform-created child spans receive no automatic PostHog ID propagation from the edge client.
+
## Node.js compatibility
`posthog-node` ships a dedicated `workerd` export that avoids Node.js built-ins — it does **not** require `nodejs_compat` on its own. However, other dependencies in your project may need it. If you see missing-module errors at import time, add the compatibility flag to your Wrangler config:
diff --git a/contents/docs/libraries/convex.mdx b/contents/docs/libraries/convex.mdx
index 8078bae41931..62653352affa 100644
--- a/contents/docs/libraries/convex.mdx
+++ b/contents/docs/libraries/convex.mdx
@@ -17,16 +17,18 @@ import { CalloutBox } from 'components/Docs/CalloutBox'
| Forward Convex exceptions to PostHog Error Tracking | [Convex dashboard exception reporting](https://docs.convex.dev/production/integrations/exception-reporting) (no code) |
## Get your PostHog project token
@@ -38,7 +40,7 @@ Every integration on this page needs the same two values:
## Capture events and flags from your Convex code
-The [`@posthog/convex`](https://www.npmjs.com/package/@posthog/convex) component lets you capture events, identify users, evaluate feature flags, and forward exceptions directly from your Convex queries, mutations, and actions.
+The [`@posthog/convex`](https://www.npmjs.com/package/@posthog/convex) component captures events, identifies users, and forwards exceptions from mutations and actions. Local feature flags also work in queries.
Requires Convex 1.39 or newer.
@@ -321,7 +323,7 @@ export function AuthSync() {
Mount `` once near the root of your app, inside the `` and below your Convex `` boundary if you have one. The `me?._id` guard skips the `identify` call until authentication completes.
-[Session replays](/docs/session-replay), server-side mutations, exceptions, and logs all land on one timeline for that user.
+Matching IDs connect frontend and backend events for that person. Session links need an explicit session ID. Convex log streams and dashboard exception reporting use separate ID rules, described below.
@@ -334,6 +336,78 @@ npx convex env set VITE_POSTHOG_HOST
+## Pass session IDs to backend functions
+
+Query, mutation, and action calls from the Convex client do not give your handler an HTTP request. Pass the browser IDs as function arguments when you need them. Read the session ID when you call the function, since it can change while the page is open:
+
+```ts
+await updateProfile({
+ plan: "pro",
+ sessionId: posthog.get_session_id(),
+ distinctId: posthog.get_distinct_id(),
+})
+```
+
+Use your existing browser PostHog instance and Convex mutation. Add optional string validators to that mutation, and resolve the authenticated person on the server:
+
+```ts
+import { mutation } from "./_generated/server"
+import { v } from "convex/values"
+import { posthog } from "./posthog"
+
+function cleanId(value: string | undefined): string | undefined {
+ const id = value?.trim()
+ return id && id.length <= 1000 && !/[\x00-\x1f\x7f-\x9f]/.test(id) ? id : undefined
+}
+
+export const updateProfile = mutation({
+ args: {
+ plan: v.string(),
+ sessionId: v.optional(v.string()),
+ distinctId: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity()
+ const distinctId = identity?.tokenIdentifier ?? cleanId(args.distinctId)
+ if (!distinctId) throw new Error("Missing analytics identity")
+ const sessionId = cleanId(args.sessionId)
+
+ await posthog.capture(ctx, {
+ distinctId,
+ event: "plan_changed",
+ properties: {
+ plan: args.plan,
+ ...(sessionId ? { $session_id: sessionId } : {}),
+ },
+ })
+ },
+})
+```
+
+Add this capture to your existing authorized profile update. This example uses the authenticated `tokenIdentifier`. If your app uses its own user ID, resolve that ID on the server and use the same string with `posthog.identify()` in the browser. The component's optional `identify` callback also takes priority over a supplied `distinctId`.
+
+Browser IDs are analytics hints. Use Convex authentication and authorization to control access to data. Missing or invalid session IDs stay absent; the server does not create browser sessions.
+
+`@posthog/convex` copies event properties into the scheduled capture call. Mutation scheduling commits with the mutation: if the mutation throws, its scheduled capture is rolled back too. Queries cannot schedule captures. Scheduled application functions do not inherit authentication or ambient context. Pass the selected IDs as arguments to an internal scheduled function when it needs them, and keep authorization checks at the public entry point.
+
+### Application spans and logs
+
+`@posthog/convex` does not install an OpenTelemetry provider or attach IDs to function spans. For application-created spans, use `sessionId` and `posthogDistinctId` as span attributes. Keep explicit application attributes when you add these defaults:
+
+```ts
+const span = tracer.startSpan("update_profile", {
+ attributes: {
+ ...(sessionId ? { sessionId } : {}),
+ posthogDistinctId: distinctId,
+ ...applicationAttributes,
+ },
+})
+```
+
+Apply the IDs to each span that needs them. If a Node action already uses `posthog-node` and an application-owned OpenTelemetry provider, the [Node span processor](/docs/distributed-tracing/installation/nodejs#with-opentelemetry) can apply the IDs inside `withContext()`. Keep those Node imports in a file with `"use node"`. The full Node SDK cannot be bundled into queries or mutations. The default Convex runtime supports `AsyncLocalStorage`, but its values do not cross `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` calls. Pass IDs explicitly across these boundaries. Await action work and exporter flushes before the action returns.
+
+Convex's [PostHog log stream](https://docs.convex.dev/production/integrations/log-streams/#posthog) sends function metadata and console output in the OTLP log body. IDs you print in console output remain body content; this does not establish canonical log attributes or trace links. The dashboard's [PostHog exception destination](https://docs.convex.dev/production/integrations/exception-reporting#supported-properties) uses the authenticated `tokenIdentifier`, or the deployment name when unauthenticated. It does not document a browser session ID. Use the component's `captureException()` with an explicit `distinctId` and `$session_id` in `additionalProperties` when you need that session link on a specific exception.
+
## Trace LLM calls and Convex Agent
If your app calls LLM providers from Convex actions, or uses [`@convex-dev/agent`](https://www.convex.dev/components/agent), forward the traces to [PostHog AI observability](/docs/ai-observability) to capture generations, traces, spans, token counts, latency, and cost per model call.
@@ -353,4 +427,3 @@ Requires a [Convex Professional plan](https://www.convex.dev/pricing) for Convex
- [Convex data warehouse source](/docs/data-pipelines/sources/convex)
- [Convex PostHog log streams](https://docs.convex.dev/production/integrations/log-streams/)
- [Convex PostHog exception reporting](https://docs.convex.dev/production/integrations/exception-reporting)
-
diff --git a/contents/docs/libraries/elixir/index.mdx b/contents/docs/libraries/elixir/index.mdx
index e90d2de27d59..881786a5633f 100644
--- a/contents/docs/libraries/elixir/index.mdx
+++ b/contents/docs/libraries/elixir/index.mdx
@@ -68,6 +68,34 @@ If you're using [PostHog JS](/docs/libraries/js) on the frontend, configure [`tr
Tracing headers are client-controlled analytics context, not authentication or authorization. Pass an authenticated `distinct_id` explicitly for security-sensitive server-side decisions.
+## Trace context
+
+To link application spans to people and sessions, install the optional `opentelemetry` dependency and add this entry before the export processors in your existing OpenTelemetry `:processors` list:
+
+```elixir
+{PostHog.OpenTelemetry.ContextSpanProcessor, %{supervisor_name: PostHog}}
+```
+
+Use your named PostHog supervisor in place of `PostHog` if needed. Keep your existing provider, exporter, and sampler. The processor supports OpenTelemetry SDK 1.7.0 or later. It adds missing `sessionId` and `posthogDistinctId` attributes from the current process's PostHog context when spans start. Existing canonical attributes take precedence.
+
+The PostHog plug sets context for child spans. Phoenix with Cowboy starts its request span before the endpoint plugs run. To also link that earlier span, change your existing Cowboy instrumentation setup to capture only the two PostHog headers:
+
+```elixir
+:opentelemetry_cowboy.setup(%{
+ request_headers: ["x-posthog-session-id", "x-posthog-distinct-id"]
+})
+
+OpentelemetryPhoenix.setup(adapter: :cowboy2)
+```
+
+Configure instrumentation once, before the endpoint starts. Keep other existing options in the same configuration. Do not capture all request headers. Configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) in the browser SDK so it sends these headers to your selected backend hostname.
+
+Cowboy supplies the captured headers as initial span attributes. The processor sanitizes them and uses them only when no process identity or explicit canonical span attribute is present. Without header capture, an earlier request span stays unchanged. Missing IDs stay absent.
+
+This request path was tested with Phoenix 1.8.15, `opentelemetry_phoenix` 2.0.1, and `opentelemetry_cowboy` 1.0.0. The same setup has not been verified with Bandit. HTTP request context does not transfer into LiveView processes automatically.
+
+Context is process-local and ends when the request process exits. Clear it before you reuse the same process for unrelated work. Tasks and other processes need their own explicit context. Spans keep the IDs captured when they start. No trace provider, exporter, session, or recording is created by this bridge.
+
## Feature flags
import FeatureFlagsLibsIntro from "../_snippets/feature-flags-libs-intro.mdx"
diff --git a/contents/docs/libraries/fastify.mdx b/contents/docs/libraries/fastify.mdx
index 79e2e5f5bade..26d1399d68ef 100644
--- a/contents/docs/libraries/fastify.mdx
+++ b/contents/docs/libraries/fastify.mdx
@@ -84,6 +84,42 @@ If authentication runs later, pass the verified ID directly to `captureException
Do not use `x-posthog-distinct-id` as proof of identity. Tracing headers are client-controlled analytics values, and this example does not read them. To link Session Replay, you can explicitly add a validated `sessionId` to the context. Never use it for authorization.
+## Trace context
+
+This optional setup requires the Node SDK release with `posthog-node/otel`. The base SDK version stated above does not include these APIs.
+
+To add browser session and person IDs to application spans, register the [optional Node context processor](/docs/distributed-tracing/installation/nodejs#with-opentelemetry) on your existing OpenTelemetry provider before requests start. Reuse the `posthog` client above and keep the existing exporter and sampler.
+
+Configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your backend hostname in the browser SDK. Replace the earlier `onRequest` hook with this Node.js setup:
+
+```js
+import { enrichCurrentSpan } from 'posthog-node/otel'
+
+function readTracingId(value) {
+ if (typeof value !== 'string') return undefined
+ return value.replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim().slice(0, 1000) || undefined
+}
+
+app.addHook('onRequest', (request, reply, done) => {
+ posthog.withContext(
+ {
+ distinctId: request.user?.id ?? readTracingId(request.headers['x-posthog-distinct-id']),
+ sessionId: readTracingId(request.headers['x-posthog-session-id']),
+ properties: { $request_method: request.method, request_id: request.id },
+ },
+ () => {
+ enrichCurrentSpan(posthog)
+ done()
+ },
+ { fresh: true }
+ )
+})
+```
+
+Keep this hook after authentication and before routes, in the same Fastify scope. The helper adds missing `sessionId` and `posthogDistinctId` attributes to an active request span. The processor adds them to child spans when they start. Explicit canonical attributes take precedence; missing IDs stay absent.
+
+Work that the request starts keeps its IDs, including work that continues after the response ends. A background job that needs its own IDs needs its own explicit context. Keep your existing error and shutdown handlers.
+
## Capture events
Add this route before `app.listen()` to capture an event for an authenticated request. The event inherits the verified identity and request properties from `withContext`.
diff --git a/contents/docs/libraries/hono.mdx b/contents/docs/libraries/hono.mdx
index e59cac00f89f..81e8d9cd9968 100644
--- a/contents/docs/libraries/hono.mdx
+++ b/contents/docs/libraries/hono.mdx
@@ -47,7 +47,7 @@ const posthogServerMiddleware = createMiddleware(async (c, next) => {
-Make sure to always call `posthog.shutdown()` after capturing events from the server-side. PostHog queues events into larger batches. This call forces all batched events to be flushed immediately.
+Keep one PostHog client for the Node.js application. Call `await posthog.shutdown()` once during application cleanup, after requests stop. Use `flush()` when an invocation must wait for buffered events, without shutting down the shared client.
@@ -70,17 +70,13 @@ Cloudflare Pages uses its own middleware system that is different from Hono's mi
Hono uses [`app.onError`](https://hono.dev/docs/api/exception#handling-httpexception) for root-level error handling. You can take advantage of this for [Error tracking](/docs/error-tracking).
-```ts file=index.ts
-import { PostHog } from 'posthog-node'
-
-const posthog = new PostHog(process.env.POSTHOG_PUBLIC_KEY, { host: '' })
+Reuse the existing client in your application's error handler:
+```ts file=index.ts
app.onError(async (err, c) => {
- posthog.captureException(err, 'user_distinct_id_with_err_rethrow', {
+ await posthog.captureExceptionImmediate(err, undefined, {
path: c.req.path,
method: c.req.method,
- url: c.req.url,
- headers: c.req.header(),
// ... other properties
})
await posthog.flush() // TIP: On program exit, call flush to send any remaining events
@@ -89,6 +85,41 @@ app.onError(async (err, c) => {
})
```
+## Trace context on Node.js
+
+For Node.js, register the [optional context processor](/docs/distributed-tracing/installation/nodejs#with-opentelemetry) on your existing OpenTelemetry provider before requests start. Reuse the existing `posthog` client. Keep your exporter and sampler, and put tracing middleware outside the PostHog middleware.
+
+Configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your backend hostname. Replace the earlier `posthogServerMiddleware` with this middleware and register it before your routes:
+
+```js
+import { enrichCurrentSpan } from 'posthog-node/otel'
+
+function readTracingId(value) {
+ return value?.replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim().slice(0, 1000) || undefined
+}
+
+const posthogServerMiddleware = createMiddleware(async (c, next) => {
+ const user = c.get('user')
+ return posthog.withContext(
+ {
+ distinctId: user?.id ?? readTracingId(c.req.header('x-posthog-distinct-id')),
+ sessionId: readTracingId(c.req.header('x-posthog-session-id')),
+ },
+ async () => {
+ enrichCurrentSpan(posthog)
+ await next()
+ },
+ { fresh: true }
+ )
+})
+```
+
+The optional `user` value belongs to your application. Set it in earlier authentication middleware with a verified string `id`, or omit it and use header fallback for analytics. Header IDs are client-controlled analytics context, never authentication or authorization.
+
+The helper enriches an active request span; the processor snapshots IDs for child spans. Existing canonical `sessionId` and `posthogDistinctId` attributes take precedence. Missing IDs stay absent. Work that the request starts keeps its IDs after `next()` returns, including after failures. Stream body work that starts outside the request needs its own explicit context.
+
+This automatic context setup requires the Node.js entry point. The SDK's edge entry point does not provide async request context. Explicit event properties in other runtimes are a separate path; this example does not establish Workers span support.
+
## Next steps
To read more about how to integrate specific PostHog features into Hono, have a look at our [Node SDK docs](/docs/libraries/node) for concepts such as:
diff --git a/contents/docs/libraries/nestjs.mdx b/contents/docs/libraries/nestjs.mdx
index 80c729d9bcab..00f4c6422145 100644
--- a/contents/docs/libraries/nestjs.mdx
+++ b/contents/docs/libraries/nestjs.mdx
@@ -141,6 +141,16 @@ The interceptor uses an RxJS `catchError` operator to observe exceptions without
For more details on Error Tracking setup, see the [NestJS Error Tracking installation guide](/docs/error-tracking/installation/nestjs).
+## Trace context
+
+Register the [optional Node context processor](/docs/distributed-tracing/installation/nodejs#with-opentelemetry) on your existing OpenTelemetry provider before requests start. Use the same client passed to `PostHogInterceptor`. Keep your provider, exporter, and sampler.
+
+The interceptor adds missing `sessionId` and `posthogDistinctId` attributes to an active request span after reading the request headers. The processor adds them to child spans when they start. Existing canonical attributes take precedence. Missing IDs stay absent.
+
+With the Express and Fastify HTTP adapters, the context stays active for work that the request starts, including work that continues after the response ends. Existing spans keep their IDs. A background job that needs its own IDs needs an explicit context.
+
+This setup was verified for HTTP on both adapters. WebSocket, GraphQL, and custom adapters need separate verification.
+
## Next steps
To read more about how to integrate specific PostHog features into NestJS, have a look at our [Node SDK docs](/docs/libraries/node) for concepts such as:
diff --git a/contents/docs/libraries/next-js/posthog-next.mdx b/contents/docs/libraries/next-js/posthog-next.mdx
index 84ce80c85b12..474d61b87f75 100644
--- a/contents/docs/libraries/next-js/posthog-next.mdx
+++ b/contents/docs/libraries/next-js/posthog-next.mdx
@@ -40,6 +40,11 @@ tableOfContents: [
value: 'Use PostHog on the server',
depth: 1,
},
+ {
+ url: 'link-server-spans-to-sessions',
+ value: 'Link server spans to sessions',
+ depth: 1,
+ },
{
url: 'capture-server-side-request-errors',
value: 'Capture server-side request errors',
@@ -329,6 +334,46 @@ For server-side events and feature flag evaluations, PostHog can use the client-
+
+
+For Node.js App Router work, use the optional `@posthog/next/otel` entry. This setup requires the releases that include this entry and the shared Node context processor. Reuse the same configuration as `createPostHog()` so analytics and tracing use one cached client:
+
+```ts file=lib/posthog.ts
+import 'server-only'
+import { createPostHog } from '@posthog/next'
+import { createPostHogSpanContext } from '@posthog/next/otel'
+import { auth } from '@/auth'
+
+const config = {
+ getDistinctId: async () => (await auth())?.user?.id,
+}
+
+export const { getPostHog } = createPostHog(config)
+export const { createContextSpanProcessor, withPostHogContext } = createPostHogSpanContext(config)
+```
+
+In your existing Node-only tracing setup, await `createContextSpanProcessor()` and add the returned processor to your provider before it handles requests. Keep your provider, exporters, sampler, and other processors. See the [Node.js tracing guide](/docs/distributed-tracing/installation/nodejs#with-opentelemetry) for setup.
+
+Wrap the request or action work that needs span context:
+
+```ts file=app/api/status/route.ts
+import { withPostHogContext } from '@/lib/posthog'
+
+export const runtime = 'nodejs'
+
+export async function GET() {
+ return withPostHogContext(async () => {
+ return Response.json({ ok: true })
+ })
+}
+```
+
+The helper uses the existing consent, cookie, header, and application identity resolver. It fills missing `sessionId` and `posthogDistinctId` attributes on the active span and its nearest recording server ancestor whose parent chain the processor observed. Existing attributes keep their values. A sampled-out intermediate hides earlier server ancestors; new recording child spans still receive context.
+
+Work that the callback starts keeps the context after the callback settles, including on error. Wrap each server action or awaited Server Component function that needs its own scope. Later child rendering and streamed response work are outside an earlier callback's scope. A separate job that needs its own IDs needs an explicit context. This entry supports Node.js App Router work; it does not accept Pages Router request arguments or run in the browser or Edge runtime.
+
+
+
`@posthog/next` enables automatic browser exception capture by default. On Next.js 15 and later, capture server-side request errors by exporting `onRequestError` from your root `instrumentation.ts` file:
diff --git a/contents/docs/libraries/nuxt-js.mdx b/contents/docs/libraries/nuxt-js.mdx
index b2f8f1145555..26fcaa13fc8f 100644
--- a/contents/docs/libraries/nuxt-js.mdx
+++ b/contents/docs/libraries/nuxt-js.mdx
@@ -10,7 +10,7 @@ import NuxtApiKeysSecurity from "../integrate/_snippets/nuxt-api-keys-security.m
PostHog makes it easy to get data about usage of your [Nuxt.js](https://nuxt.com/) app. Integrating PostHog into your app enables analytics about user behavior, custom events capture, session replays, feature flags, and more.
-This guide covers Nuxt v4.x and v3.7+. For these versions, we recommend using `@posthog/nuxt` module for client-side capture.
+This guide covers Nuxt v4.x and v3.7+. For these versions, we recommend using `@posthog/nuxt` module for client-side capture.
The `@posthog/nuxt` module provides:
@@ -19,7 +19,7 @@ The `@posthog/nuxt` module provides:
- Automatic exception capture for error tracking
- Source map configuration and upload for error tracking
-For server-side event capture beyond error tracking, use the `posthog-node` SDK directly.
+For server-side event capture, reuse the module's Node client as shown below.
> **Using an older version?** See our docs for [Nuxt 3.0-3.6](/docs/libraries/nuxt-js-3-6) or [Nuxt 2.x](/docs/libraries/nuxt-js-2).
@@ -107,24 +107,15 @@ posthog?.capture('button_clicked', { button_name: 'signup' })
## Usage on the server side
-The `@posthog/nuxt` module initializes a server-side client for error tracking only. For general event capture in Nitro routes, create your own `posthog-node` SDK client.
-
-The `@posthog/nuxt` module makes your config available at `runtimeConfig.public.posthog`.
-
-First, create a server utility to reuse the PostHog client across requests:
+Reuse the server client initialized by the module. The Node-only `@posthog/nuxt/server` accessor returns that client after its Nitro plugin starts and returns `undefined` before startup or after shutdown:
```typescript file=server/utils/posthog.ts
-import { PostHog } from 'posthog-node'
-
-let client: PostHog | null = null
+import type { PostHog } from 'posthog-node'
+import { getPostHogServerClient } from '@posthog/nuxt/server'
export function useServerPostHog(): PostHog {
- if (!client) {
- const config = useRuntimeConfig()
- client = new PostHog(config.public.posthog.publicKey, {
- host: config.public.posthog.host,
- })
- }
+ const client = getPostHogServerClient()
+ if (!client) throw new Error('PostHog server client is not initialized')
return client
}
```
@@ -144,6 +135,37 @@ export default defineEventHandler((event) => {
})
```
+### Request and span context
+
+This setup requires the Nuxt module release with `requestContext` and `getPostHogServerClient()`, plus the Node SDK release with the shared context bridge. Add the opt-in to your existing Nuxt configuration:
+
+```typescript file=nuxt.config.ts
+export default defineNuxtConfig({
+ posthogConfig: {
+ serverConfig: { requestContext: true },
+ },
+})
+```
+
+The module reads `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` as fallback analytics context. It preserves application identity, removes control characters, trims values, limits IDs to 1,000 characters, and omits missing IDs. These headers must not authorize access. Analytics context works without OpenTelemetry.
+
+For application-owned OpenTelemetry spans, add a processor using the same client inside your existing tracing plugin:
+
+```typescript file=server/utils/posthog-tracing.ts
+import { PostHogContextSpanProcessor } from 'posthog-node/otel'
+import { useServerPostHog } from './posthog'
+
+export function createPostHogSpanProcessor() {
+ return new PostHogContextSpanProcessor(useServerPostHog())
+}
+```
+
+Call `createPostHogSpanProcessor()` from your existing tracing plugin after the PostHog module's Nitro plugin starts and before requests begin. Add it to your existing provider's processors. Keep the provider, exporters, sampler, and other processors. See the [Node.js tracing guide](/docs/distributed-tracing/installation/nodejs#with-opentelemetry) for setup. The module does not create spans or install OpenTelemetry.
+
+The handler scope fills missing `posthogDistinctId` and `sessionId` attributes on an existing recording span. The processor handles new child spans and verified recording server ancestors. Explicit attributes stay unchanged. If a sampler drops an intermediate span, the earlier server ancestor stays unchanged.
+
+The Node setup covers Nitro 2's H3 handler and Nitro 3's fetch handler. Work that the handler starts, including detached tasks, keeps the request IDs after the handler returns or throws. The existing error hook uses the saved two-ID request snapshot; background errors keep anonymous identity. Give separate jobs that need their own IDs an explicit context. The module owns client shutdown; do not close it after each request.
+
diff --git a/contents/docs/libraries/phoenix.mdx b/contents/docs/libraries/phoenix.mdx
index fa4b39407bcf..6e14d6333a84 100644
--- a/contents/docs/libraries/phoenix.mdx
+++ b/contents/docs/libraries/phoenix.mdx
@@ -39,7 +39,6 @@ window.addEventListener("phx:navigate", ({ detail: { href } }) =>
This captures a `$pageview` event any time the browser's URL bar is programmatically changed by Phoenix or the user.
You can learn more about the `phx:navigate` event in the [Phoenix LiveView documentation](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#navigate/2).
-
## Capture custom events
To [capture custom events](/docs/product-analytics/capture-events), you can use `posthog.capture()` in your LiveView or regular Phoenix views. Here are some examples:
@@ -107,6 +106,12 @@ document.addEventListener('phx:click', (e) => {
})
```
+## Backend trace context
+
+For backend events and spans, use the [Elixir SDK request and trace context setup](/docs/libraries/elixir#trace-context). The optional processor links Cowboy request spans and handler child spans when the two PostHog tracing headers are explicitly captured. Keep your existing OpenTelemetry provider and exporter.
+
+The JavaScript setup above does not transfer context into a LiveView server process. LiveView and other background processes need explicit context in their own process. Bandit request tracing has not been verified with this setup.
+
## Next steps
Installing the JavaScript Web SDK or snippet means all of its functionality is available in your Phoenix project. To learn more about this, have a look at our [JavaScript Web SDK docs](/docs/libraries/js/usage).
\ No newline at end of file
diff --git a/contents/docs/libraries/python/_snippets/django-context-middleware.mdx b/contents/docs/libraries/python/_snippets/django-context-middleware.mdx
index 1446ba2ee8c7..8851c6a66e4d 100644
--- a/contents/docs/libraries/python/_snippets/django-context-middleware.mdx
+++ b/contents/docs/libraries/python/_snippets/django-context-middleware.mdx
@@ -29,8 +29,8 @@ POSTHOG_MW_CLIENT = Posthog(
The middleware automatically extracts and uses:
-- **Session ID** from the `X-POSTHOG-SESSION-ID` header when you enable `POSTHOG_MW_TRUST_TRACING_HEADERS`
-- **Distinct ID** from the authenticated Django user's `pk` (Django's primary-key alias, which works with custom user models), or from the `X-POSTHOG-DISTINCT-ID` header when you enable `POSTHOG_MW_TRUST_TRACING_HEADERS`
+- **Session ID** from the `X-POSTHOG-SESSION-ID` header when header capture is enabled
+- **Distinct ID** from the authenticated Django user's `pk` (Django's primary-key alias, which works with custom user models), or from the `X-POSTHOG-DISTINCT-ID` header when header capture is enabled
- **User email** from the authenticated Django user's `email` as `email`
- **Current URL** as `$current_url`
- **Request method** as `$request_method`
@@ -38,14 +38,14 @@ The middleware automatically extracts and uses:
- **Forwarded IP address** from `X-Forwarded-For` as `$ip`
- **User agent** from `User-Agent` as `$user_agent`
-The middleware ignores the session and distinct ID headers by default because clients can control them. To use them as analytics context, enable them in your Django settings:
+With SDK 7.71.0 or later, the middleware reads session and distinct ID headers by default. To disable header extraction, set this Django setting:
```python
# settings.py
-POSTHOG_MW_TRUST_TRACING_HEADERS = True
+POSTHOG_MW_CAPTURE_TRACING_HEADERS = False
```
-Don't use these headers for authentication or authorization. The middleware sanitizes trusted header values before use. It ignores empty values, removes control characters, trims values, and caps them at 1000 characters.
+Don't use these headers for authentication or authorization. The middleware sanitizes header values before use. It ignores empty values, removes control characters, trims values, and caps them at 1000 characters.
All events captured during the request (including exceptions) include these properties and are associated with the extracted session and distinct ID.
@@ -67,7 +67,7 @@ def identify_posthog_user(sender, request, user, **kwargs):
Every capture later in that request is then attributed to the user who just logged in. Requests made after login don't need this. The middleware sees the authenticated user from the start.
-If you're using [PostHog JavaScript Web](/docs/libraries/js) on the frontend, configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your Django backend hostname so browser requests include the session and distinct ID headers. You must also set `POSTHOG_MW_TRUST_TRACING_HEADERS = True` in Django to accept them.
+If you're using [PostHog JavaScript Web](/docs/libraries/js) on the frontend, configure [`tracing_headers`](/docs/libraries/js/config#tracing-headers) for your Django backend hostname so browser requests include the session and distinct ID headers. Header capture must be enabled in Django. It is enabled by default with SDK 7.71.0 or later.
### Exception capture
@@ -223,6 +223,6 @@ POSTHOG_MW_PROPERTIES_MAP = clean_properties
POSTHOG_MW_CAPTURE_EXCEPTIONS = True
```
-All events captured within the request context automatically include the configured properties. They use the authenticated Django user as the distinct ID, or the session and distinct ID from request headers when you enable `POSTHOG_MW_TRUST_TRACING_HEADERS`.
+All events captured within the request context automatically include the configured properties. They use the authenticated Django user as the distinct ID, or the session and distinct ID from request headers when header capture is enabled.
The middleware supports both sync (WSGI) and async (ASGI) Django applications. In async mode, it uses Django's `request.auser()` API when available to avoid synchronous user access.
diff --git a/contents/docs/libraries/python/index.mdx b/contents/docs/libraries/python/index.mdx
index e514ffc8bafe..1fd6f0a44889 100644
--- a/contents/docs/libraries/python/index.mdx
+++ b/contents/docs/libraries/python/index.mdx
@@ -655,7 +655,7 @@ Keep one `AsyncPosthog` client for the lifetime of your application. Use buffere
## Django
-See our [Django docs](/docs/libraries/django) for how to set up PostHog in Django. The [Django contexts middleware](/docs/libraries/django#django-contexts-middleware) adds request properties and authenticated user identity to events captured during a request. You can also opt in to browser session and distinct ID headers.
+See our [Django docs](/docs/libraries/django) for how to set up PostHog in Django. The [Django contexts middleware](/docs/libraries/django#django-contexts-middleware) adds request properties and authenticated user identity to events captured during a request. With SDK 7.71.0 or later, it also reads browser session and distinct ID headers by default. Set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` to disable header extraction.
## ASGI request middleware
@@ -680,7 +680,7 @@ app.add_middleware(PosthogASGIMiddleware, client=posthog)
The middleware's `capture_exceptions` option defaults to `None`, which inherits the client's `enable_exception_autocapture` setting. Set it to `True` or `False` to override the client. Use `request_filter` to skip selected requests and `extra_properties` to add properties from the ASGI scope. Both callbacks can be synchronous or asynchronous.
-The middleware ignores `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` by default because clients can control them. Set `trust_tracing_headers=True` only when you want to accept these headers as analytics context. Don't use them for authentication or authorization.
+With SDK 7.71.0 or later, the middleware reads `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` by default. Set `capture_tracing_headers=False` to disable header extraction. Clients can control these headers. Use them only as analytics context, never for authentication or authorization.
To instrument another ASGI framework or a raw ASGI app, wrap the application directly:
diff --git a/contents/docs/libraries/react-router/react-router-v7-framework-mode.mdx b/contents/docs/libraries/react-router/react-router-v7-framework-mode.mdx
index 82098d9a3cc0..6f309e7509fe 100644
--- a/contents/docs/libraries/react-router/react-router-v7-framework-mode.mdx
+++ b/contents/docs/libraries/react-router/react-router-v7-framework-mode.mdx
@@ -141,7 +141,6 @@ posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, { // +
tracing_headers: [ window.location.hostname, 'localhost' ], // +
}); // +
-
startTransition(() => {
hydrateRoot(
document,
@@ -158,6 +157,7 @@ startTransition(() => {
To help PostHog track your user sessions across the client and server, you'll need to add the `tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...]` option to your PostHog initialization. This adds the `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers to requests sent to the configured hostnames, which we'll later use on the server-side.
+
TypeError: Cannot read properties of undefined
@@ -225,47 +225,56 @@ Install the [PostHog Node SDK](/docs/libraries/node) using your package manager.
-Next, create a server-side middleware to help you capture server-side events. This middleware helps you achieve the following:
-- Initialize a PostHog client
-- Fetch the session and distinct ID from the `X-POSTHOG-SESSION-ID` and `X-POSTHOG-DISTINCT-ID` headers and pass them to your request as a [context](/docs/libraries/node#contexts). This automatically identifies the user and session for you in all subsequent event captures.
-- Calls `shutdown()` on the PostHog client to ensure all events are sent before the request is completed.
+Use one PostHog client for a long-running Node server. Create it in a server-only module and import it from your middleware and tracing setup.
-```ts file=app/lib/posthog-middleware.ts
-import { PostHog } from "posthog-node";
-import type { RouterContextProvider } from "react-router";
-import type { Route } from "../+types/root";
+```ts file=app/lib/posthog.server.ts
+import { PostHog } from 'posthog-node';
-export interface PostHogContext extends RouterContextProvider {
- posthog?: PostHog;
-}
+export const posthog = new PostHog(process.env.VITE_POSTHOG_PROJECT_TOKEN!, {
+ host: process.env.VITE_POSTHOG_HOST!,
+});
+```
-export const posthogMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
- const posthog = new PostHog(process.env.VITE_POSTHOG_PROJECT_TOKEN!, {
- host: process.env.VITE_POSTHOG_HOST!,
- flushAt: 1,
- flushInterval: 0,
- });
+In React Router 7, enable server middleware in `react-router.config.ts`:
- const sessionId = request.headers.get('X-POSTHOG-SESSION-ID');
- const distinctId = request.headers.get('X-POSTHOG-DISTINCT-ID');
+```ts file=react-router.config.ts
+export default {
+ future: { v8_middleware: true },
+};
+```
- (context as PostHogContext).posthog = posthog;
+React Router 8 enables server middleware by default. Add the following middleware. It reads PostHog tracing headers and keeps each request context separate. Missing or blank headers add no ID. Header IDs are analytics context and must not authorize access.
- const response = await posthog.withContext(
- { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
- next
- );
+```ts file=app/lib/posthog-middleware.server.ts
+import { createContext, type MiddlewareFunction } from 'react-router';
+import { posthog } from './posthog.server';
+
+type RequestIds = { distinctId?: string; sessionId?: string };
- await posthog.shutdown().catch(() => {});
+export const posthogClientContext = createContext();
+export const posthogErrorContext = createContext(undefined);
+
+function tracingId(value: string | null): string | undefined {
+ return value?.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim().slice(0, 1000) || undefined;
+}
- return response;
+export const posthogMiddleware: MiddlewareFunction = async ({ request, context }, next) => {
+ const existing = posthog.getContext();
+ const data: RequestIds = {
+ distinctId: existing?.distinctId ?? tracingId(request.headers.get('x-posthog-distinct-id')),
+ sessionId: existing?.sessionId ?? tracingId(request.headers.get('x-posthog-session-id')),
+ };
+ context.set(posthogClientContext, posthog);
+ context.set(posthogErrorContext, data);
+
+ return posthog.withContext(data, () => next(), { fresh: true });
};
```
-Then, you'll need to register the middleware in your app in the `app/root.tsx` file by exporting it in the `Route.MiddlewareFunction[]` array.
+Register the middleware in `app/root.tsx`. Put an authentication middleware before it if you use `posthog.withContext` to establish a verified application identity. Existing application IDs take priority over header IDs.
```tsx file=app/root.tsx
-import { posthogMiddleware } from './lib/posthog-middleware';
+import { posthogMiddleware } from './lib/posthog-middleware.server';
export const middleware: Route.MiddlewareFunction[] = [
posthogMiddleware,
@@ -273,23 +282,60 @@ export const middleware: Route.MiddlewareFunction[] = [
];
```
+Work that the request starts keeps its IDs after `next()` settles. Pass explicit context to separate jobs that need their own IDs. Flush queued events before your platform can stop server work. Call `posthog.shutdown()` once when the server stops.
+
+To capture server errors, add the capture call to your existing `handleError` export in `app/entry.server.tsx`. The saved IDs remain available when a middleware error reaches this hook outside the scope. Errors before this middleware runs have no saved request identity.
+
+```ts file=app/entry.server.tsx
+import type { HandleErrorFunction } from 'react-router';
+import { posthog } from './lib/posthog.server';
+import { posthogErrorContext } from './lib/posthog-middleware.server';
+
+export const handleError: HandleErrorFunction = (error, { context }) => {
+ const saved = context.get(posthogErrorContext);
+ posthog.captureException(error, saved?.distinctId,
+ saved?.sessionId ? { $session_id: saved.sessionId } : undefined);
+};
+```
+
+### Optional server span context
+
+Use the optional `posthog-node/otel` entrypoint when your Node SDK includes it. Add `new PostHogContextSpanProcessor(posthog)` to the processors in your existing OpenTelemetry setup. Import the processor from `posthog-node/otel` and reuse the client from `posthog.server.ts`. Keep your provider, exporters, and sampler.
+
+Import the helper in `posthog-middleware.server.ts`:
+
+```ts
+import { enrichCurrentSpan } from 'posthog-node/otel';
+```
+
+Inside the `withContext` callback, call it immediately before `next()`:
+
+```ts
+ enrichCurrentSpan(posthog);
+ return await next();
+```
+
+The helper fills missing `posthogDistinctId` and `sessionId` attributes on the active span and its nearest recording server ancestor whose parent chain the processor observed. Explicit span attributes stay unchanged. The processor adds the request IDs to new child spans. If a sampler drops an intermediate span, the earlier server span stays unchanged because its parent chain cannot be verified.
+
+React Router provides [server instrumentation hooks](https://reactrouter.com/how-to/instrumentation) for application-owned tracing. Their stable export, `instrumentations`, is available from 7.15.0. This setup does not create request spans. Configure those hooks or your existing server tracing to start a span before the middleware runs. This recipe was tested with React Router 7.15.0 and 8.4.0 on Node.
+
-At this point, you should be able to capture server-side events and see them in your PostHog project.
+At this point, you should be able to capture server-side events and see them in your PostHog project.
In a route, you can access the PostHog client from the context and capture an event. The middleware assigns the session ID and the distinct ID. This ensures that the system associates events with the correct user and session.
```tsx file=app/routes/api.checkout.ts
-import type { PostHogContext } from "../lib/posthog-middleware";
+import { posthogClientContext } from "../lib/posthog-middleware.server";
export async function action({ request, context }: Route.ActionArgs) {
const body = await request.json();
// ... existing code ...
// Access the PostHog client from the context and capture an event
- const posthog = (context as PostHogContext).posthog;
+ const posthog = context.get(posthogClientContext);
posthog?.capture({ event: 'checkout_completed' });
return Response.json({
@@ -298,6 +344,7 @@ export async function action({ request, context }: Route.ActionArgs) {
});
}
```
+
diff --git a/contents/docs/libraries/remix.mdx b/contents/docs/libraries/remix.mdx
index 01c8e42a9647..678df0a07c60 100644
--- a/contents/docs/libraries/remix.mdx
+++ b/contents/docs/libraries/remix.mdx
@@ -115,6 +115,51 @@ export default function App() {
When you run your app now, PostHog will automatically capture events and pageviews. You can also use the other features of PostHog by importing and using the `usePostHog` hook in your components.
+## Node.js server context and traces
+
+For a Remix server on Node.js, use the `@remix-run/express` adapter and the shared Node SDK request middleware. The browser setup above does not add server context.
+
+Reuse one `posthog-node` client from your server setup. Add `PostHogContextSpanProcessor` from `posthog-node/otel` to your application's existing OpenTelemetry provider. Keep your exporter, sampler, and other processors. Load your tracing setup, including HTTP and Express instrumentation, before importing the server modules below.
+
+```ts
+import express from 'express'
+import { createRequestHandler } from '@remix-run/express'
+import { setupExpressRequestContext } from 'posthog-node'
+import posthog from './posthog.server.js'
+import * as build from './build/server/index.js'
+
+const app = express()
+
+setupExpressRequestContext(posthog, app)
+
+app.all('*', createRequestHandler({
+ build,
+ getLoadContext: () => ({ posthog }),
+}))
+```
+
+Place the middleware before `createRequestHandler`. This covers document requests, loader data requests, actions, resource routes, server rendering, and streamed responses. Wrapping only `entry.server` misses loader and action requests that do not render a document.
+
+The middleware reads the PostHog tracing headers and fills missing context. An existing application identity takes precedence over the browser value. Use your existing authentication setup for application identity. These headers are analytics context and must not be used for authentication or authorization.
+
+Only requests that carry the headers receive browser context. Fetch/XHR instrumentation does not add these headers to standard browser document navigations, so initial server rendering can have no browser IDs.
+
+The optional processor adds `sessionId` and `posthogDistinctId` to application spans. At request entry, it can also fill missing IDs on the nearest recording HTTP server span through parent objects observed by the processor. Explicit canonical attributes keep their values. If the sampler drops an intermediate span, the processor cannot verify the earlier server ancestor and leaves it unchanged. Recording spans created within the request still receive context.
+
+Request context stays active for async work that the request starts, including streaming work and work that continues after the response ends. Use a new explicit context for a background job that needs its own IDs.
+
+Remix handles many loader and action errors inside its router, so an Express error handler alone does not capture all of them. To capture these errors, use Remix's `handleError` export in `app/entry.server.tsx` with the same server client:
+
+```ts
+import posthog from '../posthog.server.js'
+
+export function handleError(error: unknown) {
+ posthog.captureException(error)
+}
+```
+
+This setup was checked with Remix 2.17.5 and its Node.js Express adapter. Other adapters and runtimes need their own context and lifetime setup. See the [Node.js tracing guide](/docs/distributed-tracing/installation/nodejs) for the shared processor setup and limits.
+
## Identifying users
import IdentifyFrontendCallout from '../_snippets/identify-frontend-callout.mdx'
diff --git a/contents/docs/libraries/ruby-on-rails.mdx b/contents/docs/libraries/ruby-on-rails.mdx
index d6ffe76ffcf7..867514c8ef5e 100644
--- a/contents/docs/libraries/ruby-on-rails.mdx
+++ b/contents/docs/libraries/ruby-on-rails.mdx
@@ -176,6 +176,22 @@ Disable tracing header identity/session capture if you do not want client-suppli
PostHog::Rails.config.use_tracing_headers = false
```
+## Trace context
+
+PostHog Rails adds missing `sessionId` and `posthogDistinctId` attributes to an active OpenTelemetry SDK request span after it reads the request context. To also add these attributes to child spans, register the optional processor on your existing OpenTelemetry provider before requests start:
+
+```ruby
+require 'posthog/otel/context_span_processor'
+
+OpenTelemetry.tracer_provider.add_span_processor(
+ PostHog::ContextSpanProcessor.new
+)
+```
+
+This integration supports OpenTelemetry SDK 1.0.0 or later. Keep your existing provider, exporter, sampler, and processors. Put the OpenTelemetry request middleware outside the PostHog request middleware so the request span is active when PostHog reads the context. To send spans to PostHog, configure your existing OTLP exporter with the [PostHog trace endpoint](/docs/distributed-tracing/installation).
+
+Existing `sessionId` and `posthogDistinctId` span attributes take precedence. Set authenticated application identity with `posthogDistinctId`. Missing IDs stay absent. Spans that already started keep their attributes after the request ends. Separate threads and fibers need explicit context transfer. With `config.auto_instrument_active_job = true`, Active Job copies the session and distinct IDs into queued jobs, so job spans get them too. The processor does not create a session, recording, trace provider, or exporter.
+
## Logs
To set up [PostHog Logs](/docs/logs) in your Rails app, follow the [Ruby on Rails logs installation guide](/docs/logs/installation/ruby-on-rails). The integration forwards `Rails.logger` output to PostHog Logs over OpenTelemetry, automatically correlated with each request's distinct ID and session ID. Requires Ruby 3.3+.
@@ -456,7 +472,6 @@ Configure these via `PostHog::Rails.configure` or `PostHog::Rails.config`:
| `current_user_method` | Symbol | `:current_user` | Controller method used to fetch the current user. |
| `user_id_method` | Symbol | `nil` | Method used to extract the distinct ID from the user object. Auto-detects when nil. |
-
## Troubleshooting
### Exceptions not being captured
@@ -484,7 +499,6 @@ Configure these via `PostHog::Rails.configure` or `PostHog::Rails.config`:
Ensure you've set `personal_api_key` in your configuration.
-
## Next steps
For any technical questions for how to integrate specific PostHog features into Rails (such as analytics, feature flags, A/B testing, etc.), have a look at our [Ruby SDK docs](/docs/libraries/ruby).
diff --git a/contents/docs/libraries/svelte.mdx b/contents/docs/libraries/svelte.mdx
index c31bae0bd824..ee1e687208b4 100644
--- a/contents/docs/libraries/svelte.mdx
+++ b/contents/docs/libraries/svelte.mdx
@@ -45,6 +45,61 @@ import SvelteInstallServer from "../integrate/_snippets/install-svelte-server.md
+## Server span context
+
+For a Node server with SvelteKit 2.31.0 or later, use the same server client for PostHog events and OpenTelemetry span context. This setup needs the optional `posthog-node/otel` entrypoint.
+
+Enable [SvelteKit server tracing and instrumentation](https://svelte.dev/docs/kit/observability). In SvelteKit 3, configure `tracing: { server: true }` in the `sveltekit(...)` Vite plugin options. In SvelteKit 2.31, configure `kit.experimental.tracing.server` and `kit.experimental.instrumentation.server` in `svelte.config.js`.
+
+Add `new PostHogContextSpanProcessor(posthog)` to the span processors in your existing OpenTelemetry setup in `src/instrumentation.server.js`. Import the processor from `posthog-node/otel` and the client from `$lib/server/posthog`. Keep your existing provider, exporters, and sampler. The processor fills missing `posthogDistinctId` and `sessionId` attributes on child spans.
+
+Add this server hook. If you already have hooks, add this scope around their request work and add the capture call to your existing `handleError` function. An authentication hook can establish the verified identity with `posthog.withContext` before this scope starts. Header IDs are analytics context and must not authorize access.
+
+```js file=src/hooks.server.js
+import { context, trace, isSpanContextValid } from '@opentelemetry/api';
+import { enrichCurrentSpan } from 'posthog-node/otel';
+import { posthog } from '$lib/server/posthog';
+
+function tracingId(value) {
+ return value?.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim().slice(0, 1000) || undefined;
+}
+
+export async function handle({ event, resolve }) {
+ const existing = posthog.getContext();
+ const data = {
+ distinctId: existing?.distinctId ?? tracingId(event.request.headers.get('x-posthog-distinct-id')),
+ sessionId: existing?.sessionId ?? tracingId(event.request.headers.get('x-posthog-session-id')),
+ };
+ const spanContext = event.tracing.root.spanContext();
+ event.locals.posthogError = {
+ distinctId: data.distinctId,
+ properties: {
+ ...(data.sessionId ? { $session_id: data.sessionId } : {}),
+ ...(event.tracing.enabled && isSpanContextValid(spanContext)
+ ? { $trace_id: spanContext.traceId, $span_id: spanContext.spanId }
+ : {}),
+ },
+ };
+
+ return posthog.withContext(data, async () => {
+ if (event.tracing.enabled) {
+ context.with(trace.setSpan(context.active(), event.tracing.root), () => enrichCurrentSpan(posthog));
+ }
+ enrichCurrentSpan(posthog);
+ return await resolve(event);
+ }, { fresh: true });
+}
+
+export function handleError({ error, event, message }) {
+ posthog.captureException(error, event.locals.posthogError?.distinctId, event.locals.posthogError?.properties);
+ return { message };
+}
+```
+
+SvelteKit starts its request root span before this hook. The two `enrichCurrentSpan` calls fill missing IDs on that public root span and on the active span. Explicit span attributes remain unchanged. A missing or blank header adds no ID.
+
+Work that the request starts keeps its IDs after `resolve(event)` settles. Pass explicit context to separate jobs that need their own IDs. The saved values on `event.locals` let `handleError` attribute a hook error that SvelteKit reports outside this scope. Errors before this hook runs have no saved request identity. Add the `posthogError` field to `App.Locals` if your app uses TypeScript.
+
## Feature flags
To use client-side feature flags, import PostHog into your Svelte component and check if the feature is enabled (while ensuring the code only runs in the browser).
@@ -69,20 +124,15 @@ To use client-side feature flags, import PostHog into your Svelte component and
{/if}
```
-To use server-side feature flags, import PostHog into your SvelteKit `load` function and check if the feature is enabled.
+To use server-side feature flags, import PostHog into your SvelteKit `load` function and check if the feature is enabled.
```js file=routes/+page.server.js
-import { PostHog } from 'posthog-node';
-
-const client = new PostHog(
- '',
- { host: '' }
-);
+import { posthog } from '$lib/server/posthog';
export async function load() {
const distinctId = 'distinct_id_of_the_user';
- const megaFeature = await client.isFeatureEnabled(
+ const megaFeature = await posthog.isFeatureEnabled(
'mega-feature',
distinctId
);
@@ -118,4 +168,3 @@ Alternatively, the following tutorials can help you get started:
- [How to set up Svelte analytics, feature flags, and more](/tutorials/svelte-analytics)
- [How to set up A/B tests in Svelte](/tutorials/svelte-ab-tests)
- [How to set up surveys in Svelte](/tutorials/svelte-surveys)
-
diff --git a/contents/docs/libraries/tanstack-start.mdx b/contents/docs/libraries/tanstack-start.mdx
index 8567af36a607..905c8e553326 100644
--- a/contents/docs/libraries/tanstack-start.mdx
+++ b/contents/docs/libraries/tanstack-start.mdx
@@ -36,7 +36,7 @@ import IdentifyFrontendCallout from '../_snippets/identify-frontend-callout.mdx'
## Initialize PostHog on the client
-Wrap your app with `PostHogProvider` in your root route with your project token, host, and other options.
+Wrap your app with `PostHogProvider` in your root route with your project token, host, and other options.
```tsx file=src/routes/__root.tsx
// src/routes/__root.tsx
@@ -156,7 +156,6 @@ Use the server client in TanStack Start API routes to capture events server-side
```ts file=src/routes/api/checkout.ts
// src/routes/api/checkout.ts
import { createFileRoute } from '@tanstack/react-router'
-import { json } from '@tanstack/react-start'
import { getPostHogClient } from '../../utils/posthog-server'
export const Route = createFileRoute('/api/checkout')({
@@ -177,7 +176,7 @@ export const Route = createFileRoute('/api/checkout')({
},
})
- return json({ success: true })
+ return Response.json({ success: true })
},
},
},
@@ -186,6 +185,80 @@ export const Route = createFileRoute('/api/checkout')({
The server-side `capture` call requires a `distinctId` (the user identifier), an `event` name, and optional `properties`.
+## Add request context to backend spans
+
+For a Node.js deployment with OpenTelemetry, reuse `getPostHogClient()` for both request context and the span processor. Add this processor to your existing tracing provider before the server accepts requests:
+
+```ts
+import { PostHogContextSpanProcessor } from 'posthog-node/otel'
+import { getPostHogClient } from './src/utils/posthog-server'
+
+const posthogSpanProcessor = new PostHogContextSpanProcessor(getPostHogClient())
+```
+
+Include `posthogSpanProcessor` in that provider's `spanProcessors` configuration. Keep your existing exporter, sampler, and instrumentation. See the [Node.js tracing setup](/docs/distributed-tracing/installation/nodejs#with-opentelemetry) for provider setup. Keep this setup on the server. Shut down the provider and the PostHog client through your application's shutdown hook, after requests finish.
+
+Add global request middleware in `src/start.ts`. If that file already exists, add the middleware to its existing configuration. Keep your existing CSRF and authentication middleware. This example includes TanStack Start's CSRF middleware because defining `src/start.ts` replaces its default setup.
+
+```ts file=src/start.ts
+import {
+ createCsrfMiddleware,
+ createMiddleware,
+ createStart,
+} from '@tanstack/react-start'
+import { enrichCurrentSpan } from 'posthog-node/otel'
+import { getPostHogClient } from './utils/posthog-server'
+
+function tracingHeader(request: Request, name: string): string | undefined {
+ const value = request.headers.get(name)
+ return value?.replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim().slice(0, 1000) || undefined
+}
+
+const posthogContext = createMiddleware().server(async ({ request, next }) => {
+ const posthog = getPostHogClient()
+ const existing = posthog.getContext()
+
+ return posthog.withContext(
+ {
+ distinctId:
+ existing?.distinctId ??
+ tracingHeader(request, 'X-POSTHOG-DISTINCT-ID'),
+ sessionId:
+ existing?.sessionId ??
+ tracingHeader(request, 'X-POSTHOG-SESSION-ID'),
+ },
+ async () => {
+ enrichCurrentSpan(posthog)
+ return await next()
+ },
+ { fresh: true }
+ )
+})
+
+export const startInstance = createStart(() => ({
+ requestMiddleware: [createCsrfMiddleware({ filter: ({ handlerType }) => handlerType === 'serverFn' }), posthogContext],
+}))
+```
+
+This setup was checked with TanStack Start 1.169.3 on Node.js. It runs for SSR, server routes, and server functions. It reads the two browser tracing headers without copying other headers or request data. The header helper removes control characters, trims whitespace, and limits each ID to 1,000 characters. An identity already set by application code takes precedence. Treat browser IDs as analytics data, not authentication. Set authenticated identity before this middleware when you need it to take precedence.
+
+The processor fills missing `sessionId` and `posthogDistinctId` span attributes. Explicit canonical attributes stay unchanged, including non-string values. `user.id` alone is not an override. At request entry, the helper also fills missing attributes on the nearest recording server ancestor whose parent chain the processor observed. A sampler-dropped intermediate breaks that chain, so the earlier server span stays unchanged.
+
+Work that the request starts, including detached tasks, keeps the IDs after `next()` returns or throws. This example does not support the edge runtime. Give separately owned background work that needs its own IDs an explicit context.
+
+TanStack handles some route and server-function errors before middleware can see them. Capture an error once at the application handler that owns it, while request context is active:
+
+```ts
+try {
+ await savePurchase()
+} catch (error) {
+ getPostHogClient().captureException(error)
+ throw error
+}
+```
+
+Do not add a second capture if an existing error handler already reports the same error. This middleware does not enable automatic error capture.
+
## Next steps
Installing the JS Web SDK and Node SDK means all of their functionality is available in your TanStack Start project. To learn more about this, have a look at our [JS Web SDK docs](/docs/libraries/js/usage) and [Node SDK docs](/docs/libraries/node).
\ No newline at end of file