Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fiery-maps-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-server": minor
---

Add an optional OpenTelemetry span processor and request-scope bridge for session and person context on application spans. The processor is bound to one PostHog client. Only scopes owned by that client add IDs, and a disabled or opted-out client adds none. A nested scope without a client keeps the outer scope's owner. If the application replaces its client, build a new processor.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,32 @@ SDK usage examples and code snippets live in the official documentation so they

Do not exclude that artifact from your build. Without it, session replay, interaction autocapture and touch tracking stay off for the whole process. The SDK writes a warning to Logcat when this happens.

## Server span context

`posthog-server` can add `sessionId` and `posthogDistinctId` attributes to recording OpenTelemetry SDK spans. To turn this on, register `com.posthog.server.otel.PostHogContextSpanProcessor` on the application's existing provider. Then open each request scope with the same PostHog client.

Spans get IDs only from a scope owned by that client. A disabled or opted-out client adds no IDs. An outermost scope opened without a client adds no span IDs. A nested scope opened without a client keeps the owner of the outer scope.

When a client-owned scope opens, it also adds IDs to the current active span. This happens when that client has a registered processor on any provider.

The processor holds its client weakly. If the application closes its client and makes a new one, build a new processor and provider for the new client.

```kotlin
val posthog = PostHog.with(config)

val tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(PostHogContextSpanProcessor(posthog))
.build()

PostHogRequestContext.withContext(posthog, PostHogRequestContext.fromHeaders(headers), fresh = true) {
handleRequest()
}
```

OpenTelemetry SDK 1.32.0 or later remains an optional application dependency. Existing canonical attributes take precedence, including attributes with other value types. The bridge preserves the application's sampling and export setup.

Use a fresh request scope and close it on the same thread. Work on another thread needs its own context scope. Automatic coroutine, Reactor, and framework propagation require application setup. See the [Java tracing guide](https://posthog.com/docs/distributed-tracing/installation/java) for setup.

## Questions?

### [Check out our community page.](https://posthog.com/posts)
Expand Down
16 changes: 16 additions & 0 deletions posthog-server/api/posthog-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ public final class com/posthog/server/PostHogRequestContext {
public static final field Companion Lcom/posthog/server/PostHogRequestContext$Companion;
public static final field DISTINCT_ID_HEADER Ljava/lang/String;
public static final field SESSION_ID_HEADER Ljava/lang/String;
public static final fun beginScope (Lcom/posthog/server/PostHogInterface;Lcom/posthog/server/PostHogRequestContextData;)Lcom/posthog/server/PostHogRequestContext$Scope;
public static final fun beginScope (Lcom/posthog/server/PostHogInterface;Lcom/posthog/server/PostHogRequestContextData;Z)Lcom/posthog/server/PostHogRequestContext$Scope;
public static final fun beginScope (Lcom/posthog/server/PostHogRequestContextData;)Lcom/posthog/server/PostHogRequestContext$Scope;
public static final fun beginScope (Lcom/posthog/server/PostHogRequestContextData;Z)Lcom/posthog/server/PostHogRequestContext$Scope;
public static final fun current ()Lcom/posthog/server/PostHogRequestContextData;
Expand All @@ -485,13 +487,17 @@ public final class com/posthog/server/PostHogRequestContext {
}

public final class com/posthog/server/PostHogRequestContext$Companion {
public final fun beginScope (Lcom/posthog/server/PostHogInterface;Lcom/posthog/server/PostHogRequestContextData;)Lcom/posthog/server/PostHogRequestContext$Scope;
public final fun beginScope (Lcom/posthog/server/PostHogInterface;Lcom/posthog/server/PostHogRequestContextData;Z)Lcom/posthog/server/PostHogRequestContext$Scope;
public final fun beginScope (Lcom/posthog/server/PostHogRequestContextData;)Lcom/posthog/server/PostHogRequestContext$Scope;
public final fun beginScope (Lcom/posthog/server/PostHogRequestContextData;Z)Lcom/posthog/server/PostHogRequestContext$Scope;
public final fun current ()Lcom/posthog/server/PostHogRequestContextData;
public final fun fromHeaders (Ljava/util/Map;)Lcom/posthog/server/PostHogRequestContextData;
public final fun fromHeaders (Ljava/util/Map;Z)Lcom/posthog/server/PostHogRequestContextData;
public final fun fromHeaders (Ljava/util/Map;ZLjava/util/Map;)Lcom/posthog/server/PostHogRequestContextData;
public static synthetic fun fromHeaders$default (Lcom/posthog/server/PostHogRequestContext$Companion;Ljava/util/Map;ZLjava/util/Map;ILjava/lang/Object;)Lcom/posthog/server/PostHogRequestContextData;
public final fun withContext (Lcom/posthog/server/PostHogInterface;Lcom/posthog/server/PostHogRequestContextData;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object;
public final fun withContext (Lcom/posthog/server/PostHogInterface;Lcom/posthog/server/PostHogRequestContextData;ZLkotlin/jvm/functions/Function0;)Ljava/lang/Object;
public final fun withContext (Lcom/posthog/server/PostHogRequestContextData;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object;
public final fun withContext (Lcom/posthog/server/PostHogRequestContextData;ZLkotlin/jvm/functions/Function0;)Ljava/lang/Object;
}
Expand All @@ -509,3 +515,13 @@ public final class com/posthog/server/PostHogRequestContextData {
public final fun getSessionId ()Ljava/lang/String;
}

public final class com/posthog/server/otel/PostHogContextSpanProcessor : io/opentelemetry/sdk/trace/SpanProcessor {
public fun <init> (Lcom/posthog/server/PostHogInterface;)V
public fun forceFlush ()Lio/opentelemetry/sdk/common/CompletableResultCode;
public fun isEndRequired ()Z
public fun isStartRequired ()Z
public fun onEnd (Lio/opentelemetry/sdk/trace/ReadableSpan;)V
public fun onStart (Lio/opentelemetry/context/Context;Lio/opentelemetry/sdk/trace/ReadWriteSpan;)V
public fun shutdown ()Lio/opentelemetry/sdk/common/CompletableResultCode;
}

2 changes: 2 additions & 0 deletions posthog-server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ dependencies {
signature("org.codehaus.mojo.signature:java18:${PosthogBuildConfig.Plugins.SIGNATURE_JAVA18}@signature")

compileOnly("io.opentelemetry:opentelemetry-api:1.32.0")
compileOnly("io.opentelemetry:opentelemetry-sdk-trace:1.32.0")

// tests
testImplementation("io.opentelemetry:opentelemetry-api:1.32.0")
testImplementation("io.opentelemetry:opentelemetry-sdk-trace:1.32.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:${PosthogBuildConfig.Dependencies.MOCKITO}")
testImplementation("org.mockito:mockito-inline:${PosthogBuildConfig.Dependencies.MOCKITO_INLINE}")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${PosthogBuildConfig.Kotlin.KOTLIN}")
Expand Down
2 changes: 2 additions & 0 deletions posthog-server/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ com.squareup.okio:okio:3.11.0=jvmTestRuntimeClasspathForLint,runtimeClasspath,te
com.squareup.okio:okio:3.6.0=compileClasspath,implementationDependenciesMetadata,jvmTestCompileClasspathForLint,testCompileClasspath,testImplementationDependenciesMetadata
io.opentelemetry:opentelemetry-api:1.32.0=compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-context:1.32.0=compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk-common:1.32.0=compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk-trace:1.32.0=compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testRuntimeClasspath
it.unimi.dsi:fastutil-core:8.5.12=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin
jakarta.activation:jakarta.activation-api:1.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.posthog.server

import com.posthog.server.internal.enrichCurrentSpanSafely
import java.util.UUID

/**
Expand All @@ -23,6 +24,9 @@ public class PostHogRequestContext private constructor() {
private const val MAX_HEADER_VALUE_LENGTH = 1000

private val currentContext = ThreadLocal<PostHogRequestContextData?>()
private val currentOwner = ThreadLocal<PostHogInterface?>()

internal fun owner(): PostHogInterface? = currentOwner.get()

/**
* Returns the context active on the current thread, or null when no scope is active.
Expand Down Expand Up @@ -54,11 +58,43 @@ public class PostHogRequestContext private constructor() {
public fun beginScope(
data: PostHogRequestContextData,
fresh: Boolean,
): Scope {
return beginOwnedScope(if (fresh) null else currentOwner.get(), data, fresh)
}

/**
* Starts a scope owned by [owner] for capture and optional span enrichment.
* A different owner does not inherit the previous client's IDs or properties.
*/
@JvmStatic
public fun beginScope(
owner: PostHogInterface,
data: PostHogRequestContextData,
): Scope = beginScope(owner, data, fresh = false)

/**
* Starts an owner-bound scope. Only this client's registered processor may enrich spans.
* Opted-out clients add no attributes. [fresh] ignores any previous context.
*/
@JvmStatic
public fun beginScope(
owner: PostHogInterface,
data: PostHogRequestContextData,
fresh: Boolean,
): Scope = beginOwnedScope(owner, data, fresh)

private fun beginOwnedScope(
owner: PostHogInterface?,
data: PostHogRequestContextData,
fresh: Boolean,
): Scope {
val previous = currentContext.get()
val base = if (fresh) null else previous
val previousOwner = currentOwner.get()
val base = if (fresh || previousOwner !== owner) null else previous
currentContext.set(resolveContext(data, base))
return Scope(previous)
currentOwner.set(owner)
enrichCurrentSpanSafely()
return Scope(previous, previousOwner)
}

/**
Expand Down Expand Up @@ -91,6 +127,29 @@ public class PostHogRequestContext private constructor() {
}
}

/**
* Runs [block] in an owner-bound scope and restores the previous scope afterwards.
*/
public fun <T> withContext(
owner: PostHogInterface,
data: PostHogRequestContextData,
block: () -> T,
): T = withContext(owner, data, fresh = false, block)

/**
* Runs [block] in an owner-bound scope, optionally ignoring previous context with [fresh].
*/
public fun <T> withContext(
owner: PostHogInterface,
data: PostHogRequestContextData,
fresh: Boolean,
block: () -> T,
): T {
beginScope(owner, data, fresh).use {
return block()
}
}

/**
* Extracts PostHog tracing headers into context data.
*
Expand Down Expand Up @@ -244,7 +303,10 @@ public class PostHogRequestContext private constructor() {
/**
* Active request context scope. Close it to restore the previous context.
*/
public class Scope internal constructor(private val previous: PostHogRequestContextData?) : AutoCloseable {
public class Scope internal constructor(
private val previous: PostHogRequestContextData?,
private val previousOwner: PostHogInterface?,
) : AutoCloseable {
private var closed: Boolean = false

override fun close() {
Expand All @@ -257,6 +319,11 @@ public class PostHogRequestContext private constructor() {
} else {
currentContext.set(previous)
}
if (previousOwner == null) {
currentOwner.remove()
} else {
currentOwner.set(previousOwner)
}
closed = true
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.posthog.server.internal

import com.posthog.server.PostHogInterface
import com.posthog.server.PostHogRequestContext
import java.lang.ref.WeakReference
import java.util.WeakHashMap

private val registeredProcessors = WeakHashMap<Any, WeakReference<PostHogInterface>>()

internal fun registerSpanContextOwner(owner: PostHogInterface): Any =
Any().also { token ->
synchronized(registeredProcessors) {
registeredProcessors[token] = WeakReference(owner)
}
}

internal fun unregisterSpanContextOwner(token: Any) {
synchronized(registeredProcessors) {
registeredProcessors.remove(token)
}
}

private val openTelemetrySdkAvailable: Boolean by lazy {
try {
Class.forName("io.opentelemetry.sdk.trace.ReadWriteSpan", false, PostHogRequestContext::class.java.classLoader)
true
} catch (_: Exception) {
false
} catch (_: LinkageError) {
false
}
}

internal fun enrichCurrentSpanSafely() {
val owner = PostHogRequestContext.owner() ?: return
val registered = synchronized(registeredProcessors) { registeredProcessors.values.any { it.get() === owner } }
if (!registered || !openTelemetrySdkAvailable) {
return
}
try {
enrichActiveOpenTelemetrySpan(owner)
} catch (_: Exception) {
} catch (_: LinkageError) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.posthog.server.internal

import com.posthog.server.PostHog
import com.posthog.server.PostHogInterface
import com.posthog.server.PostHogRequestContext
import io.opentelemetry.api.trace.Span
import io.opentelemetry.sdk.trace.ReadWriteSpan

internal fun enrichActiveOpenTelemetrySpan(owner: PostHogInterface) {
val span = Span.current()
if (span is ReadWriteSpan) {
enrichSpanWithRequestContext(owner, span)
}
}

private fun PostHogInterface.optedOut(): Boolean =
when (this) {
is PostHog -> isOptOut()
}

internal fun enrichSpanWithRequestContext(
owner: PostHogInterface,
span: ReadWriteSpan,
) {
try {
// Check the owner first because a closed client logs on each isOptOut() call.
if (PostHogRequestContext.owner() !== owner || !span.isRecording || owner.optedOut()) {
return
}
val context = PostHogRequestContext.current() ?: return
val existingNames = span.toSpanData().attributes.asMap().keys.map { it.key }.toSet()
listOf("sessionId" to context.sessionId, "posthogDistinctId" to context.distinctId).forEach { (key, value) ->
if (!value.isNullOrBlank() && key !in existingNames) {
span.setAttribute(key, value)
}
}
} catch (_: Exception) {
} catch (_: LinkageError) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.posthog.server.otel

import com.posthog.server.PostHogInterface
import com.posthog.server.PostHogRequestContext
import com.posthog.server.internal.enrichSpanWithRequestContext
import com.posthog.server.internal.registerSpanContextOwner
import com.posthog.server.internal.unregisterSpanContextOwner
import io.opentelemetry.context.Context
import io.opentelemetry.sdk.common.CompletableResultCode
import io.opentelemetry.sdk.trace.ReadWriteSpan
import io.opentelemetry.sdk.trace.ReadableSpan
import io.opentelemetry.sdk.trace.SpanProcessor
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicBoolean

/**
* Adds available PostHog request IDs when an application span starts.
*
* Register this processor on the application's existing OpenTelemetry SDK provider.
* OpenTelemetry SDK 1.32.0 or later must be supplied by the application. This processor
* does not export spans or change providers, exporters, or sampling.
*
* The processor reads [PostHogRequestContext] on the current thread and snapshots
* `sessionId` and `posthogDistinctId`. Existing attributes with either name take
* precedence regardless of their type. Missing or blank IDs remain absent.
*
* Bind each request scope to the same owner client. Opted-out clients add no attributes.
* The processor holds the owner weakly. If the application replaces its client,
* build a new processor and provider for the new client.
* Start and close a fresh PostHog context scope for each request. Work on another
* thread requires its own explicit context scope. Close each scope on the thread
* that opened it. This processor does not add
* coroutine, Reactor, or other framework context propagation.
*/
public class PostHogContextSpanProcessor public constructor(owner: PostHogInterface) : SpanProcessor {
private val ownerRef = WeakReference(owner)
private val stopped = AtomicBoolean(false)
private val registration = registerSpanContextOwner(owner)

override fun onStart(
parentContext: Context,
span: ReadWriteSpan,
) {
if (stopped.get()) {
return
}
val owner = ownerRef.get() ?: return
enrichSpanWithRequestContext(owner, span)
}

override fun isStartRequired(): Boolean = true

override fun onEnd(span: ReadableSpan): Unit = Unit

override fun isEndRequired(): Boolean = false

override fun shutdown(): CompletableResultCode {
if (stopped.compareAndSet(false, true)) {
unregisterSpanContextOwner(registration)
}
return CompletableResultCode.ofSuccess()
}

override fun forceFlush(): CompletableResultCode = CompletableResultCode.ofSuccess()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.posthog.server

public object NoOpenTelemetryScopeFixture {
@JvmStatic
public fun main(args: Array<String>) {
check(runCatching { Class.forName("io.opentelemetry.sdk.trace.ReadWriteSpan") }.isFailure)
val apiPresent = runCatching { Class.forName("io.opentelemetry.api.trace.Span") }.isSuccess
check(apiPresent == (args.firstOrNull() == "api-only"))
PostHogRequestContext.withContext(PostHogRequestContextData("example-person", "example-session"), fresh = true) {
check(PostHogRequestContext.current()?.sessionId == "example-session")
try {
PostHogRequestContext.withContext(PostHogRequestContextData(sessionId = "nested")) {
throw IllegalStateException("synthetic failure")
}
} catch (_: IllegalStateException) {
check(PostHogRequestContext.current()?.sessionId == "example-session")
}
}
check(PostHogRequestContext.current() == null)
}
}
Loading
Loading