Skip to content

Commit cf4a852

Browse files
committed
feat(server): opt-in uncaught exception capture
Adds captureUncaughtExceptions (default false) to the server PostHogConfig. When enabled, the core PostHogErrorTrackingAutoCaptureIntegration installs a Thread.defaultUncaughtExceptionHandler that captures the throwable as a fatal, unhandled $exception (mechanism UncaughtExceptionHandler), flushes, then delegates to the previously installed handler. Core changes (all additive; Android behavior and the released install(PostHogInterface) path unchanged): - Gate strategy seam on the integration so the server can install with a local-only gate (no remote config, which the server SDK never fetches); Android keeps the remote errorTracking.autocaptureExceptions gate. - Captures flow through an internal CaptureTarget seam so the server's stateless client can drive the integration. - Handler-install ownership is tracked per integration instance, so closing a second opted-in client (whose install was a process-wide no-op) does not tear down the handler a still-open first client owns. - New @PostHogInternal PostHogCapturedThrowables identity marker (weak, ReferenceQueue-pruned). The guard is directional: log mirrors consult it, the uncaught handler only marks — a crash is always captured as the authoritative fatal/unhandled record even if the same instance was logged first (logger.error(..., e); throw e), and marking keeps post-crash log mirrors from re-reporting it. - Repeated setup() cannot replace the owning integration with a non-owning one, which would leave the global handler installed after close(). - With no previous default handler to chain to, the handler reproduces the JVM's built-in stderr crash output so enabling capture never hides crashes from stderr log collection. - Server config KDoc documents the flushAt implication for the crash path.
1 parent 2174c3c commit cf4a852

12 files changed

Lines changed: 736 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog': patch
3+
---
4+
5+
Add an internal process-wide `PostHogCapturedThrowables` guard (marked `@PostHogInternal`, visible only because of the multi-module architecture) that lets independent error-capture paths avoid double-reporting the same `Throwable` instance. The guard is directional: log-mirror paths (e.g. the `posthog-server-logback` appender) consult it and skip instances already reported, while the uncaught-exception handler only marks — a crash is always captured as the authoritative fatal/unhandled record even if the same instance was logged first, and marking it keeps post-crash log mirrors from reporting it again. Membership is keyed on instance identity and held weakly, so the guard never keeps a throwable or its stack alive.

.changeset/core-uncaught-gate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog': patch
3+
---
4+
5+
`PostHogErrorTrackingAutoCaptureIntegration` can now be gated on a caller-supplied strategy instead of the built-in gate (local `errorTrackingConfig.autoCapture` with remote config as a kill-switch): a new `PostHogErrorTrackingAutoCaptureIntegration(config, enabledGate)` constructor lets SDK layers that never fetch remote config (e.g. the server SDK) decide autocapture purely from local config. The uncaught handler also delivers captures through an internal `CaptureTarget` seam (`installWith`) so it can drive clients that are not a core `PostHogInterface`, and when no previous default handler exists it now reproduces the JVM's own `Exception in thread ...` stderr output, so installing capture never hides a crash from log collection. Android behavior and the existing `install(PostHogInterface)` path are otherwise unchanged; the additions are internal (`@PostHogInternal`) and visible only because of the multi-module architecture.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-server': minor
3+
---
4+
5+
Opt in to capturing uncaught JVM exceptions on the server SDK via `PostHogConfig.captureUncaughtExceptions` (also on the config `Builder`). When enabled, `PostHog` installs a global `Thread.defaultUncaughtExceptionHandler` on setup that captures the crashing exception as a fatal, unhandled `$exception` event (mechanism `UncaughtExceptionHandler`), flushes, and then delegates to the previously registered handler; the handler is removed again on `close()`. Unlike the Android SDK this is gated purely on the local flag — the server SDK never fetches remote config. A fatal `$exception` event is enqueued and sent synchronously on the crashing thread, bypassing `flushAt`, so capturing the crash does not depend on the periodic flush; delivery is still best-effort under an immediate hard exit.

posthog-server/api/posthog-server.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ public class com/posthog/server/PostHogConfig {
134134
public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V
135135
public static final fun builder (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder;
136136
public final fun getApiKey ()Ljava/lang/String;
137+
public final fun getCaptureUncaughtExceptions ()Z
137138
public final fun getDebug ()Z
138139
public final fun getEncryption ()Lcom/posthog/PostHogEncryption;
139140
public final fun getEvaluationContexts ()Ljava/util/List;
@@ -158,6 +159,7 @@ public class com/posthog/server/PostHogConfig {
158159
public final fun getRemoteConfig ()Z
159160
public final fun getSendFeatureFlagEvent ()Z
160161
public final fun removeBeforeSend (Lcom/posthog/PostHogBeforeSend;)V
162+
public final fun setCaptureUncaughtExceptions (Z)V
161163
public final fun setDebug (Z)V
162164
public final fun setEncryption (Lcom/posthog/PostHogEncryption;)V
163165
public final fun setEvaluationContexts (Ljava/util/List;)V
@@ -185,6 +187,7 @@ public class com/posthog/server/PostHogConfig {
185187
public final class com/posthog/server/PostHogConfig$Builder {
186188
public fun <init> (Ljava/lang/String;)V
187189
public final fun build ()Lcom/posthog/server/PostHogConfig;
190+
public final fun captureUncaughtExceptions (Z)Lcom/posthog/server/PostHogConfig$Builder;
188191
public final fun debug (Z)Lcom/posthog/server/PostHogConfig$Builder;
189192
public final fun encryption (Lcom/posthog/PostHogEncryption;)Lcom/posthog/server/PostHogConfig$Builder;
190193
public final fun evaluationContexts (Ljava/util/List;)Lcom/posthog/server/PostHogConfig$Builder;

posthog-server/src/main/java/com/posthog/server/PostHog.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.posthog.server
33
import com.posthog.FeatureFlagResult
44
import com.posthog.PostHogEventName
55
import com.posthog.PostHogStateless
6+
import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration
67
import com.posthog.internal.FeatureFlag
78
import com.posthog.server.internal.EvaluationsHost
89
import com.posthog.server.internal.PostHogFeatureFlags
@@ -27,11 +28,54 @@ public class PostHog : PostHogStateless(), PostHogInterface {
2728
}
2829
}
2930

31+
/**
32+
* Uncaught-exception integration installed when [PostHogConfig.captureUncaughtExceptions] is
33+
* enabled, retained so it can be uninstalled on [close].
34+
*/
35+
private var uncaughtExceptionIntegration: PostHogErrorTrackingAutoCaptureIntegration? = null
36+
3037
override fun <T : PostHogConfig> setup(config: T) {
38+
// The base keeps its original state when it rejects a setup (already set up, or an invalid
39+
// config), so only wire anything on top when THIS call is the one that enabled the client —
40+
// otherwise a second setup() could install a handler bound to a config the base discarded.
41+
val alreadySetUp = isEnabled()
3142
super.setup(config.asCoreConfig())
43+
if (alreadySetUp || !isEnabled()) {
44+
return
45+
}
46+
47+
// Core setup never installs integrations for the stateless base, so wire the uncaught
48+
// handler explicitly. Gate purely on the local server flag — the server SDK never fetches
49+
// remote config, so the remote-config gate the Android SDK uses can never fire here.
50+
// Single-owner by design: the handler is process-wide, so only the first client that opts in
51+
// installs it. With several live clients all opting in, closing the owner restores the
52+
// previous handler and the remaining clients do not take over — capture stops until a client
53+
// is set up again. Server apps use one client per process, so we don't ref-count here.
54+
if (config.captureUncaughtExceptions) {
55+
getConfig<com.posthog.PostHogConfig>()?.let { coreConfig ->
56+
val integration = PostHogErrorTrackingAutoCaptureIntegration(coreConfig) { true }
57+
// The uncaught Throwable is a PostHogThrowable carrying fatal/handled=false/mechanism;
58+
// routing it through captureException preserves those via the shared coercer, and the
59+
// queue sends fatal exception events synchronously on the crashing thread.
60+
integration.installWith(
61+
object : PostHogErrorTrackingAutoCaptureIntegration.CaptureTarget {
62+
override fun capture(throwable: Throwable) {
63+
captureException(throwable)
64+
}
65+
66+
override fun flush() {
67+
this@PostHog.flush()
68+
}
69+
},
70+
)
71+
uncaughtExceptionIntegration = integration
72+
}
73+
}
3274
}
3375

3476
override fun close() {
77+
uncaughtExceptionIntegration?.uninstall()
78+
uncaughtExceptionIntegration = null
3579
super.close()
3680
}
3781

posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,28 @@ public open class PostHogConfig constructor(
199199
*/
200200
public var inAppExcludes: List<String> = DEFAULT_IN_APP_EXCLUDES
201201

202+
/**
203+
* Opt in to capturing uncaught exceptions for the whole JVM as `$exception` events.
204+
*
205+
* When true, [PostHog] installs a [Thread.defaultUncaughtExceptionHandler] on setup that
206+
* captures the crashing exception (marked fatal, `handled=false`, mechanism
207+
* `UncaughtExceptionHandler`), flushes, and then delegates to the previously registered
208+
* handler. The handler is removed again on [PostHog.close].
209+
*
210+
* Unlike the Android SDK, this is gated purely on this local flag — the server SDK never
211+
* fetches remote config, so no remote toggle is involved.
212+
*
213+
* Delivery: the queue treats a fatal `$exception` event specially — it is enqueued and sent
214+
* synchronously on the crashing thread, bypassing [flushAt] — so the crash itself does not depend
215+
* on the periodic flush. The handler still calls `flush()` afterwards for anything else that was
216+
* pending. Delivery remains best-effort under an immediate hard exit (the same guarantee the
217+
* Android SDK provides). See [PostHog] for details.
218+
*
219+
* Docs https://posthog.com/docs/error-tracking
220+
* Defaults to false
221+
*/
222+
public var captureUncaughtExceptions: Boolean = false
223+
202224
private val beforeSendCallbacks = mutableListOf<PostHogBeforeSend>()
203225
private val integrations = mutableListOf<PostHogIntegration>()
204226

@@ -374,6 +396,7 @@ public open class PostHogConfig constructor(
374396
private var releaseIdentifier: String? = null
375397
private var inAppIncludes: List<String> = emptyList()
376398
private var inAppExcludes: List<String> = DEFAULT_IN_APP_EXCLUDES
399+
private var captureUncaughtExceptions: Boolean = false
377400

378401
/**
379402
* Sets the PostHog ingestion host.
@@ -580,6 +603,15 @@ public open class PostHogConfig constructor(
580603
*/
581604
public fun inAppExcludes(inAppExcludes: List<String>): Builder = apply { this.inAppExcludes = inAppExcludes }
582605

606+
/**
607+
* Opts in to capturing uncaught JVM exceptions as `$exception` events.
608+
*
609+
* @param captureUncaughtExceptions true to install a global uncaught-exception handler on setup.
610+
* @return This builder.
611+
*/
612+
public fun captureUncaughtExceptions(captureUncaughtExceptions: Boolean): Builder =
613+
apply { this.captureUncaughtExceptions = captureUncaughtExceptions }
614+
583615
/**
584616
* Builds a [PostHogConfig] from the accumulated values.
585617
*
@@ -614,6 +646,7 @@ public open class PostHogConfig constructor(
614646
config.releaseIdentifier = releaseIdentifier
615647
config.inAppIncludes = inAppIncludes
616648
config.inAppExcludes = inAppExcludes
649+
config.captureUncaughtExceptions = captureUncaughtExceptions
617650
return config
618651
}
619652
}

0 commit comments

Comments
 (0)