diff --git a/.changeset/core-uncaught-gate.md b/.changeset/core-uncaught-gate.md new file mode 100644 index 000000000..cb5f82c03 --- /dev/null +++ b/.changeset/core-uncaught-gate.md @@ -0,0 +1,5 @@ +--- +'posthog': patch +--- + +Uncaught-exception events now use the canonical `onuncaughtexception` mechanism type. diff --git a/.changeset/server-uncaught-exceptions.md b/.changeset/server-uncaught-exceptions.md new file mode 100644 index 000000000..11c04b331 --- /dev/null +++ b/.changeset/server-uncaught-exceptions.md @@ -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()`. diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index 4c50cfaaf..e10d6bcb3 100644 --- a/posthog-server/api/posthog-server.api +++ b/posthog-server/api/posthog-server.api @@ -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; @@ -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 @@ -185,6 +187,7 @@ public class com/posthog/server/PostHogConfig { public final class com/posthog/server/PostHogConfig$Builder { public fun (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; diff --git a/posthog-server/src/main/java/com/posthog/server/PostHog.kt b/posthog-server/src/main/java/com/posthog/server/PostHog.kt index 1cd9a97c7..b16c68cc7 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -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 { @@ -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 + override fun 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 + // 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()?.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( diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt index 30a6bbef9..6416dc87c 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -200,6 +200,40 @@ public open class PostHogConfig constructor( */ public var inAppExcludes: List = 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() private val integrations = mutableListOf() @@ -382,6 +416,7 @@ public open class PostHogConfig constructor( private var releaseIdentifier: String? = null private var inAppIncludes: List = emptyList() private var inAppExcludes: List = DEFAULT_IN_APP_EXCLUDES + private var captureUncaughtExceptions: Boolean = false /** * Sets the PostHog ingestion host. @@ -594,6 +629,15 @@ public open class PostHogConfig constructor( */ public fun inAppExcludes(inAppExcludes: List): 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. * @@ -628,6 +672,7 @@ public open class PostHogConfig constructor( config.releaseIdentifier = releaseIdentifier config.inAppIncludes = inAppIncludes config.inAppExcludes = inAppExcludes + config.captureUncaughtExceptions = captureUncaughtExceptions return config } } diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PostHogMemoryQueue.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogMemoryQueue.kt index 305f65fa2..b090b0af4 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/PostHogMemoryQueue.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/PostHogMemoryQueue.kt @@ -13,7 +13,10 @@ import java.io.IOException import java.util.Date import java.util.Timer import java.util.TimerTask +import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.schedule import kotlin.math.min @@ -32,6 +35,7 @@ internal class PostHogMemoryQueue( private val executor: ExecutorService, private val retryDelaySeconds: Int = DEFAULT_RETRY_DELAY_SECONDS, private val maxRetryDelaySeconds: Int = DEFAULT_MAX_RETRY_DELAY_SECONDS, + private val fatalFlushTimeoutMs: Long = FATAL_FLUSH_TIMEOUT_MS, ) : PostHogQueueInterface { private val events: ArrayDeque = ArrayDeque() private val eventsLock = Any() @@ -50,27 +54,88 @@ internal class PostHogMemoryQueue( private val delay: Long get() = (config.flushIntervalSeconds * 1000).toLong() override fun add(record: PostHogEvent) { + // Same fatal-record marker the core PostHogQueue keys on: a fatal $exception event is about + // to take the process down with it, so it must not take the regular async path. + if (record.isFatalExceptionEvent()) { + addFatalBlocking(record) + return + } + executor.executeSafely { - var removedEvent: PostHogEvent? = null + enqueue(record) + flushIfOverThreshold() + } + } - synchronized(eventsLock) { - if (events.size >= config.maxQueueSize) { - removedEvent = events.removeFirstOrNull() - } + private fun enqueue(record: PostHogEvent) { + var removedEvent: PostHogEvent? = null + var dropped = false + synchronized(eventsLock) { + if (events.size >= config.maxQueueSize) { + val victimIndex = nonFatalVictimIndexLocked() + if (victimIndex < 0) { + // Every queued event is a fatal record awaiting retry; ordinary traffic must + // not displace a crash report, so the incoming event is dropped instead. + dropped = true + } else { + removedEvent = events.removeAt(victimIndex) + } + } + if (!dropped) { events.addLast(record) } + } - if (removedEvent != null) { - config.logger.log("Queue is full, the oldest event ${removedEvent?.event} was discarded.") - } + if (dropped) { + config.logger.log("Queue is full of fatal events awaiting retry, ${record.event} was dropped.") + return + } + if (removedEvent != null) { + config.logger.log("Queue is full, the oldest event ${removedEvent.event} was discarded.") + } + + config.logger.log("Event: ${record.event} was added to the queue.") + } - config.logger.log("Event: ${record.event} was added to the queue.") + // Front-inserts the fatal event so the FIRST drained batch carries it: a backlog batch that + // fails with a retriable error is requeued and stops the drain (no retry loops on a crashing + // process), which would otherwise consume the whole budget before the crash event ever reached + // the wire. Batch order does not matter to ingestion — events carry their own timestamps. + private fun enqueueFatalFirst(record: PostHogEvent) { + var removedEvent: PostHogEvent? = null - flushIfOverThreshold() + synchronized(eventsLock) { + if (events.size >= config.maxQueueSize) { + val victimIndex = nonFatalVictimIndexLocked() + removedEvent = + if (victimIndex >= 0) { + events.removeAt(victimIndex) + } else { + // All-fatal contents were front-inserted, so the tail is normally the + // oldest and the newest crash reports win; maxQueueSize stays a hard bound. + // (Approximate: a concurrent failed flush requeues its batch at the head, + // which can reorder parked fatal events — which of several parked crash + // reports gets trimmed is deliberately best-effort, not age-tracked.) + events.removeLastOrNull() + } + } + events.addFirst(record) } + + if (removedEvent != null) { + config.logger.log("Queue is full, the oldest event ${removedEvent.event} was discarded.") + } + + config.logger.log("Event: ${record.event} was added to the front of the queue.") } + // Must be called under eventsLock. The preferred eviction victim is the oldest NON-fatal event: + // front-inserting fatal records breaks the head-is-oldest invariant, and a fatal event parked + // after a failed attempt (in a process that survived) must not be displaced by ordinary + // traffic. In the common case the head is non-fatal, so callers stay O(1). + private fun nonFatalVictimIndexLocked(): Int = events.indexOfFirst { !it.isFatalExceptionEvent() } + override fun flush() { flush(ignoreRetryPause = false) } @@ -98,11 +163,126 @@ internal class PostHogMemoryQueue( } } + /** + * Crash path for fatal `$exception` events: enqueue and drain in one ordered task on the queue + * executor, blocking the calling (crashing) thread for at most [fatalFlushTimeoutMs]. + * + * The regular [add] path submits the enqueue asynchronously, so a crashing thread that flushed + * inline could read the deque before its own event landed, send nothing, and let the event die + * with the JVM. Running enqueue + drain as one task on the single-threaded executor makes the + * send happen-after the enqueue. The fatal event is front-inserted so the first drained batch + * carries it, and the drain then continues batch by batch (ignoring `flushAt`) until the queue + * is empty — a backlog of `maxBatchSize` or more can neither strand the fatal event nor eat the + * budget with a failing batch before the crash ever reaches the wire. + * + * Delivery stays best-effort: the caller stops waiting at the timeout (the drain keeps going on + * the executor thread for whatever process lifetime remains), and each HTTP attempt can fail or + * be cut short by process exit. + */ + private fun addFatalBlocking(record: PostHogEvent) { + val done = CountDownLatch(1) + val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(fatalFlushTimeoutMs) + + try { + executor.execute { + try { + enqueueFatalFirst(record) + drainUntilDeadline(deadlineNanos) + } finally { + done.countDown() + } + } + } catch (e: RejectedExecutionException) { + // the executor is gone (client closed mid-crash); there is nothing to await + config.logger.log("The fatal event flush could not be scheduled: $e.") + return + } + + awaitUninterruptibly(done, fatalFlushTimeoutMs) + } + + /** + * Sends batch after batch until the queue is empty, the [deadlineNanos] budget is spent, or a + * send fails (a failed send requeues its batch; a crashing process gets one straight-line + * attempt per batch, never a retry loop). A flush already holding the [isFlushing] flag is + * polled within the budget instead of bailing, so a periodic-timer flush racing the crash + * cannot make the fatal drain a silent no-op. (A public [flush] caller that set the flag but + * whose executor task is queued BEHIND this one cannot release it while we poll — the poll then + * just runs out and that flush's own drain delivers whatever is left.) + */ + private fun drainUntilDeadline(deadlineNanos: Long) { + while (System.nanoTime() < deadlineNanos) { + val before = synchronized(eventsLock) { events.size } + if (before == 0) { + return + } + if (isFlushing.getAndSet(true)) { + config.logger.log("Queue is flushing.") + try { + Thread.sleep(FATAL_DRAIN_POLL_MS) + } catch (e: InterruptedException) { + // the executor is being shut down; an escaping exception on this thread would + // land in the default uncaught handler — us + Thread.currentThread().interrupt() + return + } + continue + } + try { + executeBatch() + } finally { + isFlushing.set(false) + } + val after = synchronized(eventsLock) { events.size } + if (after >= before) { + return + } + } + } + + /** + * Waits out the whole [timeoutMs] even when the calling thread is already interrupted or gets + * interrupted while waiting — a crash on a thread some shutdown just interrupted must still get + * its flush attempt instead of returning on the spot with the budget unspent. The interrupt flag + * is restored before returning so the caller's own handling still sees it. + */ + private fun awaitUninterruptibly( + latch: CountDownLatch, + timeoutMs: Long, + ): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs) + var interrupted = false + try { + while (true) { + val remaining = deadline - System.nanoTime() + if (remaining <= 0) { + config.logger.log("Blocking flush timed out after $timeoutMs ms.") + return false + } + try { + if (latch.await(remaining, TimeUnit.NANOSECONDS)) { + return true + } + } catch (e: InterruptedException) { + // await clears the interrupt status when it throws, so the next wait really waits. + interrupted = true + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt() + } + } + } + override fun start() { executor.executeSafely { synchronized(timerLock) { if (timer == null) { - timer = Timer() + // Daemon, matching the core PostHogQueue's Timer(true) and the executor's + // daemon threads: a background flush timer must never keep a finished (or + // crashed) JVM alive until close() is called. + timer = Timer(true) startTimer(delay) config.logger.log("Queue timer started.") } @@ -271,5 +451,12 @@ internal class PostHogMemoryQueue( public companion object { private const val DEFAULT_RETRY_DELAY_SECONDS = 5 private const val DEFAULT_MAX_RETRY_DELAY_SECONDS = 60 + + // How long a crashing thread waits for the fatal enqueue + drain, mirroring the Rust SDK's + // bounded panic-hook flush. Bounded so telemetry can never hang a dying process. + internal const val FATAL_FLUSH_TIMEOUT_MS = 2_000L + + // How often the fatal drain re-checks a flush held by another thread. + private const val FATAL_DRAIN_POLL_MS = 10L } } diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PostHogUncaughtExceptionCapture.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogUncaughtExceptionCapture.kt new file mode 100644 index 000000000..70904ba1c --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/PostHogUncaughtExceptionCapture.kt @@ -0,0 +1,81 @@ +package com.posthog.server.internal + +import com.posthog.PostHogConfig +import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration +import com.posthog.server.PostHogInterface + +/** + * Wires the shared uncaught-exception integration to the server client, so the client class itself + * carries none of the crash-capture mechanics: the gate (purely the local flag — the server SDK + * never fetches remote config, so the remote-config gate the Android SDK uses can never fire), the + * per-thread fatal policy, and the capture target. + */ +internal object PostHogUncaughtExceptionCapture { + // Event-level capture-integration identity for the uncaught handler, following the + // sdk-specs lowercase . convention. + private const val EXCEPTION_SOURCE_ATTRIBUTE = "\$exception_source" + private const val EXCEPTION_SOURCE_UNCAUGHT_HANDLER = "jvm.uncaught_exception_handler" + + /** + * Installs the handler delivering captures to [client]. Returns the integration to uninstall + * on close, or null when installation failed — an environment that refuses a process-wide + * handler (e.g. a SecurityManager) must not fail client setup. + */ + fun install( + client: PostHogInterface, + coreConfig: PostHogConfig, + ): PostHogErrorTrackingAutoCaptureIntegration? { + val integration = + PostHogErrorTrackingAutoCaptureIntegration(coreConfig, { true }, ::isProcessFatal) + // The uncaught Throwable is a PostHogThrowable carrying fatal/handled=false/mechanism; + // routing it through captureException preserves those via the shared coercer. A + // fatal-level event takes PostHogMemoryQueue's bounded blocking fatal path inside + // add() (same fatal-record marker the core queue keys on), so capture() itself + // delivers the crash — and everything queued ahead of it — before returning. + val target = + object : PostHogErrorTrackingAutoCaptureIntegration.CaptureTarget { + override fun capture(throwable: Throwable) { + // $exception_source names the concrete runtime hook per the sdk-specs + // convention (.); the mechanism category + // (onuncaughtexception) rides on the PostHogThrowable. + client.captureException( + throwable, + null, + mapOf(EXCEPTION_SOURCE_ATTRIBUTE to EXCEPTION_SOURCE_UNCAUGHT_HANDLER), + ) + } + + override fun flush() { + // Deliberately empty. The fatal path above already drains the queue + // within its bounded budget; the public flush() would run another HTTP + // batch inline on the crashing thread with no timeout (e.g. retrying a + // batch a 5xx just requeued), stalling crash delegation past the + // advertised bound. A worker-thread (non-fatal) capture leaves the + // process alive, so the periodic flush delivers it. + } + } + + return try { + integration.installWith(target) + integration + } catch (e: Throwable) { + // Thread.setDefaultUncaughtExceptionHandler can throw (SecurityException under a + // SecurityManager); the integration rolls its ownership state back, and setup + // continues with capture disabled instead of leaving a half-initialized client. + coreConfig.logger.log("Could not install the uncaught-exception handler: $e.") + null + } + } + + // The JVM cannot tell whether a thread's death will end the process, so this approximates the + // spec's "expected to terminate" boundary with the main thread: an uncaught exception there is + // fatal, while a worker thread's kills only that thread (level error) and the process lives on. + // Id 1 is the initial thread on mainstream JVMs and "main" its conventional name; either match + // counts, since a missed main thread would silently downgrade a real crash. Known approximation + // limits, accepted rather than censusing live threads inside a crash handler: a worker that + // happens to be the last live non-daemon thread does end the process (its crash is still level + // error, async delivery), and a main-thread exception need not end it while other non-daemon + // threads keep running. + @Suppress("DEPRECATION") // Thread.getId is deprecated on JDK 19+ but stable while a thread lives + private fun isProcessFatal(thread: Thread): Boolean = thread.id == 1L || thread.name == "main" +} diff --git a/posthog-server/src/test/java/com/posthog/server/CrashFixture.kt b/posthog-server/src/test/java/com/posthog/server/CrashFixture.kt new file mode 100644 index 000000000..bb85adcb6 --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/CrashFixture.kt @@ -0,0 +1,49 @@ +package com.posthog.server + +/** + * Forked-JVM fixture for [PostHogUncaughtExceptionSubprocessTest]: a real client with uncaught + * capture enabled crashing for real, so process exit, worker-thread survival and stderr output can + * be asserted on an actual JVM instead of a directly invoked handler. + * + * Args: ` ` where scenario is `main-crash` or `worker-crash`. + */ +public object CrashFixture { + @JvmStatic + public fun main(args: Array) { + val host = args[0] + val scenario = args[1] + + val postHog = + PostHog.with( + PostHogConfig.builder("fixture_api_key") + .host(host) + // A worker-thread capture takes the regular async path; a threshold of one lets + // the fixture deliver it without waiting for the periodic flush. + .flushAt(1) + .captureUncaughtExceptions(true) + .build(), + ) + + when (scenario) { + // The real main thread dies: the handler must deliver the fatal event before the JVM + // exits (code 1) and reproduce the default crash banner on stderr. + "main-crash" -> throw IllegalStateException("fixture main crash") + + // Only the worker dies: the process must survive, the capture is level error, and the + // fixture exits 0 after flushing. + "worker-crash" -> { + val worker = Thread { throw IllegalStateException("fixture worker crash") } + worker.name = "fixture-worker" + worker.start() + worker.join() + // The capture is enqueued asynchronously; give the queue executor a moment before + // the inline flush so the event is visible to it. + Thread.sleep(500) + postHog.flush() + println("WORKER_SURVIVED") + } + + else -> error("unknown scenario: $scenario") + } + } +} diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionSubprocessTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionSubprocessTest.kt new file mode 100644 index 000000000..e02e786c4 --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionSubprocessTest.kt @@ -0,0 +1,130 @@ +package com.posthog.server + +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import java.io.File +import java.io.InputStream +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Forked-JVM tests via [CrashFixture]: the in-process suite invokes the handler directly, which + * cannot prove real process exit, worker-thread survival, or the stderr the JVM actually emits. + */ +internal class PostHogUncaughtExceptionSubprocessTest { + private class FixtureRun( + val exitCode: Int, + val stdout: String, + val stderr: String, + val batches: List, + ) + + private fun drainAsync(stream: InputStream): Pair, Thread> { + val content = AtomicReference("") + val reader = + Thread { content.set(stream.bufferedReader().readText()) }.apply { + isDaemon = true + start() + } + return content to reader + } + + private fun runFixture(scenario: String): FixtureRun { + val batches = CopyOnWriteArrayList() + val server = MockWebServer() + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + if (request.path?.contains("/batch") == true) { + batches.add(request.parseBatch()) + } + return MockResponse().setResponseCode(200) + } + } + server.start() + + try { + val javaBin = File(File(System.getProperty("java.home"), "bin"), "java").absolutePath + val process = + ProcessBuilder( + javaBin, + "-cp", + System.getProperty("java.class.path"), + CrashFixture::class.java.name, + server.url("/").toString(), + scenario, + ).start() + + // Drain both streams on their own threads so a fixture that fails to exit — the exact + // regression this suite exists to catch — hits the timed wait below instead of hanging + // this thread in readText(). + val stdout = drainAsync(process.inputStream) + val stderr = drainAsync(process.errorStream) + val exited = process.waitFor(60, TimeUnit.SECONDS) + if (!exited) { + process.destroyForcibly() + process.waitFor(10, TimeUnit.SECONDS) + } + stdout.second.join(5_000) + stderr.second.join(5_000) + assertTrue(exited, "Fixture JVM did not exit in time; stderr was: ${stderr.first.get()}") + + return FixtureRun(process.exitValue(), stdout.first.get(), stderr.first.get(), batches.toList()) + } finally { + server.shutdown() + } + } + + private fun exceptionProperties(run: FixtureRun): Map { + val batch = run.batches.firstOrNull { it.findEvent("\$exception") != null } + assertNotNull(batch, "Expected an \$exception event to reach the server, got: ${run.batches}") + return batch.eventProperties("\$exception") + } + + @Test + fun `a main-thread crash terminates the process and delivers a fatal event first`() { + val run = runFixture("main-crash") + + // The JVM's exit code for an uncaught exception on main. + assertEquals(1, run.exitCode, "stderr was: ${run.stderr}") + + // Installing capture must not eat the default crash output. + assertTrue( + run.stderr.contains("Exception in thread \"main\""), + "Expected the default crash banner on stderr, got: ${run.stderr}", + ) + assertTrue( + run.stderr.contains("fixture main crash"), + "Expected the throwable on stderr, got: ${run.stderr}", + ) + + val props = exceptionProperties(run) + assertEquals("fatal", props["\$exception_level"]) + assertEquals("jvm.uncaught_exception_handler", props["\$exception_source"]) + } + + @Test + fun `a worker-thread crash leaves the process running and delivers an error event`() { + val run = runFixture("worker-crash") + + assertEquals(0, run.exitCode, "stderr was: ${run.stderr}") + assertTrue( + run.stdout.contains("WORKER_SURVIVED"), + "Expected the process to keep running after the worker died, got: ${run.stdout}", + ) + assertTrue( + run.stderr.contains("fixture worker crash"), + "Expected the worker's throwable on stderr, got: ${run.stderr}", + ) + + val props = exceptionProperties(run) + assertEquals("error", props["\$exception_level"]) + } +} diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionTest.kt new file mode 100644 index 000000000..0891252ab --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionTest.kt @@ -0,0 +1,432 @@ +package com.posthog.server + +import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests for the opt-in server uncaught-exception capture wired via + * [PostHogConfig.captureUncaughtExceptions]. + * + * The global [Thread.setDefaultUncaughtExceptionHandler] and the integration's process-global + * install flag are shared JVM state, so every test restores the original handler and closes the + * client to keep the suite isolated. + */ +internal class PostHogUncaughtExceptionTest { + private var originalHandler: Thread.UncaughtExceptionHandler? = null + + @BeforeTest + fun setUp() { + originalHandler = Thread.getDefaultUncaughtExceptionHandler() + } + + @AfterTest + fun tearDown() { + // Defensively clear the process-global install flag if a test threw before closing, so a + // leaked handler can't turn later tests' installs into no-ops. + (Thread.getDefaultUncaughtExceptionHandler() as? PostHogErrorTrackingAutoCaptureIntegration) + ?.uninstall() + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + private fun startServer(): MockWebServer = + MockWebServer().apply { + enqueue(MockResponse().setResponseCode(200)) + start() + } + + @Test + fun `disabled by default does not install an uncaught handler`() { + val sentinel = Thread.UncaughtExceptionHandler { _, _ -> } + Thread.setDefaultUncaughtExceptionHandler(sentinel) + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host("https://example.com") + .build(), + ) + + // The default handler must be untouched when the option is off. + assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler()) + + postHog.close() + } + + @Test + fun `enabled installs handler and chains the previous one, restored on close`() { + var chainedThread: Thread? = null + var chainedThrowable: Throwable? = null + val sentinel = + Thread.UncaughtExceptionHandler { thread, throwable -> + chainedThread = thread + chainedThrowable = throwable + } + Thread.setDefaultUncaughtExceptionHandler(sentinel) + + val mockServer = startServer() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .captureUncaughtExceptions(true) + .build(), + ) + + // Our integration is now the default handler and it is not the sentinel. + val installed = Thread.getDefaultUncaughtExceptionHandler() + assertTrue( + installed is PostHogErrorTrackingAutoCaptureIntegration, + "Expected the PostHog integration to be installed as the default handler", + ) + + // Simulate an uncaught exception. + val thread = Thread.currentThread() + val boom = RuntimeException("boom") + installed.uncaughtException(thread, boom) + + // The previous handler is chained after capture. + assertSame(thread, chainedThread) + assertSame(boom, chainedThrowable) + + // Close removes our handler and restores the sentinel. + postHog.close() + assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler()) + + mockServer.shutdown() + } + + @Test + fun `uncaught main-thread exception is captured as a fatal, unhandled exception event`() { + val mockServer = startServer() + // Default flushAt (100) on purpose: the crash path's blocking fatal delivery is what sends + // the event, so a low threshold would mask whether that path works at all. + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .captureUncaughtExceptions(true) + .build(), + ) + + val handler = Thread.getDefaultUncaughtExceptionHandler() + assertTrue(handler is PostHogErrorTrackingAutoCaptureIntegration) + + // The thread argument is data to the handler, so a main-named stand-in exercises the + // process-fatal policy without crashing the suite's real main thread. + handler.uncaughtException(Thread("main"), IllegalStateException("kaboom")) + + // Already sent when the handler returned, so this should not have to wait. + val request = mockServer.takeRequest(1, TimeUnit.SECONDS) + assertNotNull(request, "Expected the crash event to be flushed before the handler returned") + + val batch = request.parseBatch() + val exceptionEvent = batch.findEvent("\$exception") + assertNotNull(exceptionEvent, "Expected an \$exception event") + + val props = batch.eventProperties("\$exception") + assertEquals("fatal", props["\$exception_level"], "Uncaught exceptions must be fatal") + + @Suppress("UNCHECKED_CAST") + val exceptionList = props["\$exception_list"] as? List> + assertNotNull(exceptionList, "Expected a \$exception_list") + assertTrue(exceptionList.isNotEmpty()) + + @Suppress("UNCHECKED_CAST") + val mechanism = exceptionList.first()["mechanism"] as? Map + assertNotNull(mechanism, "Expected a mechanism on the first exception item") + assertEquals(false, mechanism["handled"], "Uncaught exceptions must be marked handled=false") + assertEquals( + "onuncaughtexception", + mechanism["type"], + "Uncaught exceptions must carry the canonical onuncaughtexception mechanism", + ) + assertEquals( + "jvm.uncaught_exception_handler", + props["\$exception_source"], + "Uncaught exceptions must name the concrete runtime hook in \$exception_source", + ) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `uncaught worker-thread exception is captured as an error, since the process survives it`() { + val mockServer = startServer() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + // A worker-thread capture takes the regular async path, so a threshold of one + // is what delivers it within the test. + .flushAt(1) + .captureUncaughtExceptions(true) + .build(), + ) + + val handler = Thread.getDefaultUncaughtExceptionHandler() + assertTrue(handler is PostHogErrorTrackingAutoCaptureIntegration) + + handler.uncaughtException(Thread("worker-7"), IllegalStateException("worker kaboom")) + + val request = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(request, "Expected the worker crash event to be flushed") + + val batch = request.parseBatch() + val props = batch.eventProperties("\$exception") + assertEquals( + "error", + props["\$exception_level"], + "A worker-thread uncaught exception only kills that thread, so it must not be fatal", + ) + + @Suppress("UNCHECKED_CAST") + val exceptionList = props["\$exception_list"] as? List> + assertNotNull(exceptionList) + + @Suppress("UNCHECKED_CAST") + val mechanism = exceptionList.first()["mechanism"] as? Map + assertNotNull(mechanism) + assertEquals(false, mechanism["handled"], "Escaping the thread still means handled=false") + assertEquals("onuncaughtexception", mechanism["type"]) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `repeated setup keeps handler ownership so close still restores the previous handler`() { + val sentinel = Thread.UncaughtExceptionHandler { _, _ -> } + Thread.setDefaultUncaughtExceptionHandler(sentinel) + + val mockServer = startServer() + val config = + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .captureUncaughtExceptions(true) + .build() + val postHog = PostHog.with(config) + + assertTrue( + Thread.getDefaultUncaughtExceptionHandler() is PostHogErrorTrackingAutoCaptureIntegration, + ) + + // A second setup on the same instance is a no-op for the base client; it must not replace + // the owning integration with a non-owning one, or close() could no longer uninstall. + postHog.setup(config) + + postHog.close() + assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler()) + + mockServer.shutdown() + } + + @Test + fun `a rejected repeated setup does not install a handler`() { + val sentinel = Thread.UncaughtExceptionHandler { _, _ -> } + Thread.setDefaultUncaughtExceptionHandler(sentinel) + + val mockServer = startServer() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .build(), + ) + + assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler()) + + // The base client ignores a second setup and keeps its original config, so opting in through + // that rejected call must not install a handler either. + postHog.setup( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .captureUncaughtExceptions(true) + .build(), + ) + + assertSame( + sentinel, + Thread.getDefaultUncaughtExceptionHandler(), + "A rejected setup must not install the uncaught handler", + ) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `double setup does not install a second handler`() { + val mockServer = startServer() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .captureUncaughtExceptions(true) + .build(), + ) + + val firstHandler = Thread.getDefaultUncaughtExceptionHandler() + assertTrue(firstHandler is PostHogErrorTrackingAutoCaptureIntegration) + + // A second client that also opts in must not stack a second handler on top of ours (the + // process-global install guard makes the second install a no-op). + val second = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .captureUncaughtExceptions(true) + .build(), + ) + + assertSame( + firstHandler, + Thread.getDefaultUncaughtExceptionHandler(), + "The second setup must not replace the already-installed handler", + ) + + second.close() + postHog.close() + mockServer.shutdown() + } + + @Test + fun `crash event is flushed even while its enqueue is still pending`() { + // Makes the crash-path race deterministic instead of hoping for a scheduling order. The queue + // thread is parked inside the gated warmup batch, so the crash capture's fatal task is provably + // still pending when the handler flushes, and with flushAt above one and a 600s flush interval + // nothing else can deliver the crash event. The handler runs on its own thread so the test can + // assert it is still blocked inside the capture while the task is pending — the inline flush + // it used to do returned immediately, having read an empty queue, and the event died with the + // JVM. + val batches = CopyOnWriteArrayList() + val warmupReceived = CountDownLatch(1) + val gate = CountDownLatch(1) + val mockServer = MockWebServer() + mockServer.dispatcher = + object : Dispatcher() { + private val batchRequests = AtomicInteger(0) + + override fun dispatch(request: RecordedRequest): MockResponse { + if (request.path?.contains("/batch") == true) { + batches.add(request.parseBatch()) + if (batchRequests.getAndIncrement() == 0) { + warmupReceived.countDown() + // Holds the queue thread until the test opens the gate. + gate.await(5, TimeUnit.SECONDS) + } + } + return MockResponse().setResponseCode(200) + } + } + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(2) + // Long enough that only the crash path can deliver the event within the test. + .flushIntervalSeconds(600) + .captureUncaughtExceptions(true) + .build(), + ) + + // Two events reach flushAt(2), so the queue thread flushes and parks in the gated request. + postHog.capture(DISTINCT_ID, "warmup_1") + postHog.capture(DISTINCT_ID, "warmup_2") + assertTrue( + warmupReceived.await(5, TimeUnit.SECONDS), + "Expected the warmup batch to reach the server and park the queue thread", + ) + + val handler = Thread.getDefaultUncaughtExceptionHandler() + assertTrue(handler is PostHogErrorTrackingAutoCaptureIntegration) + val crashThread = + Thread { + // A main-named stand-in: only a process-fatal crash takes the blocking fatal path + // whose ordering this test pins down. + handler.uncaughtException(Thread("main"), IllegalStateException("kaboom")) + }.apply { + isDaemon = true + start() + } + + // The only timed wait on this thread's crash path is the fatal add's bounded await, so + // TIMED_WAITING while the queue thread is parked means the enqueue-and-drain task is queued + // behind the parked warmup flush. The old inline flush never blocked: the + // thread just finishes, and this assertion fails. + val parkedBy = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (crashThread.isAlive && + crashThread.state != Thread.State.TIMED_WAITING && + System.nanoTime() < parkedBy + ) { + Thread.sleep(1) + } + assertTrue( + crashThread.isAlive && crashThread.state == Thread.State.TIMED_WAITING, + "Expected the handler to still be blocked inside the crash flush", + ) + assertEquals(1, batches.size, "The crash event cannot have been sent yet") + + gate.countDown() + crashThread.join(TimeUnit.SECONDS.toMillis(5)) + assertFalse( + crashThread.isAlive, + "Expected the handler to return once the crash flush completed", + ) + + // The handler only returns after its flush ran, so the crash batch is already on the wire. + assertEquals(2, batches.size, "Expected the crash event to be flushed before returning") + assertNotNull( + batches[1].findEvent("\$exception"), + "Expected an \$exception event in the crash batch", + ) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `enabled works with no remote config present`() { + // The server SDK never fetches remote config; the local-only gate must still install. + val sentinel = Thread.UncaughtExceptionHandler { _, _ -> } + Thread.setDefaultUncaughtExceptionHandler(sentinel) + + val mockServer = startServer() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .captureUncaughtExceptions(true) + .build(), + ) + + assertTrue( + Thread.getDefaultUncaughtExceptionHandler() is PostHogErrorTrackingAutoCaptureIntegration, + "Local-only gate should install even without any remote config", + ) + assertFalse(Thread.getDefaultUncaughtExceptionHandler() === sentinel) + + postHog.close() + mockServer.shutdown() + } +} diff --git a/posthog-server/src/test/java/com/posthog/server/internal/PostHogMemoryQueueTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/PostHogMemoryQueueTest.kt index b74f35962..f581fb817 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/PostHogMemoryQueueTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/PostHogMemoryQueueTest.kt @@ -1,6 +1,7 @@ package com.posthog.server.internal import com.posthog.PostHogConfig +import com.posthog.PostHogEvent import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogApiEndpoint import com.posthog.internal.PostHogDateProvider @@ -14,12 +15,17 @@ import com.posthog.server.shutdownAndAwaitTermination import com.posthog.server.unGzip import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import java.util.Date +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test internal class PostHogMemoryQueueTest { @@ -46,6 +52,7 @@ internal class PostHogMemoryQueueTest { maxBatchSize: Int = 50, networkStatus: PostHogNetworkStatus? = null, retryDelaySeconds: Int = 5, + fatalFlushTimeoutMs: Long = 2_000L, ): PostHogMemoryQueue { val config = PostHogConfig("some_api_key", host).apply { @@ -63,9 +70,18 @@ internal class PostHogMemoryQueueTest { PostHogApiEndpoint.BATCH, executor = executor, retryDelaySeconds = retryDelaySeconds, + fatalFlushTimeoutMs = fatalFlushTimeoutMs, ) } + // A fatal $exception event, i.e. one PostHogEvent.isFatalExceptionEvent() marks for the + // blocking crash path in add(). + private fun generateFatalEvent(): PostHogEvent { + val event = generateEvent("\$exception") + event.properties?.put("\$exception_level", "fatal") + return event + } + @Test fun `adds a single event`() { val http = createMockHttp() @@ -398,4 +414,313 @@ internal class PostHogMemoryQueueTest { http.shutdown() executor.shutdownAndAwaitTermination() } + + @Test + fun `a fatal exception event is sent before add returns`() { + val http = createMockHttp(MockResponse().setBody("{}")) + val sut = getSut(http.url("/").toString(), flushAt = 100) + + // add() blocks on the fatal path, so the request must already be there when it returns — + // no executor await, no waiting takeRequest. + sut.add(generateFatalEvent()) + + assertEquals(1, http.requestCount) + val body = http.takeRequest().body.unGzip() + assertTrue("Body should contain the fatal exception event", body.contains("\$exception")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `the fatal path is ordered behind a pending enqueue instead of racing it`() { + val http = createMockHttp(MockResponse().setBody("{}")) + val sut = getSut(http.url("/").toString(), flushAt = 100) + + // Park the single queue thread so the enqueue below is provably still pending: the + // crash-path race, made deterministic without any sleeping. + val blocked = CountDownLatch(1) + executor.execute { blocked.await() } + + sut.add(generateEvent("earlier_event")) + + // The fatal add has to wait for the parked thread, so nothing can have been sent yet. + val sender = Thread { sut.add(generateFatalEvent()) } + sender.start() + assertEquals(0, http.requestCount) + + blocked.countDown() + sender.join(5_000) + assertFalse("Expected the fatal add to return once the queue drained", sender.isAlive) + + // Both the pending earlier event and the fatal event went out. + val body = http.takeRequest().body.unGzip() + assertTrue("Body should contain the earlier pending event", body.contains("earlier_event")) + assertTrue("Body should contain the fatal exception event", body.contains("\$exception")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `the fatal path drains a backlog larger than maxBatchSize so the crash event is not stranded`() { + // 5 older events + the fatal one at maxBatchSize=2 need 3 sequential batches; a single + // bounded batch would send only the oldest 2 and strand the crash event (FIFO puts it last). + val http = + createMockHttp( + MockResponse().setBody("{}"), + MockResponse().setBody("{}"), + MockResponse().setBody("{}"), + ) + val sut = getSut(http.url("/").toString(), flushAt = 100, maxBatchSize = 2) + + repeat(5) { sut.add(generateEvent("backlog_event_$it")) } + executor.awaitExecution() + assertEquals(0, http.requestCount) + + sut.add(generateFatalEvent()) + + assertEquals(3, http.requestCount) + // Front-inserted, so the crash event rides the very first batch instead of trailing the + // backlog. + val firstBody = http.takeRequest().body.unGzip() + assertTrue("The crash event must be in the first drained batch", firstBody.contains("\$exception")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `the fatal event gets its wire attempt even when the first batch fails`() { + // A retriable failure requeues the batch and stops the drain (no retry loops on a crashing + // process). With the fatal event appended last it would never have reached the wire; front + // insertion puts it in that first, only attempt. + val http = createMockHttp(MockResponse().setResponseCode(500)) + val sut = getSut(http.url("/").toString(), flushAt = 100, maxBatchSize = 2) + + repeat(5) { sut.add(generateEvent("backlog_event_$it")) } + executor.awaitExecution() + + sut.add(generateFatalEvent()) + + assertEquals(1, http.requestCount) + val body = http.takeRequest().body.unGzip() + assertTrue("The failed attempt must have carried the crash event", body.contains("\$exception")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `the fatal path waits out a flush already in progress instead of giving up`() { + // A periodic-timer flush racing the crash used to make the fatal drain a silent no-op: the + // drain saw no progress and returned with the crash event still queued. + val gate = CountDownLatch(1) + val firstRequestReceived = CountDownLatch(1) + val requestBodies = CopyOnWriteArrayList() + val http = MockWebServer() + http.dispatcher = + object : Dispatcher() { + private val requests = AtomicInteger(0) + + override fun dispatch(request: RecordedRequest): MockResponse { + requestBodies.add(request.body.unGzip()) + if (requests.getAndIncrement() == 0) { + firstRequestReceived.countDown() + gate.await(5, TimeUnit.SECONDS) + } + return MockResponse().setBody("{}") + } + } + http.start() + val sut = getSut(http.url("/").toString(), flushAt = 100) + + sut.add(generateEvent("pre_event")) + executor.awaitExecution() + + // A stand-in for the periodic timer: flush() runs inline on this thread and parks in the + // gated request while holding the isFlushing flag. + val flusher = Thread { sut.flush() } + flusher.start() + assertTrue( + "Expected the concurrent flush to reach the server and hold the flag", + firstRequestReceived.await(5, TimeUnit.SECONDS), + ) + + val sender = Thread { sut.add(generateFatalEvent()) } + sender.start() + // Let the drain observe the held flag before the gate opens. + Thread.sleep(100) + gate.countDown() + + sender.join(5_000) + flusher.join(5_000) + assertFalse("Expected the fatal add to return", sender.isAlive) + + assertTrue( + "The crash event must be sent once the concurrent flush finished, got: $requestBodies", + requestBodies.any { it.contains("\$exception") }, + ) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `a fatal event parked at the head after a failed attempt is not evicted by capacity trimming`() { + // First attempt fails, requeueing the fatal event at the deque head; in a process that + // survived, later ordinary captures reaching maxQueueSize used to evict the head as the + // "oldest" event — discarding the crash before any retry could send it. + val http = createMockHttp(MockResponse().setResponseCode(500), MockResponse().setBody("{}")) + val sut = getSut(http.url("/").toString(), flushAt = 100, maxQueueSize = 2, retryDelaySeconds = 0) + + sut.add(generateFatalEvent()) + assertEquals(1, http.requestCount) + + // Two ordinary events on a full queue: eviction must take the non-fatal one. + sut.add(generateEvent("ordinary_1")) + sut.add(generateEvent("ordinary_2")) + executor.awaitExecution() + + sut.flush() + assertEquals(2, http.requestCount) + http.takeRequest() // the failed first attempt + val retryBody = http.takeRequest().body.unGzip() + assertTrue("The retried batch must still contain the crash event", retryBody.contains("\$exception")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `maxQueueSize stays a hard bound even when every queued event is fatal`() { + // Persistent send failures in a surviving process can park fatal events; the fatal-eviction + // preference must not turn the cap into unbounded growth — with only fatal events queued, + // the oldest one is evicted anyway. + val http = + createMockHttp( + MockResponse().setResponseCode(500), + MockResponse().setResponseCode(500), + MockResponse().setBody("{}"), + ) + val sut = getSut(http.url("/").toString(), flushAt = 100, maxQueueSize = 1, retryDelaySeconds = 0) + + val fatalA = generateFatalEvent().also { it.properties?.put("marker", "fatal_a") } + val fatalB = generateFatalEvent().also { it.properties?.put("marker", "fatal_b") } + + sut.add(fatalA) + sut.add(fatalB) + assertEquals(2, http.requestCount) + http.takeRequest() + http.takeRequest() + + // Only the newer fatal event survived the cap; the retry sends it alone. + sut.flush() + assertEquals(3, http.requestCount) + val retryBody = http.takeRequest().body.unGzip() + assertTrue("The newest fatal event must survive", retryBody.contains("fatal_b")) + assertFalse("The evicted fatal event must be gone", retryBody.contains("fatal_a")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `ordinary traffic is dropped rather than displacing a parked fatal event`() { + val http = createMockHttp(MockResponse().setResponseCode(500), MockResponse().setBody("{}")) + val sut = getSut(http.url("/").toString(), flushAt = 100, maxQueueSize = 1, retryDelaySeconds = 0) + + val fatal = generateFatalEvent().also { it.properties?.put("marker", "fatal_a") } + sut.add(fatal) + assertEquals(1, http.requestCount) + http.takeRequest() + + // The queue is full of exactly one parked crash report; the ordinary event loses. + sut.add(generateEvent("ordinary_1")) + executor.awaitExecution() + + sut.flush() + assertEquals(2, http.requestCount) + val retryBody = http.takeRequest().body.unGzip() + assertTrue("The parked fatal event must survive ordinary traffic", retryBody.contains("fatal_a")) + assertFalse("The ordinary event must have been dropped", retryBody.contains("ordinary_1")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `fatal-on-fatal eviction removes the oldest crash report, not the newest`() { + // Fatal records are front-inserted (head is NEWEST), so all-fatal trimming must take the + // tail; taking the head would silently discard the most recent crash. + val http = + createMockHttp( + MockResponse().setResponseCode(500), + MockResponse().setResponseCode(500), + MockResponse().setResponseCode(500), + MockResponse().setBody("{}"), + ) + val sut = getSut(http.url("/").toString(), flushAt = 100, maxQueueSize = 2, retryDelaySeconds = 0) + + listOf("fatal_a", "fatal_b", "fatal_c").forEach { marker -> + sut.add(generateFatalEvent().also { it.properties?.put("marker", marker) }) + } + assertEquals(3, http.requestCount) + repeat(3) { http.takeRequest() } + + sut.flush() + assertEquals(4, http.requestCount) + val retryBody = http.takeRequest().body.unGzip() + assertTrue("The newest crash reports must survive", retryBody.contains("fatal_b")) + assertTrue("The newest crash reports must survive", retryBody.contains("fatal_c")) + assertFalse("The oldest crash report is the one trimmed", retryBody.contains("fatal_a")) + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `the fatal path still flushes when the calling thread is already interrupted`() { + val http = createMockHttp(MockResponse().setBody("{}")) + val sut = getSut(http.url("/").toString(), flushAt = 100) + + // A crash on a thread some shutdown just interrupted must not lose its flush. + Thread.currentThread().interrupt() + try { + sut.add(generateFatalEvent()) + assertEquals(1, http.requestCount) + assertTrue( + "Expected the interrupt flag to be restored for the caller", + Thread.currentThread().isInterrupted, + ) + } finally { + // Never leak the interrupt into whatever runs next on this thread. + Thread.interrupted() + } + + http.shutdown() + executor.shutdownAndAwaitTermination() + } + + @Test + fun `the fatal path gives up after the timeout instead of hanging the crashing thread`() { + val http = createMockHttp() + val sut = getSut(http.url("/").toString(), flushAt = 100, fatalFlushTimeoutMs = 200) + + // Occupy the single queue thread so the fatal enqueue-and-drain task can never run. + val blocked = CountDownLatch(1) + executor.execute { blocked.await() } + + val startedAt = System.nanoTime() + sut.add(generateFatalEvent()) + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + + assertTrue("Expected to wait for the timeout, waited $elapsedMs ms", elapsedMs >= 200) + assertTrue("Expected to return right after the timeout, waited $elapsedMs ms", elapsedMs < 2_000) + assertEquals(0, http.requestCount) + + blocked.countDown() + http.shutdown() + executor.shutdownAndAwaitTermination() + } } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 78313ed52..717bf5c7c 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -533,12 +533,19 @@ public abstract interface annotation class com/posthog/PostHogVisibleForTesting public final class com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration : com/posthog/PostHogIntegration, java/lang/Thread$UncaughtExceptionHandler { public fun (Lcom/posthog/PostHogConfig;)V + public fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V public fun install (Lcom/posthog/PostHogInterface;)V + public final fun installWith (Lcom/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration$CaptureTarget;)V public fun onRemoteConfig (Z)V public fun uncaughtException (Ljava/lang/Thread;Ljava/lang/Throwable;)V public fun uninstall ()V } +public abstract interface class com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration$CaptureTarget { + public abstract fun capture (Ljava/lang/Throwable;)V + public abstract fun flush ()V +} + public final class com/posthog/errortracking/PostHogErrorTrackingConfig { public fun ()V public fun (Z)V diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index 6d803852a..95e9be715 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -3,6 +3,7 @@ package com.posthog.errortracking import com.posthog.PostHogConfig import com.posthog.PostHogIntegration import com.posthog.PostHogInterface +import com.posthog.PostHogInternal import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.internal.errortracking.UncaughtExceptionHandlerAdapter import java.util.concurrent.atomic.AtomicBoolean @@ -11,12 +12,27 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th private val config: PostHogConfig private val adapterExceptionHandler: UncaughtExceptionHandlerAdapter + /** + * Decides whether the handler may capture. Defaults to the Android/core gate: local + * `errorTrackingConfig.autoCapture` with remote config acting only as a kill-switch. Layers that + * decide autocapture purely from local config (e.g. the server SDK, which never fetches remote + * config) supply their own gate. + */ + private val enabledGate: () -> Boolean + + /** + * Whether an uncaught exception on [Thread] is expected to terminate the process (level + * `fatal`) or only that thread (level `error`). Defaults to always-fatal, which is correct on + * Android where any uncaught exception kills the app; layers where the process survives a + * worker-thread crash (e.g. the server SDK) supply their own policy. + */ + private val fatalPolicy: (Thread) -> Boolean + // @Volatile: read on the crashing thread in uncaughtException with no happens-before edge to // the install()/uninstall() writes; a pre-existing thread could otherwise see a stale null and // skip delegating to the app/system handler. @Volatile private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null - private var postHog: PostHogInterface? = null private var ownsInstallation = false // Tracks whether we should capture, separate from whether we're linked into the handler chain. @@ -25,14 +41,67 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th @Volatile private var captureEnabled = false + /** + * Where captured uncaught exceptions are delivered. Set on install. The core [PostHogInterface] + * client and the stateless server client do not share a common capture supertype, so the handler + * targets this minimal seam instead of a concrete client type. + */ + private var captureTarget: CaptureTarget? = null + public constructor(config: PostHogConfig) { this.config = config this.adapterExceptionHandler = UncaughtExceptionHandlerAdapter.Adapter.getInstance() + this.enabledGate = { defaultGate() } + this.fatalPolicy = { true } + } + + /** + * Internal constructor allowing a custom [enabledGate] and [fatalPolicy]. Used by SDK layers + * (e.g. the server SDK) that decide autocapture purely from local config without any + * remote-config round trip, and where an uncaught exception does not always terminate the + * process. + * + * Not part of the public API; visible only because of the multi-module architecture. + */ + @PostHogInternal + public constructor(config: PostHogConfig, enabledGate: () -> Boolean, fatalPolicy: (Thread) -> Boolean) { + this.config = config + this.adapterExceptionHandler = UncaughtExceptionHandlerAdapter.Adapter.getInstance() + this.enabledGate = enabledGate + this.fatalPolicy = fatalPolicy } internal constructor(config: PostHogConfig, adapterExceptionHandler: UncaughtExceptionHandlerAdapter) { this.config = config this.adapterExceptionHandler = adapterExceptionHandler + this.enabledGate = { defaultGate() } + this.fatalPolicy = { true } + } + + internal constructor( + config: PostHogConfig, + adapterExceptionHandler: UncaughtExceptionHandlerAdapter, + enabledGate: () -> Boolean, + fatalPolicy: (Thread) -> Boolean = { true }, + ) { + this.config = config + this.adapterExceptionHandler = adapterExceptionHandler + this.enabledGate = enabledGate + this.fatalPolicy = fatalPolicy + } + + /** + * Minimal capture surface the uncaught handler needs. Both the core client and the stateless + * server client can satisfy it, without sharing a public supertype. [capture] and [flush] are + * separate so the handler can ask for everything pending to be sent as its last act. + * + * Not part of the public API; visible only because of the multi-module architecture. + */ + @PostHogInternal + public interface CaptureTarget { + public fun capture(throwable: Throwable) + + public fun flush() } internal companion object { @@ -45,7 +114,29 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th @Synchronized override fun install(postHog: PostHogInterface) { - this.postHog = postHog + installWith( + object : CaptureTarget { + override fun capture(throwable: Throwable) { + postHog.captureException(throwable) + } + + override fun flush() { + postHog.flush() + } + }, + ) + } + + /** + * Installs the handler delivering captures to [target]. Used by SDK layers whose client is not a + * core [PostHogInterface] (e.g. the server SDK). + * + * Not part of the public API; visible only because of the multi-module architecture. + */ + @PostHogInternal + @Synchronized + public fun installWith(target: CaptureTarget) { + this.captureTarget = target // Already linked into the chain (possibly dormant below a handler installed after us): // resume capturing in place. Re-running the link logic while we're a mid-chain delegate @@ -84,8 +175,10 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th } } + private fun canCapture(): Boolean = enabledGate() + // Local config is the primary gate; remote config is only a kill-switch (below). - private fun canCapture(): Boolean = config.errorTrackingConfig.autoCapture && !remoteKillSwitchActive() + private fun defaultGate(): Boolean = config.errorTrackingConfig.autoCapture && !remoteKillSwitchActive() // Remote config is a kill-switch, not a gate: it blocks capture only when a config that // already exists — fetched this session or cached from a prior launch — explicitly disables @@ -101,8 +194,19 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th if (!integrationInstalled.compareAndSet(false, true)) { return } + try { + adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this) + } catch (e: Throwable) { + // e.g. SecurityException under a SecurityManager. Roll the ownership state back so a + // denied install can neither capture nor permanently block a later installation, then + // rethrow for the caller (the core client catches and logs per integration; the server + // client catches around its install helper). + integrationInstalled.set(false) + ownsInstallation = false + defaultExceptionHandler = null + throw e + } ownsInstallation = true - adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this) captureEnabled = true config.logger.log("Exception autocapture is enabled.") } @@ -122,7 +226,7 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th ownsInstallation = false integrationInstalled.set(false) // We're out of the chain now, so drop the delegate ref (a re-install re-reads it). - // postHog is kept: onRemoteConfig re-enable calls install(postHog) on this instance. + // captureTarget is kept: an onRemoteConfig re-enable re-installs with it. defaultExceptionHandler = null config.logger.log("Exception autocapture is disabled.") } else { @@ -147,7 +251,7 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th } val autocaptureExceptionsEnabled = config.remoteConfigHolder?.isAutocaptureExceptionsEnabled() ?: false if (autocaptureExceptionsEnabled) { - postHog?.let { install(it) } + captureTarget?.let { installWith(it) } } else { uninstall() } @@ -158,13 +262,40 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th throwable: Throwable, ) { if (captureEnabled) { - postHog?.let { postHog -> - postHog.captureException(PostHogThrowable(throwable, thread)) - postHog.flush() + captureTarget?.let { target -> + // Telemetry must never replace the application's own crash handling: if capture or + // flush throws, log and fall through to the delegation below instead of letting the + // failure escape this handler. + try { + target.capture(PostHogThrowable(throwable, thread, isFatal = fatalPolicy(thread))) + // Depending on the target's queue the capture above may only enqueue the event, so + // this flush is its last chance to reach the network before the process goes down. + // Delivery stays best-effort under an immediate hard exit. + target.flush() + } catch (e: Throwable) { + config.logger.log("Capturing the uncaught exception failed: $e.") + } } } // Always delegate: we may still be mid-chain even while dormant. - defaultExceptionHandler?.uncaughtException(thread, throwable) + val previousHandler = defaultExceptionHandler + if (previousHandler != null) { + previousHandler.uncaughtException(thread, throwable) + } else if (!isThreadDeath(throwable)) { + // No previous default handler: reproduce the JVM's built-in crash output that + // ThreadGroup would have printed had we not installed ourselves as the default handler, + // so opting into capture never hides crashes from stderr log collection. ThreadDeath is + // excluded because ThreadGroup stays silent for it. + System.err.print("Exception in thread \"${thread.name}\" ") + throwable.printStackTrace(System.err) + } } + + // ThreadDeath is deprecated (for removal) since JDK 20 and this project compiles with warnings + // as errors, so the check lives behind a narrowly scoped suppression (both names, since Kotlin + // reports for-removal deprecations as DEPRECATION_ERROR). It still matters on older JDKs, where + // Thread.stop can throw it and ThreadGroup stays silent for it. + @Suppress("DEPRECATION", "DEPRECATION_ERROR") + private fun isThreadDeath(throwable: Throwable): Boolean = throwable is ThreadDeath } diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt index 8223502b0..1a46232e7 100644 --- a/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt +++ b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt @@ -1,7 +1,16 @@ package com.posthog.internal.errortracking -internal class PostHogThrowable(throwable: Throwable, val thread: Thread = Thread.currentThread()) : Throwable(throwable) { +internal class PostHogThrowable( + throwable: Throwable, + val thread: Thread = Thread.currentThread(), + // Whether the uncaught boundary is expected to terminate the process ($exception_level fatal) + // or only the crashing thread ($exception_level error). Defaults to fatal: on Android any + // uncaught exception kills the app; the server SDK supplies a per-thread policy. + val isFatal: Boolean = true, +) : Throwable(throwable) { val handled: Boolean = false - val isFatal: Boolean = true - val mechanism: String = "UncaughtExceptionHandler" + + // Canonical capture-boundary category from the sdk-specs exception-event-metadata spec; the + // concrete hook goes into the event-level $exception_source, not in here. + val mechanism: String = "onuncaughtexception" } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 332385ebf..6376d21bf 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2930,7 +2930,7 @@ internal class PostHogTest { val mechanism = mainException["mechanism"] as Map<*, *> assertEquals(false, mechanism["handled"]) assertEquals(false, mechanism["synthetic"]) - assertEquals("UncaughtExceptionHandler", mechanism["type"]) + assertEquals("onuncaughtexception", mechanism["type"]) // A single-item list carries no chain ids at all. assertFalse(mechanism.containsKey("exception_id")) assertFalse(mechanism.containsKey("parent_id")) diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index 2aefb1799..b2c0f062d 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -16,6 +16,8 @@ import org.mockito.kotlin.whenever import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith internal class PostHogErrorTrackingAutoCaptureIntegrationTest { private val mockConfig = mock() @@ -186,6 +188,28 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } + @Test + fun `uncaughtException still delegates to the previous handler when capture itself throws`() { + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + currentHandler = mockExceptionHandler + whenever(mockPostHog.captureException(any(), anyOrNull())).thenThrow(IllegalStateException("telemetry broke")) + + val thread = Thread.currentThread() + val throwable = RuntimeException("Test exception") + + val integration = getSut() + integration.install(mockPostHog) + + // Regression: a throwing capture/flush used to escape uncaughtException, so the previous + // handler (the app's own crash handling) never ran. + integration.uncaughtException(thread, throwable) + + verify(mockExceptionHandler).uncaughtException(thread, throwable) + + integration.uninstall() + } + @Test fun `onRemoteConfig does nothing when remoteConfigHolder is null`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) @@ -261,6 +285,30 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) } + @Test + fun `local-only gate installs without any remote config present`() { + // No remoteConfigHolder and no errorTrackingConfig stubbed: the default gate reads both, a + // local-only gate bypasses them entirely. This is the server SDK's path. + currentHandler = null + + val integration = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { true }) + integration.install(mockPostHog) + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) + + integration.uninstall() + } + + @Test + fun `local-only gate that is false does not install`() { + val integration = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { false }) + integration.install(mockPostHog) + + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + @Test fun `onRemoteConfig can re-install after being disabled`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) @@ -556,4 +604,130 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } + + private class RecordingTarget : PostHogErrorTrackingAutoCaptureIntegration.CaptureTarget { + val captured = mutableListOf() + var flushCount = 0 + + override fun capture(throwable: Throwable) { + captured.add(throwable) + } + + override fun flush() { + flushCount++ + } + } + + @Test + fun `uncaughtException reproduces the JVM default crash output when no previous handler exists`() { + currentHandler = null + + val target = RecordingTarget() + val integration = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { true }) + integration.installWith(target) + + val originalErr = System.err + val stderr = java.io.ByteArrayOutputStream() + System.setErr(java.io.PrintStream(stderr)) + try { + integration.uncaughtException(Thread.currentThread(), RuntimeException("printed crash")) + } finally { + System.setErr(originalErr) + } + + val output = stderr.toString() + // Installing capture must not hide the crash from stderr: with no previous handler to + // chain to, the integration prints what ThreadGroup's default behavior would have. + assertEquals(true, output.contains("Exception in thread"), "Expected the default crash banner, got: $output") + assertEquals(true, output.contains("printed crash"), "Expected the throwable in stderr, got: $output") + + integration.uninstall() + } + + @Test + fun `uncaughtException captures and flushes for a fresh throwable`() { + val target = RecordingTarget() + val integration = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { true }) + integration.installWith(target) + + integration.uncaughtException(Thread.currentThread(), RuntimeException("fresh")) + + assertEquals(1, target.captured.size, "A first-seen throwable must be captured") + assertEquals(1, target.flushCount) + + integration.uninstall() + } + + @Test + fun `a denied handler installation rolls back so a later install can still succeed`() { + // Thread.setDefaultUncaughtExceptionHandler can throw (SecurityException under a + // SecurityManager). The process-wide install flag used to be set before that call, so a + // denied install leaked it and no instance could ever install again. + whenever(mockAdapter.setDefaultUncaughtExceptionHandler(anyOrNull())) + .thenThrow(SecurityException("denied")) + .thenAnswer { + currentHandler = it.getArgument(0) + null + } + + val first = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { true }) + assertFailsWith { first.installWith(RecordingTarget()) } + + // The denied instance must not capture. + val target = RecordingTarget() + first.uncaughtException(Thread.currentThread(), RuntimeException("boom")) + assertEquals(0, target.captured.size) + + // A later install (e.g. after the policy changed) must not be blocked by the failed one. + val second = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { true }) + second.installWith(target) + assertEquals(second, currentHandler) + + second.uncaughtException(Thread.currentThread(), RuntimeException("boom")) + assertEquals(1, target.captured.size) + + second.uninstall() + } + + @Test + fun `uncaughtException applies the layer-supplied fatal policy per thread`() { + val target = RecordingTarget() + val integration = + PostHogErrorTrackingAutoCaptureIntegration( + mockConfig, + mockAdapter, + { true }, + { thread -> thread.name == "process-owner" }, + ) + integration.installWith(target) + + integration.uncaughtException(Thread("process-owner"), RuntimeException("boom")) + integration.uncaughtException(Thread("worker"), RuntimeException("boom")) + + assertEquals(true, (target.captured[0] as PostHogThrowable).isFatal) + assertEquals(false, (target.captured[1] as PostHogThrowable).isFatal) + + integration.uninstall() + } + + @Test + fun `uninstall by a non-installing instance does not tear down the installed handler`() { + // First instance installs and owns the global handler. + currentHandler = mockExceptionHandler + val first = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { true }) + first.installWith(RecordingTarget()) + verify(mockAdapter).setDefaultUncaughtExceptionHandler(first) + + // Second instance's install is a process-wide no-op (a handler is already installed). + val second = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter, enabledGate = { true }) + second.installWith(RecordingTarget()) + + // Closing the second must NOT restore/replace the handler — it never installed. + second.uninstall() + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(mockExceptionHandler) + + // The first still owns it and can restore on its own uninstall. + first.uninstall() + verify(mockAdapter).setDefaultUncaughtExceptionHandler(mockExceptionHandler) + } }