Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d8bc581
feat(server): opt-in uncaught exception capture
cat-ph Aug 3, 2026
06affef
fix(server): guard uncaught-handler lifecycle with setupLock
posthog[bot] Aug 18, 2026
d2ced6c
fix(server): flush the crash capture behind an executor barrier
cat-ph Aug 20, 2026
59a37c1
fix(core): keep the ThreadDeath check compiling under newer JDKs
cat-ph Aug 24, 2026
02b3de2
fix(core): always delegate even when the uncaught capture fails
cat-ph Aug 24, 2026
737f697
refactor(server): route fatal exception events through a queue fatal …
cat-ph Aug 24, 2026
277db0d
refactor(core): move the captured-throwables guard to the logback PR
cat-ph Aug 24, 2026
c50cd28
fix(core): use the canonical onuncaughtexception mechanism and name t…
cat-ph Aug 24, 2026
e308994
fix(server): capture worker-thread uncaught exceptions as error, not …
cat-ph Aug 24, 2026
3975eed
docs(server): document single-owner uncaught capture in the config KDoc
cat-ph Aug 24, 2026
259e11e
fix(server): make the periodic flush timer a daemon thread
cat-ph Aug 24, 2026
2174b3b
test(server): prove crash semantics on a real forked JVM
cat-ph Aug 24, 2026
c074bd9
fix(server): wait out a concurrent flush in the fatal drain
cat-ph Aug 24, 2026
529b089
fix(server): send the fatal event in the first drained batch
cat-ph Aug 24, 2026
b7dc5a5
fix(server): keep the crash path bounded after the fatal drain
cat-ph Aug 24, 2026
942bd71
fix(server): never evict a fatal event by capacity trimming
cat-ph Aug 24, 2026
195b37c
fix(server): keep maxQueueSize a hard bound under fatal-only contents
cat-ph Aug 24, 2026
c2a2ac2
fix(server): refine fatal-priority eviction at the capacity bound
cat-ph Aug 24, 2026
dcbee3e
docs(server): note the fatal-trim victim choice is best-effort
cat-ph Aug 24, 2026
7cd8bc0
Merge origin/main: reconcile the fatal crash path with the reworked f…
cat-ph Aug 24, 2026
8507cfb
style(server): drop redundant safe-calls flagged by CodeQL
cat-ph Aug 24, 2026
0d4f164
fix(core): roll back a denied uncaught-handler installation
cat-ph Aug 25, 2026
a57eb1e
refactor(server): move the uncaught-capture wiring out of the client …
cat-ph Aug 25, 2026
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/core-uncaught-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog': patch
---

Uncaught-exception events now use the canonical `onuncaughtexception` mechanism type.
5 changes: 5 additions & 0 deletions .changeset/server-uncaught-exceptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-server': minor
---

Add `captureUncaughtExceptions`: opt in to capturing uncaught JVM exceptions as error tracking events, with a best-effort flush before the process exits. The SDK's flush timer is now a daemon thread and no longer keeps a finished JVM alive until `close()`.
3 changes: 3 additions & 0 deletions posthog-server/api/posthog-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ public class com/posthog/server/PostHogConfig {
public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V
public static final fun builder (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder;
public final fun getApiKey ()Ljava/lang/String;
public final fun getCaptureUncaughtExceptions ()Z
public final fun getDebug ()Z
public final fun getEncryption ()Lcom/posthog/PostHogEncryption;
public final fun getEvaluationContexts ()Ljava/util/List;
Expand All @@ -158,6 +159,7 @@ public class com/posthog/server/PostHogConfig {
public final fun getRemoteConfig ()Z
public final fun getSendFeatureFlagEvent ()Z
public final fun removeBeforeSend (Lcom/posthog/PostHogBeforeSend;)V
public final fun setCaptureUncaughtExceptions (Z)V
public final fun setDebug (Z)V
public final fun setEncryption (Lcom/posthog/PostHogEncryption;)V
public final fun setEvaluationContexts (Ljava/util/List;)V
Expand Down Expand Up @@ -185,6 +187,7 @@ public class com/posthog/server/PostHogConfig {
public final class com/posthog/server/PostHogConfig$Builder {
public fun <init> (Ljava/lang/String;)V
public final fun build ()Lcom/posthog/server/PostHogConfig;
public final fun captureUncaughtExceptions (Z)Lcom/posthog/server/PostHogConfig$Builder;
public final fun debug (Z)Lcom/posthog/server/PostHogConfig$Builder;
public final fun encryption (Lcom/posthog/PostHogEncryption;)Lcom/posthog/server/PostHogConfig$Builder;
public final fun evaluationContexts (Ljava/util/List;)Lcom/posthog/server/PostHogConfig$Builder;
Expand Down
45 changes: 43 additions & 2 deletions posthog-server/src/main/java/com/posthog/server/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package com.posthog.server

import com.posthog.FeatureFlagResult
import com.posthog.PostHogStateless
import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration
import com.posthog.internal.FeatureFlag
import com.posthog.server.internal.EvaluationsHost
import com.posthog.server.internal.PostHogFeatureFlags
import com.posthog.server.internal.PostHogUncaughtExceptionCapture

@Suppress("DEPRECATION")
public class PostHog : PostHogStateless(), PostHogInterface {
Expand All @@ -26,12 +28,51 @@ public class PostHog : PostHogStateless(), PostHogInterface {
}
}

/**
* Uncaught-exception integration installed when [PostHogConfig.captureUncaughtExceptions] is
* enabled, retained so it can be uninstalled on [close].
*/
private var uncaughtExceptionIntegration: PostHogErrorTrackingAutoCaptureIntegration? = null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think this should live here, the integration should rather call flush directly instead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair, updated!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mm not yet?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” might have dropped the commit while playing with jj, sorry

updated! moved to PostHogUncaughtExceptionCapture, just calls install here

..but I left the flush no-op because I think capture() already uses the fatal path and the sync flush could block the crashing thread (well, no timeout as I see)


override fun <T : PostHogConfig> setup(config: T) {
super.setup(config.asCoreConfig())
// Hold setupLock across the whole lifecycle so the enabled-transition check and the handler
// install stay atomic with the base's own setup. The monitor is reentrant, so super.setup
// re-acquires it harmlessly. Without this, two concurrent setup() calls could both observe
// alreadySetUp as false and the rejected one would install a handler bound to a config the
// base discarded; and a concurrent close() could read uncaughtExceptionIntegration before it
// was assigned and leak the process-wide handler after the client closed.
synchronized(setupLock) {
// The base keeps its original state when it rejects a setup (already set up, or an invalid
// config), so only wire anything on top when THIS call is the one that enabled the client β€”
// otherwise a second setup() could install a handler bound to a config the base discarded.
val alreadySetUp = isEnabled()
super.setup(config.asCoreConfig())
if (alreadySetUp || !isEnabled()) {
return
}

// Core setup never installs integrations for the stateless base, so wire the uncaught
// handler explicitly (all mechanics live in PostHogUncaughtExceptionCapture).
// Single-owner by design: the handler is process-wide, so only the first client that opts in
Comment thread
cat-ph marked this conversation as resolved.
// installs it. With several live clients all opting in, closing the owner restores the
// previous handler and the remaining clients do not take over β€” capture stops until a client
// is set up again. Server apps use one client per process, so we don't ref-count here.
if (config.captureUncaughtExceptions) {
getConfig<com.posthog.PostHogConfig>()?.let { coreConfig ->
uncaughtExceptionIntegration = PostHogUncaughtExceptionCapture.install(this, coreConfig)
}
}
}
}

override fun close() {
super.close()
// Same lock as setup so the uninstall + field clear cannot race a concurrent setup() that is
// still assigning uncaughtExceptionIntegration; super.close re-acquires the reentrant lock.
synchronized(setupLock) {
uncaughtExceptionIntegration?.uninstall()
uncaughtExceptionIntegration = null
super.close()
}
}

override fun identify(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,40 @@ public open class PostHogConfig constructor(
*/
public var inAppExcludes: List<String> = DEFAULT_IN_APP_EXCLUDES

/**
* Opt in to capturing uncaught exceptions for the whole JVM as `$exception` events.
*
* When true, [PostHog] installs a [Thread.defaultUncaughtExceptionHandler] on setup that
* captures the crashing exception (`handled=false`, mechanism `onuncaughtexception`,
* `$exception_source: jvm.uncaught_exception_handler`), flushes, and then delegates to the
* previously registered handler. The handler is removed again on [PostHog.close].
*
* A main-thread crash is captured with `$exception_level` `fatal` (the process is expected to
* terminate); an uncaught exception on any other thread kills only that thread, so it is
* captured with level `error` and delivered like a regular event.
*
* Unlike the Android SDK, this is gated purely on this local flag β€” the server SDK never
* fetches remote config, so no remote toggle is involved.
*
* The JVM has a single process-wide default handler, so capture is single-owner: the first
* client that opts in installs the handler, later opted-in clients do not. Closing the owner
* restores the previous handler β€” other still-open clients do not take over; capture resumes
* with the next client that is set up with this flag enabled. Server apps normally run one
* client per process, where none of this matters.
*
* Delivery for a fatal (main-thread) crash: the event takes a dedicated queue path that
* enqueues and sends in one ordered task, draining the queue batch by batch and ignoring
* [flushAt], while the crashing thread waits on it for a bounded timeout β€” so the crash gets a
* network attempt before the JVM exits without ever blocking the crashing thread indefinitely.
* Delivery stays best-effort, the same guarantee class the Android SDK provides: the drain can
* hit its timeout, the HTTP attempt can fail, and an immediate hard exit can cut it short. See
* [PostHog] for details.
*
* Docs https://posthog.com/docs/error-tracking
* Defaults to false
*/
public var captureUncaughtExceptions: Boolean = false

private val beforeSendCallbacks = mutableListOf<PostHogBeforeSend>()
private val integrations = mutableListOf<PostHogIntegration>()

Expand Down Expand Up @@ -382,6 +416,7 @@ public open class PostHogConfig constructor(
private var releaseIdentifier: String? = null
private var inAppIncludes: List<String> = emptyList()
private var inAppExcludes: List<String> = DEFAULT_IN_APP_EXCLUDES
private var captureUncaughtExceptions: Boolean = false

/**
* Sets the PostHog ingestion host.
Expand Down Expand Up @@ -594,6 +629,15 @@ public open class PostHogConfig constructor(
*/
public fun inAppExcludes(inAppExcludes: List<String>): Builder = apply { this.inAppExcludes = inAppExcludes.toList() }

/**
* Opts in to capturing uncaught JVM exceptions as `$exception` events.
*
* @param captureUncaughtExceptions true to install a global uncaught-exception handler on setup.
* @return This builder.
*/
public fun captureUncaughtExceptions(captureUncaughtExceptions: Boolean): Builder =
apply { this.captureUncaughtExceptions = captureUncaughtExceptions }

/**
* Builds a [PostHogConfig] from the accumulated values.
*
Expand Down Expand Up @@ -628,6 +672,7 @@ public open class PostHogConfig constructor(
config.releaseIdentifier = releaseIdentifier
config.inAppIncludes = inAppIncludes
config.inAppExcludes = inAppExcludes
config.captureUncaughtExceptions = captureUncaughtExceptions
return config
}
}
Expand Down
Loading
Loading