From 72b02c703aaa79a614ac53ee9609046c917f3b9d Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Mon, 3 Aug 2026 19:23:54 +0300 Subject: [PATCH 1/5] feat(server): expose in-app frame config and captureException options - PostHogConfig.inAppIncludes/inAppExcludes control in_app frame classification (prefix match, excludes win); inAppExcludes defaults to DEFAULT_IN_APP_EXCLUDES (JDK/Kotlin/framework noise) so zero-config users get a your-code vs framework split out of the box. - New captureException(exception[, distinctId], options) overloads with the same option-merging semantics as capture(..., options): custom props, $groups, $set/$set_once, timestamp, and flag enrichment via snapshot or appendFeatureFlags; reserved props ($exception_level, ...) overridable via options properties; request-context resolution and personless fallback unchanged. --- .changeset/server-error-tracking-config.md | 9 + posthog-server/api/posthog-server.api | 12 + .../main/java/com/posthog/server/PostHog.kt | 129 ++++++++-- .../java/com/posthog/server/PostHogConfig.kt | 76 ++++++ .../com/posthog/server/PostHogInterface.kt | 39 +++ .../com/posthog/server/PostHogConfigTest.kt | 86 +++++++ .../java/com/posthog/server/PostHogTest.kt | 243 ++++++++++++++++++ 7 files changed, 566 insertions(+), 28 deletions(-) create mode 100644 .changeset/server-error-tracking-config.md diff --git a/.changeset/server-error-tracking-config.md b/.changeset/server-error-tracking-config.md new file mode 100644 index 000000000..2d773e73d --- /dev/null +++ b/.changeset/server-error-tracking-config.md @@ -0,0 +1,9 @@ +--- +'posthog-server': minor +--- + +Expose the error-tracking configuration surface on the server SDK: + +- `PostHogConfig.inAppIncludes` / `inAppExcludes` control the `in_app` classification of captured stack-trace frames (prefix match on the class name; excludes always win). `inAppExcludes` defaults to the new `PostHogConfig.DEFAULT_IN_APP_EXCLUDES` — a list of common JVM/framework prefixes (JDK, Kotlin, Spring, Netty, servlet containers, HTTP clients, the PostHog SDK) — so zero-config users get a sensible your-code vs framework split. Assigning your own list replaces the defaults. +- Both are available on the config `Builder` (`inAppIncludes(...)`, `inAppExcludes(...)`). +- New `captureException(exception, distinctId, options)` / `captureException(exception, options)` overloads accept `PostHogCaptureOptions` with the same merging semantics as `capture(..., options)`: custom properties, `$groups`, user properties (`$set`/`$set_once`), timestamp, and feature-flag enrichment via a pre-evaluated `flags` snapshot or `appendFeatureFlags`. Reserved exception properties (e.g. `$exception_level`, `$exception_fingerprint`) can be overridden through options properties. Request-context distinct-id resolution and personless fallback behave exactly like the existing `captureException` overloads. Java callers that passed an explicit untyped `null` as the third argument of `captureException` need to cast it (`(Map) null`), since that call now matches both the properties and the options overload. diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index 262fe4e3e..4c50cfaaf 100644 --- a/posthog-server/api/posthog-server.api +++ b/posthog-server/api/posthog-server.api @@ -9,7 +9,9 @@ public final class com/posthog/server/PostHog : com/posthog/PostHogStateless, co public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;)V public synthetic fun capture (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;)V public fun captureException (Ljava/lang/Throwable;)V + public fun captureException (Ljava/lang/Throwable;Lcom/posthog/server/PostHogCaptureOptions;)V public fun captureException (Ljava/lang/Throwable;Ljava/lang/String;)V + public fun captureException (Ljava/lang/Throwable;Ljava/lang/String;Lcom/posthog/server/PostHogCaptureOptions;)V public fun captureException (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V public fun close ()V @@ -120,6 +122,7 @@ public class com/posthog/server/PostHogConfig { public static final field DEFAULT_FLUSH_AT I public static final field DEFAULT_FLUSH_INTERVAL_SECONDS I public static final field DEFAULT_HOST Ljava/lang/String; + public static final field DEFAULT_IN_APP_EXCLUDES Ljava/util/List; public static final field DEFAULT_MAX_BATCH_SIZE I public static final field DEFAULT_MAX_QUEUE_SIZE I public static final field DEFAULT_POLL_INTERVAL_SECONDS I @@ -141,6 +144,8 @@ public class com/posthog/server/PostHogConfig { public final fun getFlushAt ()I public final fun getFlushIntervalSeconds ()I public final fun getHost ()Ljava/lang/String; + public final fun getInAppExcludes ()Ljava/util/List; + public final fun getInAppIncludes ()Ljava/util/List; public final fun getLocalEvaluation ()Z public final fun getMaxBatchSize ()I public final fun getMaxQueueSize ()I @@ -162,6 +167,8 @@ public class com/posthog/server/PostHogConfig { public final fun setFlagDefinitionCacheProvider (Lcom/posthog/server/PostHogFlagDefinitionCacheProvider;)V public final fun setFlushAt (I)V public final fun setFlushIntervalSeconds (I)V + public final fun setInAppExcludes (Ljava/util/List;)V + public final fun setInAppIncludes (Ljava/util/List;)V public final fun setLocalEvaluation (Z)V public final fun setMaxBatchSize (I)V public final fun setMaxQueueSize (I)V @@ -188,6 +195,8 @@ public final class com/posthog/server/PostHogConfig$Builder { public final fun flushAt (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun flushIntervalSeconds (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun host (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder; + public final fun inAppExcludes (Ljava/util/List;)Lcom/posthog/server/PostHogConfig$Builder; + public final fun inAppIncludes (Ljava/util/List;)Lcom/posthog/server/PostHogConfig$Builder; public final fun localEvaluation (Z)Lcom/posthog/server/PostHogConfig$Builder; public final fun maxBatchSize (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun maxQueueSize (I)Lcom/posthog/server/PostHogConfig$Builder; @@ -350,7 +359,9 @@ public abstract interface class com/posthog/server/PostHogInterface { public abstract synthetic fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;)V public abstract synthetic fun capture (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;)V public abstract fun captureException (Ljava/lang/Throwable;)V + public abstract fun captureException (Ljava/lang/Throwable;Lcom/posthog/server/PostHogCaptureOptions;)V public abstract fun captureException (Ljava/lang/Throwable;Ljava/lang/String;)V + public abstract fun captureException (Ljava/lang/Throwable;Ljava/lang/String;Lcom/posthog/server/PostHogCaptureOptions;)V public abstract fun captureException (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V public abstract fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V public abstract fun close ()V @@ -394,6 +405,7 @@ public final class com/posthog/server/PostHogInterface$DefaultImpls { public static synthetic fun capture$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;ILjava/lang/Object;)V public static synthetic fun capture$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;ILjava/lang/Object;)V public static fun captureException (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;)V + public static fun captureException (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;Lcom/posthog/server/PostHogCaptureOptions;)V public static fun captureException (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;Ljava/lang/String;)V public static fun captureException (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;)V public static synthetic fun captureException$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V 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 2af98c845..a35f7593c 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -1,6 +1,7 @@ package com.posthog.server import com.posthog.FeatureFlagResult +import com.posthog.PostHogEventName import com.posthog.PostHogStateless import com.posthog.internal.FeatureFlag import com.posthog.server.internal.EvaluationsHost @@ -67,34 +68,14 @@ public class PostHog : PostHogStateless(), PostHogInterface { ) { val captureContext = PostHogRequestContext.resolveCaptureContext(distinctId, properties) val mergedProperties = - when { - flags != null -> { - if (appendFeatureFlags) { - getConfig()?.logger?.log( - "capture() received both `flags` and `appendFeatureFlags=true`; " + - "using the supplied snapshot and skipping the redundant /flags fetch.", - ) - } - mergeFeatureFlagPropertiesFromSnapshot(captureContext.properties, flags) - } - appendFeatureFlags -> { - getConfig()?.logger?.log( - "DEPRECATION: capture(appendFeatureFlags = true) is deprecated and will be " + - "removed in the next major. Call evaluateFlags(distinctId) once and pass the " + - "snapshot via capture(flags = …) instead — that path attaches " + - "\$feature/ properties without a redundant /flags request and lets you " + - "scope which flags to attach via flags.onlyAccessed() or flags.only(...).", - ) - mergeFeatureFlagProperties( - distinctId = captureContext.distinctId, - groups = groups, - userProperties = userProperties, - groupProperties = null, - properties = captureContext.properties, - ) - } - else -> captureContext.properties - } + mergeCaptureProperties( + distinctId = captureContext.distinctId, + properties = captureContext.properties, + userProperties = userProperties, + groups = groups, + appendFeatureFlags = appendFeatureFlags, + flags = flags, + ) super.captureStateless( event, @@ -107,6 +88,48 @@ public class PostHog : PostHogStateless(), PostHogInterface { ) } + /** + * Applies the shared capture-options merging semantics: a pre-evaluated [flags] snapshot wins, + * otherwise [appendFeatureFlags] triggers a (deprecated) flag evaluation, otherwise + * [properties] pass through unchanged. + */ + private fun mergeCaptureProperties( + distinctId: String, + properties: Map?, + userProperties: Map?, + groups: Map?, + appendFeatureFlags: Boolean, + flags: PostHogFeatureFlagEvaluations?, + ): Map? = + when { + flags != null -> { + if (appendFeatureFlags) { + getConfig()?.logger?.log( + "capture() received both `flags` and `appendFeatureFlags=true`; " + + "using the supplied snapshot and skipping the redundant /flags fetch.", + ) + } + mergeFeatureFlagPropertiesFromSnapshot(properties, flags) + } + appendFeatureFlags -> { + getConfig()?.logger?.log( + "DEPRECATION: capture(appendFeatureFlags = true) is deprecated and will be " + + "removed in the next major. Call evaluateFlags(distinctId) once and pass the " + + "snapshot via capture(flags = …) instead — that path attaches " + + "\$feature/ properties without a redundant /flags request and lets you " + + "scope which flags to attach via flags.onlyAccessed() or flags.only(...).", + ) + mergeFeatureFlagProperties( + distinctId = distinctId, + groups = groups, + userProperties = userProperties, + groupProperties = null, + properties = properties, + ) + } + else -> properties + } + @Deprecated( message = "Prefer evaluateFlags(distinctId).isEnabled(key). Will be removed in the next major.", ) @@ -247,6 +270,56 @@ public class PostHog : PostHogStateless(), PostHogInterface { ) } + override fun captureException( + exception: Throwable, + distinctId: String?, + options: PostHogCaptureOptions, + ) { + if (!enabled) { + return + } + + try { + val captureContext = PostHogRequestContext.resolveCaptureContext(distinctId, options.properties) + val callerProperties = + mergeCaptureProperties( + distinctId = captureContext.distinctId, + properties = captureContext.properties, + userProperties = options.userProperties, + groups = options.groups, + appendFeatureFlags = options.appendFeatureFlags, + flags = options.flags, + ) + + val config = getConfig() + val exceptionProperties = + throwableCoercer.fromThrowableToPostHogProperties( + exception, + inAppIncludes = config?.errorTrackingConfig?.inAppIncludes ?: listOf(), + releaseIdentifier = config?.releaseIdentifier, + inAppExcludes = config?.errorTrackingConfig?.inAppExcludes ?: listOf(), + ) + // Caller properties merge AFTER the coerced exception properties (same order as core + // captureExceptionStateless) so options can override reserved keys like $exception_level. + callerProperties?.let { exceptionProperties.putAll(it) } + + // captureExceptionStateless cannot carry groups/user properties/timestamp, so the + // options overload coerces above and goes through captureStateless directly. + super.captureStateless( + PostHogEventName.EXCEPTION.event, + captureContext.distinctId, + exceptionProperties, + options.userProperties, + options.userPropertiesSetOnce, + options.groups, + options.timestamp, + ) + } catch (e: Throwable) { + // error capture must never throw into user code (parity with captureExceptionStateless) + getConfig()?.logger?.log("captureException has thrown an exception: $e.") + } + } + private fun mergeFeatureFlagProperties( distinctId: String, groups: Map?, 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 db168c6bc..45de3aa24 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -176,6 +176,29 @@ public open class PostHogConfig constructor( */ public var releaseIdentifier: String? = null + /** + * Package prefixes whose stack trace frames are marked in-app (`in_app: true`) on captured + * exceptions, e.g. `listOf("com.yourcompany")`. + * Docs https://posthog.com/docs/error-tracking + * + * When empty, every frame not matched by [inAppExcludes] is considered in-app. + * [inAppExcludes] always wins over this list. + * Defaults to an empty list. + */ + public var inAppIncludes: List = emptyList() + + /** + * Package prefixes whose stack trace frames are never marked in-app on captured exceptions. + * Excludes always win over [inAppIncludes]. + * Docs https://posthog.com/docs/error-tracking + * + * Defaults to [DEFAULT_IN_APP_EXCLUDES], which covers common JVM and framework packages + * (JDK, Kotlin, Spring, Netty, servlet containers, HTTP clients, the PostHog SDK, ...). + * Assigning your own list replaces these defaults; start from [DEFAULT_IN_APP_EXCLUDES] + * if you only want to add entries. + */ + public var inAppExcludes: List = DEFAULT_IN_APP_EXCLUDES + private val beforeSendCallbacks = mutableListOf() private val integrations = mutableListOf() @@ -257,6 +280,10 @@ public open class PostHogConfig constructor( // Propagate releaseIdentifier so exception frames carry map_id for symbolication coreConfig.releaseIdentifier = releaseIdentifier + // Error tracking: in-app frame classification + coreConfig.errorTrackingConfig.inAppIncludes.addAll(inAppIncludes) + coreConfig.errorTrackingConfig.inAppExcludes.addAll(inAppExcludes) + return coreConfig } @@ -279,6 +306,33 @@ public open class PostHogConfig constructor( public const val DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE: Int = 1000 public const val DEFAULT_POLL_INTERVAL_SECONDS: Int = 30 + /** + * Default [inAppExcludes] prefixes: common JVM, Kotlin, and server-framework packages + * whose frames are noise in application stack traces. Assigning your own list to + * [inAppExcludes] replaces these defaults. + */ + @JvmField + public val DEFAULT_IN_APP_EXCLUDES: List = + listOf( + "java.", + "javax.", + "jakarta.", + "kotlin.", + "kotlinx.", + "scala.", + "sun.", + "com.sun.", + "jdk.", + "org.springframework.", + "io.netty.", + "org.apache.", + "org.eclipse.jetty.", + "io.undertow.", + "okhttp3.", + "okio.", + "com.posthog.", + ) + /** * Creates a Java-friendly builder. * @@ -318,6 +372,8 @@ public open class PostHogConfig constructor( private var evaluationContexts: List? = null private var flagDefinitionCacheProvider: PostHogFlagDefinitionCacheProvider? = null private var releaseIdentifier: String? = null + private var inAppIncludes: List = emptyList() + private var inAppExcludes: List = DEFAULT_IN_APP_EXCLUDES /** * Sets the PostHog ingestion host. @@ -506,6 +562,24 @@ public open class PostHogConfig constructor( */ public fun releaseIdentifier(releaseIdentifier: String?): Builder = apply { this.releaseIdentifier = releaseIdentifier } + /** + * Sets the package prefixes whose stack trace frames are marked in-app on captured + * exceptions. [inAppExcludes] always wins over this list. + * + * @param inAppIncludes Package prefixes, e.g. `listOf("com.yourcompany")`. + * @return This builder. + */ + public fun inAppIncludes(inAppIncludes: List): Builder = apply { this.inAppIncludes = inAppIncludes } + + /** + * Sets the package prefixes whose stack trace frames are never marked in-app on captured + * exceptions, replacing [DEFAULT_IN_APP_EXCLUDES]. Excludes win over [inAppIncludes]. + * + * @param inAppExcludes Package prefixes to exclude from in-app classification. + * @return This builder. + */ + public fun inAppExcludes(inAppExcludes: List): Builder = apply { this.inAppExcludes = inAppExcludes } + /** * Builds a [PostHogConfig] from the accumulated values. * @@ -538,6 +612,8 @@ public open class PostHogConfig constructor( ) config.flagDefinitionCacheProvider = flagDefinitionCacheProvider config.releaseIdentifier = releaseIdentifier + config.inAppIncludes = inAppIncludes + config.inAppExcludes = inAppExcludes return config } } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt index d02dfaa18..f4b65ff4b 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt @@ -817,4 +817,43 @@ public sealed interface PostHogInterface { null, ) } + + /** + * Captures an exception + * Docs https://posthog.com/docs/error-tracking + * @param exception the exception to capture + * @param distinctId the distinctId. When null or blank, the current [PostHogRequestContext] + * distinct ID is used; if none exists, a personless UUID is generated. + * @param options capture options containing properties, user properties, groups, timestamp, + * and feature flag snapshot settings. Reserved exception properties such as + * `$exception_level` can be overridden via the options properties. + * + * Java callers passing an explicit untyped `null` third argument must cast it + * (`captureException(e, id, (Map) null)`) since it now matches both this + * overload and the properties one. + */ + public fun captureException( + exception: Throwable, + distinctId: String?, + options: PostHogCaptureOptions, + ) + + /** + * Captures an exception using the current [PostHogRequestContext] distinct ID, or as a + * personless event when no request context identity is active. + * Docs https://posthog.com/docs/error-tracking + * @param exception the exception to capture + * @param options capture options containing properties, user properties, groups, timestamp, + * and feature flag snapshot settings + */ + public fun captureException( + exception: Throwable, + options: PostHogCaptureOptions, + ) { + captureException( + exception, + null, + options, + ) + } } diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt index 1bceb9759..756b6b852 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -6,6 +6,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNull +import kotlin.test.assertTrue @Suppress("DEPRECATION") internal class PostHogConfigTest { @@ -661,6 +662,91 @@ internal class PostHogConfigTest { assertEquals(provider, config.flagDefinitionCacheProvider) } + // Error tracking config tests + + @Test + fun `in-app classification properties have expected defaults`() { + val config = PostHogConfig(apiKey = TEST_API_KEY) + + assertTrue(config.inAppIncludes.isEmpty()) + assertEquals(PostHogConfig.DEFAULT_IN_APP_EXCLUDES, config.inAppExcludes) + } + + @Test + fun `DEFAULT_IN_APP_EXCLUDES covers JVM and framework prefixes`() { + val expected = + listOf( + "java.", + "javax.", + "jakarta.", + "kotlin.", + "kotlinx.", + "scala.", + "sun.", + "com.sun.", + "jdk.", + "org.springframework.", + "io.netty.", + "org.apache.", + "org.eclipse.jetty.", + "io.undertow.", + "okhttp3.", + "okio.", + "com.posthog.", + ) + + assertEquals(expected, PostHogConfig.DEFAULT_IN_APP_EXCLUDES) + } + + @Test + fun `asCoreConfig propagates in-app classification settings to core config`() { + val config = PostHogConfig(apiKey = TEST_API_KEY) + config.inAppIncludes = listOf("com.myapp.") + config.inAppExcludes = listOf("com.thirdparty.") + + val coreConfig = config.asCoreConfig() + + assertEquals(listOf("com.myapp."), coreConfig.errorTrackingConfig.inAppIncludes) + assertEquals(listOf("com.thirdparty."), coreConfig.errorTrackingConfig.inAppExcludes) + } + + @Test + fun `asCoreConfig propagates default excludes`() { + val config = PostHogConfig(apiKey = TEST_API_KEY) + + val coreConfig = config.asCoreConfig() + + assertTrue(coreConfig.errorTrackingConfig.inAppIncludes.isEmpty()) + assertEquals(PostHogConfig.DEFAULT_IN_APP_EXCLUDES, coreConfig.errorTrackingConfig.inAppExcludes) + } + + @Test + fun `builder inAppIncludes method sets value and returns builder`() { + val builder = PostHogConfig.builder(TEST_API_KEY) + val result = builder.inAppIncludes(listOf("com.myapp.")) + assertEquals(builder, result) + + val config = builder.build() + assertEquals(listOf("com.myapp."), config.inAppIncludes) + } + + @Test + fun `builder inAppExcludes method replaces default excludes and returns builder`() { + val builder = PostHogConfig.builder(TEST_API_KEY) + val result = builder.inAppExcludes(listOf("com.thirdparty.")) + assertEquals(builder, result) + + val config = builder.build() + assertEquals(listOf("com.thirdparty."), config.inAppExcludes) + } + + @Test + fun `builder keeps default excludes when inAppExcludes is not called`() { + val config = PostHogConfig.builder(TEST_API_KEY).build() + + assertEquals(PostHogConfig.DEFAULT_IN_APP_EXCLUDES, config.inAppExcludes) + } + private class NoOpFlagDefinitionCacheProvider : PostHogBlockingFlagDefinitionCacheProvider() { override fun getFlagDefinitionsBlocking(): Map? = null diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt index cb31225de..3e211e9fe 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt @@ -682,4 +682,247 @@ internal class PostHogTest { postHog.close() mockServer.shutdown() } + + @Suppress("UNCHECKED_CAST") + private fun framesOf(props: Map): List> { + val exceptionList = props["\$exception_list"] as List> + val stacktrace = exceptionList.first()["stacktrace"] as Map + return stacktrace["frames"] as List> + } + + @Test + fun `captureException with options merges groups, flag and custom properties, and stamps frames`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createLocalEvaluationResponse("test-flag"))) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val url = mockServer.url("/").toString() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(url) + .personalApiKey("phx_test_personal_api_key") + .flushAt(1) + .inAppIncludes(listOf("com.posthog.server")) + .inAppExcludes(listOf("org.", "jdk.", "java.")) + .releaseIdentifier("posthog-server@1.0.0") + .build(), + ) + + postHog.captureException( + RuntimeException("boom"), + "user123", + PostHogCaptureOptions.builder() + .property("custom", "value") + .group("company", "acme") + .appendFeatureFlags(true) + .build(), + ) + + // Skip /local_evaluation request + mockServer.takeRequest(5, TimeUnit.SECONDS) + + val batchRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(batchRequest, "Expected /batch request within 5 seconds") + + val batch = batchRequest.parseBatch() + assertNotNull(batch.findEvent("\$exception"), "Expected \$exception event in batch") + + val props = batch.eventProperties("\$exception") + assertEquals("value", props["custom"]) + assertEquals(true, props["\$feature/test-flag"]) + + @Suppress("UNCHECKED_CAST") + val groups = props["\$groups"] as Map + assertEquals("acme", groups["company"]) + + val frames = framesOf(props) + assertTrue(frames.isNotEmpty()) + frames.forEach { frame -> assertEquals("posthog-server@1.0.0", frame["map_id"]) } + + val inAppFrames = frames.filter { it["in_app"] == true } + val notInAppFrames = frames.filter { it["in_app"] == false } + assertTrue(inAppFrames.isNotEmpty(), "Expected in-app frames from com.posthog.server") + assertTrue( + inAppFrames.all { (it["module"] as String).startsWith("com.posthog.server") }, + "Only frames matching inAppIncludes should be in-app", + ) + assertTrue(notInAppFrames.isNotEmpty(), "Expected frames outside inAppIncludes to not be in-app") + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `captureException with flags snapshot attaches flag properties without another flags request`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createFlagsResponse("test-flag"))) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val url = mockServer.url("/").toString() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(url) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user123") + + postHog.captureException( + RuntimeException("boom"), + "user123", + PostHogCaptureOptions.builder() + .flags(snapshot) + .build(), + ) + + val flagsRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(flagsRequest, "Expected /flags request within 5 seconds") + assertTrue(flagsRequest.path?.contains("/flags") == true, "First request should be /flags") + + val batchRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(batchRequest, "Expected /batch request within 5 seconds") + assertTrue(batchRequest.path?.contains("/batch") == true, "Second request should be /batch") + + val props = batchRequest.parseBatch().eventProperties("\$exception") + assertEquals(true, props["\$feature/test-flag"]) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `captureException with options allows overriding exception level via properties`() { + val mockServer = MockWebServer() + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val url = mockServer.url("/").toString() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(url) + .flushAt(1) + .build(), + ) + + postHog.captureException( + RuntimeException("boom"), + "user123", + PostHogCaptureOptions.builder() + .property("\$exception_level", "warning") + .property("\$exception_fingerprint", "custom-fingerprint") + .build(), + ) + + val batchRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(batchRequest, "Expected /batch request within 5 seconds") + + val props = batchRequest.parseBatch().eventProperties("\$exception") + assertEquals("warning", props["\$exception_level"]) + assertEquals("custom-fingerprint", props["\$exception_fingerprint"]) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `captureException with options and no distinct id stays personless`() { + val mockServer = MockWebServer() + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val url = mockServer.url("/").toString() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(url) + .flushAt(1) + .build(), + ) + + postHog.captureException( + RuntimeException("boom"), + PostHogCaptureOptions.builder() + .property("custom", "value") + .build(), + ) + + val batchRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(batchRequest, "Expected /batch request within 5 seconds") + + val batch = batchRequest.parseBatch() + val event = batch.findEvent("\$exception") + assertNotNull(event, "Expected \$exception event in batch") + val eventDistinctId = event.get("distinct_id").asString + assertTrue( + eventDistinctId.matches("[0-9a-fA-F-]{36}".toRegex()), + "Expected a generated UUID distinct id, got $eventDistinctId", + ) + + val props = batch.eventProperties("\$exception") + assertEquals(false, props["\$process_person_profile"]) + assertEquals("value", props["custom"]) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `captureException with default config splits in-app frames by DEFAULT_IN_APP_EXCLUDES`() { + val mockServer = MockWebServer() + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val url = mockServer.url("/").toString() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(url) + .flushAt(1) + .build(), + ) + + postHog.captureException(RuntimeException("boom"), "user123") + + val batchRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(batchRequest, "Expected /batch request within 5 seconds") + + val props = batchRequest.parseBatch().eventProperties("\$exception") + val frames = framesOf(props) + assertTrue(frames.isNotEmpty()) + + val sdkFrames = frames.filter { (it["module"] as String).startsWith("com.posthog.") } + assertTrue(sdkFrames.isNotEmpty(), "Expected frames from the test class itself") + assertTrue( + sdkFrames.all { it["in_app"] == false }, + "Frames matching DEFAULT_IN_APP_EXCLUDES (com.posthog.) should not be in-app", + ) + assertTrue( + frames.any { it["in_app"] == true }, + "Frames not matching any default exclude (e.g. junit/gradle) should stay in-app", + ) + assertTrue( + frames.none { it.containsKey("map_id") }, + "map_id should be absent when releaseIdentifier is not configured", + ) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `captureException options overload without distinct id delegates to canonical overload`() { + val postHog = spy(PostHog()) + val exception = RuntimeException("Test exception") + val options = PostHogCaptureOptions.builder().property("k", "v").build() + + postHog.captureException(exception, options) + + verify(postHog).captureException(exception, null, options) + } } From 709788c39216f82468be051268e9ef90b774bb44 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 5 Aug 2026 14:26:30 +0300 Subject: [PATCH 2/5] docs(server): use the canonical proguard upload command in releaseIdentifier KDoc --- .../src/main/java/com/posthog/server/PostHogConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 45de3aa24..08f3cf4c9 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -171,7 +171,7 @@ public open class PostHogConfig constructor( * Identifier attached as `map_id` to exception stack frames so PostHog can symbolicate * them against an uploaded ProGuard/R8 mapping. It must match the map-id used when * uploading the mapping, e.g. via - * `posthog-cli exp proguard upload --path "mapping.txt" --map-id ""`. + * `posthog-cli proguard upload --path "mapping.txt" --map-id ""`. * Defaults to null (no `map_id` attached). */ public var releaseIdentifier: String? = null From 899946e3ec78fa5b63732f164f5c2babbb833a25 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Mon, 3 Aug 2026 19:23:55 +0300 Subject: [PATCH 3/5] feat(server): opt-in uncaught exception capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds captureUncaughtExceptions (default false) to the server PostHogConfig. When enabled, the core PostHogErrorTrackingAutoCaptureIntegration installs a Thread.defaultUncaughtExceptionHandler that captures the throwable as a fatal, unhandled $exception (mechanism UncaughtExceptionHandler), flushes, then delegates to the previously installed handler. Core changes (all additive; Android behavior and the released install(PostHogInterface) path unchanged): - Gate strategy seam on the integration so the server can install with a local-only gate (no remote config, which the server SDK never fetches); Android keeps the remote errorTracking.autocaptureExceptions gate. - Captures flow through an internal CaptureTarget seam so the server's stateless client can drive the integration. - Handler-install ownership is tracked per integration instance, so closing a second opted-in client (whose install was a process-wide no-op) does not tear down the handler a still-open first client owns. - New @PostHogInternal PostHogCapturedThrowables identity marker (weak, ReferenceQueue-pruned). The guard is directional: log mirrors consult it, the uncaught handler only marks — a crash is always captured as the authoritative fatal/unhandled record even if the same instance was logged first (logger.error(..., e); throw e), and marking keeps post-crash log mirrors from re-reporting it. - Repeated setup() cannot replace the owning integration with a non-owning one, which would leave the global handler installed after close(). - With no previous default handler to chain to, the handler reproduces the JVM's built-in stderr crash output so enabling capture never hides crashes from stderr log collection. - Server config KDoc documents the flushAt implication for the crash path. --- .changeset/core-captured-throwables.md | 5 + .changeset/core-uncaught-gate.md | 5 + .changeset/server-uncaught-exceptions.md | 5 + posthog-server/api/posthog-server.api | 3 + .../main/java/com/posthog/server/PostHog.kt | 44 +++ .../java/com/posthog/server/PostHogConfig.kt | 33 +++ .../server/PostHogUncaughtExceptionTest.kt | 278 ++++++++++++++++++ posthog/api/posthog.api | 12 + ...tHogErrorTrackingAutoCaptureIntegration.kt | 117 +++++++- .../PostHogCapturedThrowables.kt | 69 +++++ ...ErrorTrackingAutoCaptureIntegrationTest.kt | 137 +++++++++ .../PostHogCapturedThrowablesTest.kt | 37 +++ 12 files changed, 736 insertions(+), 9 deletions(-) create mode 100644 .changeset/core-captured-throwables.md create mode 100644 .changeset/core-uncaught-gate.md create mode 100644 .changeset/server-uncaught-exceptions.md create mode 100644 posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionTest.kt create mode 100644 posthog/src/main/java/com/posthog/internal/errortracking/PostHogCapturedThrowables.kt create mode 100644 posthog/src/test/java/com/posthog/internal/errortracking/PostHogCapturedThrowablesTest.kt diff --git a/.changeset/core-captured-throwables.md b/.changeset/core-captured-throwables.md new file mode 100644 index 000000000..5dd7eb2ed --- /dev/null +++ b/.changeset/core-captured-throwables.md @@ -0,0 +1,5 @@ +--- +'posthog': patch +--- + +Add an internal process-wide `PostHogCapturedThrowables` guard (marked `@PostHogInternal`, visible only because of the multi-module architecture) that lets independent error-capture paths avoid double-reporting the same `Throwable` instance. The guard is directional: log-mirror paths (e.g. the `posthog-server-logback` appender) consult it and skip instances already reported, while the uncaught-exception handler only marks — a crash is always captured as the authoritative fatal/unhandled record even if the same instance was logged first, and marking it keeps post-crash log mirrors from reporting it again. Membership is keyed on instance identity and held weakly, so the guard never keeps a throwable or its stack alive. diff --git a/.changeset/core-uncaught-gate.md b/.changeset/core-uncaught-gate.md new file mode 100644 index 000000000..17a4d68b5 --- /dev/null +++ b/.changeset/core-uncaught-gate.md @@ -0,0 +1,5 @@ +--- +'posthog': patch +--- + +`PostHogErrorTrackingAutoCaptureIntegration` can now be gated on a caller-supplied strategy instead of the built-in gate (local `errorTrackingConfig.autoCapture` with remote config as a kill-switch): a new `PostHogErrorTrackingAutoCaptureIntegration(config, enabledGate)` constructor lets SDK layers that never fetch remote config (e.g. the server SDK) decide autocapture purely from local config. The uncaught handler also delivers captures through an internal `CaptureTarget` seam (`installWith`) so it can drive clients that are not a core `PostHogInterface`, and when no previous default handler exists it now reproduces the JVM's own `Exception in thread ...` stderr output, so installing capture never hides a crash from log collection. Android behavior and the existing `install(PostHogInterface)` path are otherwise unchanged; the additions are internal (`@PostHogInternal`) and visible only because of the multi-module architecture. diff --git a/.changeset/server-uncaught-exceptions.md b/.changeset/server-uncaught-exceptions.md new file mode 100644 index 000000000..d29e7056e --- /dev/null +++ b/.changeset/server-uncaught-exceptions.md @@ -0,0 +1,5 @@ +--- +'posthog-server': minor +--- + +Opt in to capturing uncaught JVM exceptions on the server SDK via `PostHogConfig.captureUncaughtExceptions` (also on the config `Builder`). When enabled, `PostHog` installs a global `Thread.defaultUncaughtExceptionHandler` on setup that captures the crashing exception as a fatal, unhandled `$exception` event (mechanism `UncaughtExceptionHandler`), flushes, and then delegates to the previously registered handler; the handler is removed again on `close()`. Unlike the Android SDK this is gated purely on the local flag — the server SDK never fetches remote config. A fatal `$exception` event is enqueued and sent synchronously on the crashing thread, bypassing `flushAt`, so capturing the crash does not depend on the periodic flush; delivery is still best-effort under an immediate hard exit. 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 a35f7593c..84ca17b1f 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -3,6 +3,7 @@ package com.posthog.server import com.posthog.FeatureFlagResult import com.posthog.PostHogEventName 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 @@ -27,11 +28,54 @@ 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) { + // 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. Gate purely on the local server flag — the server SDK never fetches + // remote config, so the remote-config gate the Android SDK uses can never fire here. + // 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 -> + val integration = PostHogErrorTrackingAutoCaptureIntegration(coreConfig) { true } + // The uncaught Throwable is a PostHogThrowable carrying fatal/handled=false/mechanism; + // routing it through captureException preserves those via the shared coercer, and the + // queue sends fatal exception events synchronously on the crashing thread. + integration.installWith( + object : PostHogErrorTrackingAutoCaptureIntegration.CaptureTarget { + override fun capture(throwable: Throwable) { + captureException(throwable) + } + + override fun flush() { + this@PostHog.flush() + } + }, + ) + uncaughtExceptionIntegration = integration + } + } } override fun close() { + uncaughtExceptionIntegration?.uninstall() + uncaughtExceptionIntegration = null super.close() } 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 08f3cf4c9..06acd7997 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -199,6 +199,28 @@ 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 (marked fatal, `handled=false`, mechanism + * `UncaughtExceptionHandler`), flushes, and then delegates to the previously registered + * handler. The handler is removed again on [PostHog.close]. + * + * 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. + * + * Delivery: the queue treats a fatal `$exception` event specially — it is enqueued and sent + * synchronously on the crashing thread, bypassing [flushAt] — so the crash itself does not depend + * on the periodic flush. The handler still calls `flush()` afterwards for anything else that was + * pending. Delivery remains best-effort under an immediate hard exit (the same guarantee the + * Android SDK provides). 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() @@ -374,6 +396,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. @@ -580,6 +603,15 @@ public open class PostHogConfig constructor( */ public fun inAppExcludes(inAppExcludes: List): Builder = apply { this.inAppExcludes = inAppExcludes } + /** + * 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. * @@ -614,6 +646,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/test/java/com/posthog/server/PostHogUncaughtExceptionTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionTest.kt new file mode 100644 index 000000000..8bd8d2252 --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/PostHogUncaughtExceptionTest.kt @@ -0,0 +1,278 @@ +package com.posthog.server + +import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import java.util.concurrent.TimeUnit +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()) + .flushAt(1) + .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 exception is captured as a fatal, unhandled exception event`() { + val mockServer = startServer() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .captureUncaughtExceptions(true) + .build(), + ) + + val handler = Thread.getDefaultUncaughtExceptionHandler() + assertTrue(handler is PostHogErrorTrackingAutoCaptureIntegration) + + handler.uncaughtException(Thread.currentThread(), IllegalStateException("kaboom")) + + val request = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(request, "Expected a /batch request within 5 seconds") + + 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( + "UncaughtExceptionHandler", + mechanism["type"], + "Uncaught exceptions must carry the UncaughtExceptionHandler mechanism", + ) + + 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 `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/api/posthog.api b/posthog/api/posthog.api index 2cf76bad3..a6159733a 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -532,12 +532,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;)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 @@ -1162,6 +1169,11 @@ public final class com/posthog/internal/VariantDefinition { public final fun getRolloutPercentage ()D } +public final class com/posthog/internal/errortracking/PostHogCapturedThrowables { + public static final field INSTANCE Lcom/posthog/internal/errortracking/PostHogCapturedThrowables; + public final fun markAndCheck (Ljava/lang/Throwable;)Z +} + public final class com/posthog/internal/errortracking/ThrowableCoercer { public static final field EXCEPTION_LEVEL_ATTRIBUTE Ljava/lang/String; public static final field EXCEPTION_LEVEL_FATAL Ljava/lang/String; diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index 6d803852a..c16686654 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -3,6 +3,8 @@ 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.PostHogCapturedThrowables import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.internal.errortracking.UncaughtExceptionHandlerAdapter import java.util.concurrent.atomic.AtomicBoolean @@ -11,12 +13,19 @@ 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 + // @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 +34,60 @@ 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() } + } + + /** + * Internal constructor allowing a custom [enabledGate]. Used by SDK layers (e.g. the server SDK) + * that decide autocapture purely from local config without any remote-config round trip. + * + * Not part of the public API; visible only because of the multi-module architecture. + */ + @PostHogInternal + public constructor(config: PostHogConfig, enabledGate: () -> Boolean) { + this.config = config + this.adapterExceptionHandler = UncaughtExceptionHandlerAdapter.Adapter.getInstance() + this.enabledGate = enabledGate } internal constructor(config: PostHogConfig, adapterExceptionHandler: UncaughtExceptionHandlerAdapter) { this.config = config this.adapterExceptionHandler = adapterExceptionHandler + this.enabledGate = { defaultGate() } + } + + internal constructor( + config: PostHogConfig, + adapterExceptionHandler: UncaughtExceptionHandlerAdapter, + enabledGate: () -> Boolean, + ) { + this.config = config + this.adapterExceptionHandler = adapterExceptionHandler + this.enabledGate = enabledGate + } + + /** + * 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 +100,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 +161,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 @@ -122,7 +201,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 +226,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 +237,33 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th throwable: Throwable, ) { if (captureEnabled) { - postHog?.let { postHog -> - postHog.captureException(PostHogThrowable(throwable, thread)) - postHog.flush() + captureTarget?.let { target -> + // Mark the throwable so post-crash log mirrors of this exact instance (e.g. a + // shutdown hook logging the crash) don't re-report it — but never skip the capture + // itself: this is the authoritative fatal/unhandled record for the crash and must not + // be downgraded by an earlier handled capture of the same instance + // (`logger.error(..., e); throw e`). + PostHogCapturedThrowables.markAndCheck(throwable) + target.capture(PostHogThrowable(throwable, thread)) + // The queue sends a fatal exception event synchronously on this (the crashing) + // thread, bypassing the flushAt threshold; this flush covers anything else still + // pending. Delivery is still best-effort under an immediate hard exit — same + // guarantee as the Android SDK. + target.flush() } } // 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 (throwable !is ThreadDeath) { + // 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) + } } } diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/PostHogCapturedThrowables.kt b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogCapturedThrowables.kt new file mode 100644 index 000000000..2c1aae9f0 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogCapturedThrowables.kt @@ -0,0 +1,69 @@ +package com.posthog.internal.errortracking + +import com.posthog.PostHogInternal +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * Process-wide guard that lets independent error-capture paths avoid double-reporting the very same + * [Throwable] instance. The guard is directional: log-mirror paths (the Logback appender) consult it + * and skip instances already reported, while the uncaught-exception handler only marks — a crash is + * always captured as the authoritative fatal/unhandled record, even if the same instance was logged + * first, and marking it prevents post-crash log mirrors from reporting it again. + * + * Membership is keyed strictly on **instance identity** (reference equality, not `equals`/`hashCode` + * — a `Throwable` subclass with value equality must not make two distinct instances collide) and + * held weakly, so entries disappear once the throwable is otherwise unreachable: the guard never + * keeps a throwable (or its stack) alive. + * + * Not part of the public API; visible only because of the multi-module architecture. + */ +@PostHogInternal +public object PostHogCapturedThrowables { + private val queue = ReferenceQueue() + + // Identity keys of throwables already captured. HashSet is not thread-safe, so all access is + // synchronized on the set. Cleared entries are pruned opportunistically via [queue]. + private val seen = HashSet() + + /** + * Records [throwable] as captured and reports whether the caller should capture it. + * + * @return true if this is the first time the instance has been marked (the caller should + * capture it), false if it was already marked (the caller should skip it). + */ + public fun markAndCheck(throwable: Throwable): Boolean = + synchronized(seen) { + pruneCleared() + seen.add(IdentityWeakKey(throwable, queue)) + } + + // Drop keys whose referent has been collected. Must be called while holding the lock. + private fun pruneCleared() { + while (true) { + val cleared = queue.poll() ?: break + seen.remove(cleared) + } + } + + // Weak reference whose identity is the referential identity of the referent, so distinct + // instances never collide even if their class overrides equals/hashCode. hashCode is captured + // eagerly (identity hash is stable) so a key still matches after its referent is cleared. + private class IdentityWeakKey( + referent: Throwable, + queue: ReferenceQueue, + ) : WeakReference(referent, queue) { + private val identityHash = System.identityHashCode(referent) + + override fun hashCode(): Int = identityHash + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IdentityWeakKey) return false + val self = get() + // Reference equality on the referents; a cleared referent only matches itself (handled + // by the identity check above), which is fine — such keys are pruned via the queue. + return self != null && self === other.get() + } + } +} diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index 2aefb1799..ecdab1042 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -4,6 +4,7 @@ import com.posthog.PostHogConfig import com.posthog.PostHogInterface import com.posthog.internal.PostHogPrintLogger import com.posthog.internal.PostHogRemoteConfig +import com.posthog.internal.errortracking.PostHogCapturedThrowables import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.internal.errortracking.UncaughtExceptionHandlerAdapter import org.mockito.kotlin.any @@ -16,6 +17,7 @@ import org.mockito.kotlin.whenever import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals internal class PostHogErrorTrackingAutoCaptureIntegrationTest { private val mockConfig = mock() @@ -261,6 +263,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) { 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) { 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 +582,115 @@ 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 captures even when the throwable was already captured elsewhere`() { + val target = RecordingTarget() + val integration = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter) { true } + integration.installWith(target) + + val alreadyLogged = RuntimeException("logged then crashed") + // Simulate the appender having captured this exact instance first. + assertEquals(true, PostHogCapturedThrowables.markAndCheck(alreadyLogged)) + + integration.uncaughtException(Thread.currentThread(), alreadyLogged) + + // The crash is the authoritative fatal/unhandled record: it must be captured even though + // the instance was already reported as a handled log capture, and the queue is flushed so + // both events leave before the process exits. + assertEquals(1, target.captured.size, "The crash capture must not be suppressed by dedup") + assertEquals(1, target.flushCount, "flush() must run on the crash path") + + integration.uninstall() + } + + @Test + fun `uncaughtException marks the throwable so later log mirrors dedup against it`() { + val target = RecordingTarget() + val integration = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter) { true } + integration.installWith(target) + + val crash = RuntimeException("crashed then logged") + integration.uncaughtException(Thread.currentThread(), crash) + + assertEquals(1, target.captured.size) + // A post-crash log mirror consulting the guard must see the instance as already captured. + assertEquals(false, PostHogCapturedThrowables.markAndCheck(crash)) + + integration.uninstall() + } + + @Test + fun `uncaughtException reproduces the JVM default crash output when no previous handler exists`() { + currentHandler = null + + val target = RecordingTarget() + val integration = PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter) { 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) { 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 `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) { 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) { 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) + } } diff --git a/posthog/src/test/java/com/posthog/internal/errortracking/PostHogCapturedThrowablesTest.kt b/posthog/src/test/java/com/posthog/internal/errortracking/PostHogCapturedThrowablesTest.kt new file mode 100644 index 000000000..9d1d1dda7 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/errortracking/PostHogCapturedThrowablesTest.kt @@ -0,0 +1,37 @@ +package com.posthog.internal.errortracking + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class PostHogCapturedThrowablesTest { + @Test + fun `first mark returns true, repeat mark of same instance returns false`() { + val throwable = RuntimeException("boom") + + assertTrue(PostHogCapturedThrowables.markAndCheck(throwable), "first sighting should be captured") + assertFalse(PostHogCapturedThrowables.markAndCheck(throwable), "same instance should be deduped") + } + + @Test + fun `distinct instances that are equal by value are both captured`() { + // A Throwable subclass with value equality must NOT make two distinct instances collide: + // dedup is strictly instance-identity based. + val a = ValueEqualThrowable("same") + val b = ValueEqualThrowable("same") + + // Sanity: they are equal by value but distinct instances. + assertTrue(a == b) + assertFalse(a === b) + + assertTrue(PostHogCapturedThrowables.markAndCheck(a), "first instance captured") + assertTrue(PostHogCapturedThrowables.markAndCheck(b), "second, value-equal instance must still be captured") + assertFalse(PostHogCapturedThrowables.markAndCheck(a), "re-marking the first instance is still deduped") + } + + private class ValueEqualThrowable(private val token: String) : RuntimeException(token) { + override fun equals(other: Any?): Boolean = other is ValueEqualThrowable && other.token == token + + override fun hashCode(): Int = token.hashCode() + } +} From 6eda46c51472db10df06f27ab86fc1cbee657128 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Mon, 3 Aug 2026 19:23:55 +0300 Subject: [PATCH 4/5] feat(logback): add posthog-server-logback appender module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pure-JVM module publishing com.posthog:posthog-server-logback — a Logback appender that mirrors qualifying log events into PostHog error tracking. - PostHogAppender (AppenderBase): captures events at minimumCaptureLevel (default ERROR) that carry a real Throwable; maps log level to $exception_level; attaches logger name and the log message when it differs from the throwable message; never throws from append. - Client wiring via static PostHogAppender.setPostHog(...); events before registration are dropped by design. Runs on the logging thread, so PostHogRequestContext scopes resolve the distinct id automatically. - Recursion guard: com.posthog.* loggers are never captured. - Dedup: consults the core PostHogCapturedThrowables identity marker so the appender and the uncaught handler don't double-report the same Throwable. - logback-classic is compileOnly (1.3.x line, Java 8); animalsniffer and apiCheck pass. release.yml publish matrix / version-sync intentionally not updated (workflow edits out of scope) — human follow-up before release. --- .changeset/server-logback-appender.md | 10 + Makefile | 6 + buildSrc/src/main/java/PosthogBuildConfig.kt | 3 + gradle.properties | 1 + pnpm-lock.yaml | 2 + pnpm-workspace.yaml | 1 + posthog-server-logback/CHANGELOG.md | 3 + .../api/posthog-server-logback.api | 21 ++ posthog-server-logback/build.gradle.kts | 109 +++++++ posthog-server-logback/gradle.lockfile | 79 +++++ posthog-server-logback/package.json | 5 + .../posthog/server/logback/PostHogAppender.kt | 243 +++++++++++++++ .../server/logback/PostHogAppenderTest.kt | 288 ++++++++++++++++++ settings.gradle.kts | 1 + 14 files changed, 772 insertions(+) create mode 100644 .changeset/server-logback-appender.md create mode 100644 posthog-server-logback/CHANGELOG.md create mode 100644 posthog-server-logback/api/posthog-server-logback.api create mode 100644 posthog-server-logback/build.gradle.kts create mode 100644 posthog-server-logback/gradle.lockfile create mode 100644 posthog-server-logback/package.json create mode 100644 posthog-server-logback/src/main/java/com/posthog/server/logback/PostHogAppender.kt create mode 100644 posthog-server-logback/src/test/java/com/posthog/server/logback/PostHogAppenderTest.kt diff --git a/.changeset/server-logback-appender.md b/.changeset/server-logback-appender.md new file mode 100644 index 000000000..fd067c1f6 --- /dev/null +++ b/.changeset/server-logback-appender.md @@ -0,0 +1,10 @@ +--- +'posthog-server-logback': minor +--- + +Initial `0.x` release of `com.posthog:posthog-server-logback` — a Logback appender that reports logged errors to PostHog Error Tracking through the server SDK. This module is pre-1.0; its public API may change between minor versions. + +- `PostHogAppender` (`ch.qos.logback.core.AppenderBase`, package `com.posthog.server.logback`) captures events at or above `minimumCaptureLevel` (default `ERROR`) that carry a `Throwable`, sending them through the server SDK's `captureException` so request-context distinct-id resolution and in-app frame config apply automatically. +- Register a configured server client once at startup with `PostHogAppender.setPostHog(client)`; events logged before registration are dropped. Alternatively self-configure from `logback.xml` via ``/`` (each self-configuring appender owns its client and closes it on `stop()`, so a Logback config reload keeps capturing). An application-registered client always wins. +- Events from `com.posthog…` loggers are always skipped (recursion guard), events without a throwable are skipped (no message-only synthesis in v1), and captured events carry `$exception_level` (`error`, or `warning` when the threshold is lowered), `logger_name`, and the log message under `log_message` when it differs from the throwable message. +- Depends on `com.posthog:posthog-server` (transitive `api`); Logback is `compileOnly` — provide `logback-classic` yourself (1.3.x line for Java 8 compatibility). diff --git a/Makefile b/Makefile index ee03495b4..692feef47 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,9 @@ dryReleaseSurveysCompose: dryReleaseServer: ./gradlew :posthog-server:publishToMavenLocal +dryReleaseServerLogback: + ./gradlew :posthog-server-logback:publishToMavenLocal + dryReleaseAndroidPlugin: ./gradlew :posthog-android-gradle-plugin:publishToMavenLocal @@ -53,6 +56,9 @@ releaseSurveysCompose: releaseServer: ./gradlew :posthog-server:publishToSonatype closeAndReleaseSonatypeStagingRepository +releaseServerLogback: + ./gradlew :posthog-server-logback:publishToSonatype closeAndReleaseSonatypeStagingRepository + releaseAndroidPlugin: ./gradlew :posthog-android-gradle-plugin:publishToSonatype :posthog-android-gradle-plugin:closeAndReleaseSonatypeStagingRepository diff --git a/buildSrc/src/main/java/PosthogBuildConfig.kt b/buildSrc/src/main/java/PosthogBuildConfig.kt index 540e69aed..37148798c 100644 --- a/buildSrc/src/main/java/PosthogBuildConfig.kt +++ b/buildSrc/src/main/java/PosthogBuildConfig.kt @@ -65,6 +65,9 @@ object PosthogBuildConfig { val GSON = "2.10.1" val OKHTTP = "4.12.0" + + // Logback appender: 1.3.x is the last line targeting Java 8 (1.4.x+ requires Java 11). + val LOGBACK = "1.3.14" val CURTAINS = "1.2.5" val ANDROIDX_CORE = "1.5.0" val ANDROIDX_COMPOSE = "1.0.0" diff --git a/gradle.properties b/gradle.properties index 178940a95..48fee5158 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,6 +26,7 @@ coreVersion=6.32.0 androidVersion=3.58.4 surveysComposeVersion=0.1.0 serverVersion=2.12.1 +serverLogbackVersion=0.1.0 # Shared build versions (single source of truth for buildSrc, PosthogBuildConfig, and gradle plugin) kotlinVersion=2.1.21 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea41f891e..5f854ccab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,8 @@ importers: posthog-server: {} + posthog-server-logback: {} + packages: '@babel/runtime@7.29.7': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bed9e961e..8c2066d13 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - "posthog-android" - "posthog-android-surveys-compose" - "posthog-server" + - "posthog-server-logback" - "posthog-android-gradle-plugin" minimumReleaseAge: 10080 diff --git a/posthog-server-logback/CHANGELOG.md b/posthog-server-logback/CHANGELOG.md new file mode 100644 index 000000000..a9e546fcd --- /dev/null +++ b/posthog-server-logback/CHANGELOG.md @@ -0,0 +1,3 @@ +# posthog-server-logback + +## Next diff --git a/posthog-server-logback/api/posthog-server-logback.api b/posthog-server-logback/api/posthog-server-logback.api new file mode 100644 index 000000000..97cf1ffdf --- /dev/null +++ b/posthog-server-logback/api/posthog-server-logback.api @@ -0,0 +1,21 @@ +public final class com/posthog/server/logback/PostHogAppender : ch/qos/logback/core/AppenderBase { + public static final field Companion Lcom/posthog/server/logback/PostHogAppender$Companion; + public fun ()V + public synthetic fun append (Ljava/lang/Object;)V + public static final fun clearPostHog ()V + public final fun getApiKey ()Ljava/lang/String; + public final fun getHost ()Ljava/lang/String; + public final fun getMinimumCaptureLevel ()Lch/qos/logback/classic/Level; + public final fun setApiKey (Ljava/lang/String;)V + public final fun setHost (Ljava/lang/String;)V + public final fun setMinimumCaptureLevel (Lch/qos/logback/classic/Level;)V + public static final fun setPostHog (Lcom/posthog/server/PostHogInterface;)V + public fun start ()V + public fun stop ()V +} + +public final class com/posthog/server/logback/PostHogAppender$Companion { + public final fun clearPostHog ()V + public final fun setPostHog (Lcom/posthog/server/PostHogInterface;)V +} + diff --git a/posthog-server-logback/build.gradle.kts b/posthog-server-logback/build.gradle.kts new file mode 100644 index 000000000..e82dfa2f9 --- /dev/null +++ b/posthog-server-logback/build.gradle.kts @@ -0,0 +1,109 @@ +@file:Suppress("ktlint:standard:max-line-length") + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +version = properties["serverLogbackVersion"].toString() + +plugins { + `java-library` + kotlin("jvm") + id("com.android.lint") + + // publish + `maven-publish` + signing + id("org.jetbrains.dokka") + + // plugins + id("com.github.gmazzo.buildconfig") + + // tests + id("org.jetbrains.kotlinx.kover") + + // compatibility + id("ru.vyarus.animalsniffer") +} + +buildConfig { + useKotlinOutput() + packageName("com.posthog.server.logback") + buildConfigField("String", "SDK_NAME", "\"posthog-server-logback\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +java { + withSourcesJar() + sourceCompatibility = PosthogBuildConfig.Build.JAVA_VERSION + targetCompatibility = PosthogBuildConfig.Build.JAVA_VERSION +} + +val dokkaJavadocJar by tasks.register("dokkaJavadocJar") { + dependsOn(tasks.dokkaJavadoc) + from(tasks.dokkaJavadoc.flatMap { it.outputDirectory }) + archiveClassifier.set("javadoc") +} + +val dokkaHtmlJar by tasks.register("dokkaHtmlJar") { + dependsOn(tasks.dokkaHtml) + from(tasks.dokkaHtml.flatMap { it.outputDirectory }) + archiveClassifier.set("html-doc") +} + +publishing { + publications { + create("maven") { + from(components["java"]) + artifact(dokkaJavadocJar) + artifact(dokkaHtmlJar) + + postHogConfig(project.name, project.version.toString()) + pom.postHogConfig( + project.name, + moduleDescription = "Logback appender that reports errors to PostHog via the server SDK", + ) + } + } + signing.postHogConfig("maven", this) +} + +tasks.withType().configureEach { + compilerOptions.postHogConfig() +} + +kotlin { + explicitApi() +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +dependencies { + // Expose the server SDK transitively: consumers configure and use a PostHog server client + // alongside the appender, so it belongs on the compile classpath of anything depending on this. + api(project(":posthog-server")) + + implementation(kotlin("stdlib-jdk8", PosthogBuildConfig.Kotlin.KOTLIN)) + + // Logback is provided by the host application; keep it off our runtime classpath and pin a + // conservative Java 8-compatible floor (the 1.3.x line; 1.4.x+ requires Java 11). + compileOnly("ch.qos.logback:logback-classic:${PosthogBuildConfig.Dependencies.LOGBACK}") + compileOnly("org.codehaus.mojo:animal-sniffer-annotations:${PosthogBuildConfig.Plugins.ANIMAL_SNIFFER_SDK_ANNOTATION}") + + // compatibility + signature("org.codehaus.mojo.signature:java18:${PosthogBuildConfig.Plugins.SIGNATURE_JAVA18}@signature") + + // tests + testImplementation("ch.qos.logback:logback-classic:${PosthogBuildConfig.Dependencies.LOGBACK}") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${PosthogBuildConfig.Kotlin.KOTLIN}") + testImplementation("com.squareup.okhttp3:mockwebserver:${PosthogBuildConfig.Dependencies.OKHTTP}") + testImplementation("com.google.code.gson:gson:${PosthogBuildConfig.Dependencies.GSON}") +} + +tasks.javadoc { + if (JavaVersion.current().isJava9Compatible) { + (options as StandardJavadocDocletOptions).addBooleanOption("html5", true) + } +} diff --git a/posthog-server-logback/gradle.lockfile b/posthog-server-logback/gradle.lockfile new file mode 100644 index 000000000..219720402 --- /dev/null +++ b/posthog-server-logback/gradle.lockfile @@ -0,0 +1,79 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +ch.qos.logback:logback-classic:1.3.14=compileClasspath,compileOnlyDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +ch.qos.logback:logback-core:1.3.14=compileClasspath,compileOnlyDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +com.fasterxml.jackson.core:jackson-core:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +com.fasterxml.jackson.core:jackson-databind:2.12.7.1=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.12.7=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.12.7=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +com.fasterxml.jackson.module:jackson-module-kotlin:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +com.fasterxml.jackson:jackson-bom:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +com.fasterxml.woodstox:woodstox-core:6.2.4=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +com.google.code.gson:gson:2.10.1=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.squareup.okhttp3:mockwebserver:4.12.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.squareup.okhttp3:okhttp-bom:4.12.0=runtimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +it.unimi.dsi:fastutil-core:8.5.12=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +jakarta.activation:jakarta.activation-api:1.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +junit:junit:4.13.2=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.codehaus.mojo.signature:java18:1.0=signature +org.codehaus.mojo:animal-sniffer-annotations:1.23=compileClasspath,compileOnlyDependenciesMetadata +org.codehaus.mojo:animal-sniffer-annotations:1.24=animalsniffer +org.codehaus.mojo:animal-sniffer:1.24=animalsniffer +org.codehaus.woodstox:stax2-api:4.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +org.freemarker:freemarker:2.3.32=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.dokka:analysis-kotlin-descriptors:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jetbrains.dokka:analysis-markdown:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jetbrains.dokka:dokka-base:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jetbrains.dokka:dokka-core:1.9.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-build-common:2.1.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.1.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.1.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.1.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.1.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.1.10=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.1.10=bcv-rt-jvm-cp-resolver +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlin:kotlin-script-runtime:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlin:kotlin-stdlib-common:2.1.10=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.10=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.10=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlin:kotlin-stdlib:2.1.10=apiDependenciesMetadata,bcv-rt-jvm-cp-resolver,compileClasspath,implementationDependenciesMetadata,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit:2.1.10=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.1.10=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jetbrains.kotlinx:kover-jvm-agent:0.9.9=koverJvmAgent,koverJvmReporter +org.jetbrains:annotations:13.0=bcv-rt-jvm-cp-resolver,compileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:23.0.0=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains:markdown-jvm:0.5.2=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jetbrains:markdown:0.5.2=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.jsoup:jsoup:1.16.1=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.ow2.asm:asm-tree:9.6=bcv-rt-jvm-cp-resolver +org.ow2.asm:asm:9.6=bcv-rt-jvm-cp-resolver +org.ow2.asm:asm:9.7=animalsniffer +org.slf4j:slf4j-api:2.0.7=compileClasspath,compileOnlyDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +empty=annotationProcessor,dokkaPlugin,dokkaRuntime,intransitiveDependenciesMetadata,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,koverExternalArtifacts,koverExternalArtifactsJvm,lintChecks,testAnnotationProcessor,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testIntransitiveDependenciesMetadata,testKotlinScriptDefExtensions diff --git a/posthog-server-logback/package.json b/posthog-server-logback/package.json new file mode 100644 index 000000000..ac4080176 --- /dev/null +++ b/posthog-server-logback/package.json @@ -0,0 +1,5 @@ +{ + "name": "posthog-server-logback", + "version": "0.1.0", + "private": true +} diff --git a/posthog-server-logback/src/main/java/com/posthog/server/logback/PostHogAppender.kt b/posthog-server-logback/src/main/java/com/posthog/server/logback/PostHogAppender.kt new file mode 100644 index 000000000..a2785cb12 --- /dev/null +++ b/posthog-server-logback/src/main/java/com/posthog/server/logback/PostHogAppender.kt @@ -0,0 +1,243 @@ +package com.posthog.server.logback + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.ThrowableProxy +import ch.qos.logback.core.AppenderBase +import com.posthog.internal.errortracking.PostHogCapturedThrowables +import com.posthog.server.PostHog +import com.posthog.server.PostHogConfig +import com.posthog.server.PostHogInterface + +/** + * A Logback [AppenderBase] that reports logged errors to PostHog Error Tracking through the server + * SDK. + * + * ## Wiring the client + * + * The appender needs a server [PostHogInterface]. Register one you already configure elsewhere: + * + * ```kotlin + * val posthog = PostHog.with(PostHogConfig.builder("").build()) + * PostHogAppender.setPostHog(posthog) + * ``` + * + * Registration is process-wide (Logback owns appender instances), so do it once during startup, + * before the logging that should be captured. **Events logged before a client is registered are + * dropped** — the appender has nowhere to send them. + * + * Alternatively, the appender can create and own its own client from `logback.xml` when you set + * [apiKey] (and optionally [host]); that client is closed again on [stop]. An explicitly registered + * client via [setPostHog] always wins over self-configuration: appenders never replace it. Each + * self-configuring appender owns its own client, so a Logback configuration reload (new appender + * started before the old one stops) keeps capturing. + * + * ```xml + * + * ${POSTHOG_API_KEY} + * ERROR + * + * ``` + * + * ## What is captured + * + * Only events at or above [minimumCaptureLevel] (default [Level.ERROR]) that carry a [Throwable] + * are captured; events without a throwable are skipped (there is no message-only synthesis). + * Events from the PostHog SDK's own loggers (`com.posthog` and `com.posthog.*`) are always skipped + * so the SDK cannot recursively report its own errors. + * + * The throwable is sent through the server SDK's `captureException`, so request-context distinct-id + * resolution and in-app frame configuration apply automatically — the appender runs on the logging + * thread, inside any active `PostHogRequestContext` scope. + * + * Docs https://posthog.com/docs/error-tracking + */ +public class PostHogAppender : AppenderBase() { + /** + * Minimum log level captured. Defaults to `ERROR`. Set from `logback.xml` via + * `WARN`. + */ + @Volatile + public var minimumCaptureLevel: Level = Level.ERROR + + /** + * Optional PostHog project API key. When set and no client has been registered via + * [setPostHog], the appender creates and owns its own server client on [start]. + */ + public var apiKey: String? = null + + /** + * Optional PostHog host for the self-configured client. Ignored unless [apiKey] is set. + */ + public var host: String? = null + + // Client this appender created from apiKey/host and is responsible for closing on stop(). + private var ownedPostHog: PostHogInterface? = null + + override fun start() { + // Self-configure unless the application registered a client itself. A client another + // appender self-configured is NOT a reason to skip: on a Logback config reload the new + // appender starts before the old one stops, and the old one takes its client down with it. + if (!explicitlyRegistered && !apiKey.isNullOrBlank()) { + try { + val builder = PostHogConfig.builder(apiKey!!.trim()) + host?.trim()?.takeIf { it.isNotEmpty() }?.let { builder.host(it) } + val client = PostHog.with(builder.build()) + ownedPostHog = client + publishSelfConfiguredIfAbsent(client) + } catch (e: Throwable) { + addError("PostHogAppender failed to create a PostHog client from configuration.", e) + } + } + super.start() + } + + override fun stop() { + super.stop() + ownedPostHog?.let { owned -> + try { + // Captures are enqueued asynchronously and close() does not drain the queue, so ask + // for a flush first. Both are best-effort: the flush runs on the queue executor that + // close() then stops, so events logged right at shutdown may still be lost. + owned.flush() + owned.close() + } catch (e: Throwable) { + addError("PostHogAppender failed to close its PostHog client.", e) + } + // Only clear the shared reference if it still points at the client we own. + clearPostHogIf(owned) + ownedPostHog = null + } + } + + override fun append(event: ILoggingEvent) { + // Appenders must never throw into the logging pipeline. + try { + val postHog = resolveClient() ?: return + + // Recursion guard: the SDK logs its own errors; capturing them would loop. Match the + // exact SDK logger or its package (dot-terminated) so unrelated packages that merely + // share the string prefix (e.g. com.posthogger.*) are still captured. + val loggerName = event.loggerName + if (loggerName == POSTHOG_LOGGER_NAME || loggerName?.startsWith(POSTHOG_LOGGER_PACKAGE_PREFIX) == true) { + return + } + + if (!event.level.isGreaterOrEqual(minimumCaptureLevel)) { + return + } + + // v1 only captures real throwables; no message-only synthesis. + val throwable = (event.throwableProxy as? ThrowableProxy)?.throwable ?: return + + // Dedup: skip instances already reported through any capture path (an earlier log of + // the same throwable, or a crash the uncaught handler already recorded — the crash + // side always captures and only marks, so the log mirror is the side that yields). + if (!PostHogCapturedThrowables.markAndCheck(throwable)) { + return + } + + postHog.captureException(throwable, buildProperties(event, throwable)) + } catch (e: Throwable) { + addError("PostHogAppender failed to capture a logging event.", e) + } + } + + /** + * An application-registered client always wins; otherwise this appender uses the client it + * configured itself, so two self-configured appenders never send each other's events to the + * wrong project, and falls back to whatever is registered process-wide. + */ + private fun resolveClient(): PostHogInterface? = + if (explicitlyRegistered) { + sharedPostHog + } else { + ownedPostHog ?: sharedPostHog + } + + private fun buildProperties( + event: ILoggingEvent, + throwable: Throwable, + ): Map { + val properties = mutableMapOf() + properties[EXCEPTION_LEVEL] = mapLevel(event.level) + event.loggerName?.let { properties[LOGGER_NAME] = it } + + // Attach the log message only when it adds information beyond the throwable's own message. + val message = event.formattedMessage + if (!message.isNullOrEmpty() && message != throwable.message) { + properties[LOG_MESSAGE] = message + } + return properties + } + + private fun mapLevel(level: Level): String = + when { + level.isGreaterOrEqual(Level.ERROR) -> LEVEL_ERROR + else -> LEVEL_WARNING + } + + public companion object { + private const val POSTHOG_LOGGER_NAME = "com.posthog" + private const val POSTHOG_LOGGER_PACKAGE_PREFIX = "com.posthog." + + private const val EXCEPTION_LEVEL = "\$exception_level" + private const val LOGGER_NAME = "logger_name" + private const val LOG_MESSAGE = "log_message" + + private const val LEVEL_ERROR = "error" + private const val LEVEL_WARNING = "warning" + + @Volatile + private var sharedPostHog: PostHogInterface? = null + + // True while the client came from setPostHog (the application owns it), false while it came + // from an appender's own apiKey configuration. An application-registered client always wins: + // appenders never replace it, and it outlives any appender. + @Volatile + private var explicitlyRegistered = false + + /** + * Registers the PostHog server client the appender captures through. Call once during + * application startup, before the logging that should be captured. + * + * @param postHog the configured server SDK client. + */ + @JvmStatic + @Synchronized + public fun setPostHog(postHog: PostHogInterface) { + sharedPostHog = postHog + explicitlyRegistered = true + } + + /** + * Clears the registered client. Mostly useful for tests and clean shutdown. + */ + @JvmStatic + @Synchronized + public fun clearPostHog() { + sharedPostHog = null + explicitlyRegistered = false + } + + // Publishes a self-configured client as the process-wide fallback, but never over an + // application-registered one — including a setPostHog that landed while the client was being + // built. The appender keeps using its own client either way. + @Synchronized + private fun publishSelfConfiguredIfAbsent(postHog: PostHogInterface) { + if (explicitlyRegistered) { + return + } + sharedPostHog = postHog + } + + // Clears the shared client only if it is the given instance, so a self-owned client's stop() + // does not wipe a client someone else registered afterwards. + @Synchronized + private fun clearPostHogIf(expected: PostHogInterface) { + if (sharedPostHog === expected) { + sharedPostHog = null + } + } + } +} diff --git a/posthog-server-logback/src/test/java/com/posthog/server/logback/PostHogAppenderTest.kt b/posthog-server-logback/src/test/java/com/posthog/server/logback/PostHogAppenderTest.kt new file mode 100644 index 000000000..c2fe474e2 --- /dev/null +++ b/posthog-server-logback/src/test/java/com/posthog/server/logback/PostHogAppenderTest.kt @@ -0,0 +1,288 @@ +package com.posthog.server.logback + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.reflect.TypeToken +import com.posthog.server.PostHog +import com.posthog.server.PostHogConfig +import com.posthog.server.PostHogInterface +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.GzipSource +import okio.buffer +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal class PostHogAppenderTest { + private val gson = Gson() + private var mockServer: MockWebServer? = null + private var postHog: PostHogInterface? = null + private var loggerContext: LoggerContext? = null + + @AfterTest + fun tearDown() { + loggerContext?.stop() + postHog?.close() + PostHogAppender.clearPostHog() + mockServer?.shutdown() + } + + /** + * Wires a programmatic Logback context with a started [PostHogAppender] attached to a fresh + * logger, backed by a real server client pointing at a [MockWebServer] (flushAt=1 so each event + * is flushed on the queue thread right after being enqueued). + */ + private fun setup( + minimumCaptureLevel: Level = Level.ERROR, + loggerName: String = "com.example.Service", + register: Boolean = true, + ): Logger { + val server = MockWebServer() + server.enqueue(MockResponse().setResponseCode(200)) + server.enqueue(MockResponse().setResponseCode(200)) + server.start() + mockServer = server + + val client = + PostHog.with( + PostHogConfig.builder("test-api-key") + .host(server.url("/").toString()) + .flushAt(1) + .build(), + ) + postHog = client + if (register) { + PostHogAppender.setPostHog(client) + } + + val context = LoggerContext() + loggerContext = context + val appender = + PostHogAppender().apply { + this.context = context + this.minimumCaptureLevel = minimumCaptureLevel + start() + } + + val logger = context.getLogger(loggerName) + logger.level = Level.TRACE + logger.addAppender(appender) + logger.isAdditive = false + return logger + } + + private fun takeBatch(): BatchRequest? { + val request = mockServer?.takeRequest(5, TimeUnit.SECONDS) ?: return null + return BatchRequest(request, gson) + } + + @Test + fun `error with throwable is captured as one exception event`() { + val logger = setup() + + logger.error("payment failed", IllegalStateException("kaboom")) + + val batch = takeBatch() + assertNotNull(batch, "Expected a /batch request") + val event = batch.findEvent("\$exception") + assertNotNull(event, "Expected one \$exception event") + + val props = batch.eventProperties("\$exception") + assertEquals("error", props["\$exception_level"]) + assertEquals("com.example.Service", props["logger_name"]) + // Message differs from the throwable's message, so it is attached. + assertEquals("payment failed", props["log_message"]) + } + + @Test + fun `below threshold events are ignored`() { + val logger = setup(minimumCaptureLevel = Level.ERROR) + + logger.warn("just a warning", IllegalStateException("ignored")) + + // No capture ⇒ no /batch request within the window. + assertNull(mockServer?.takeRequest(2, TimeUnit.SECONDS), "WARN below ERROR threshold must be ignored") + } + + @Test + fun `warn is captured with warning level when threshold lowered`() { + val logger = setup(minimumCaptureLevel = Level.WARN) + + logger.warn("degraded", RuntimeException("slow")) + + val batch = takeBatch() + assertNotNull(batch) + val props = batch.eventProperties("\$exception") + assertEquals("warning", props["\$exception_level"], "WARN maps to \"warning\" when captured") + } + + @Test + fun `posthog logger names are ignored to prevent recursion`() { + setup(loggerName = "com.posthog.internal.Something") + val logger = loggerContext!!.getLogger("com.posthog.internal.Something") + + logger.error("SDK internal error", IllegalStateException("loop")) + + assertNull(mockServer?.takeRequest(2, TimeUnit.SECONDS), "com.posthog.* loggers must be ignored") + } + + @Test + fun `logger packages merely sharing the string prefix are still captured`() { + setup(loggerName = "com.posthogger.Service") + val logger = loggerContext!!.getLogger("com.posthogger.Service") + + logger.error("app error", IllegalStateException("not the SDK")) + + val batch = takeBatch() + assertNotNull(batch, "com.posthogger.* is not an SDK logger and must be captured") + assertEquals("com.posthogger.Service", batch.eventProperties("\$exception")["logger_name"]) + } + + @Test + fun `events without a throwable are ignored`() { + val logger = setup() + + logger.error("no throwable here") + + assertNull(mockServer?.takeRequest(2, TimeUnit.SECONDS), "Message-only events must be ignored in v1") + } + + @Test + fun `events before a client is registered are dropped`() { + val logger = setup(register = false) + + logger.error("early", IllegalStateException("too soon")) + + assertNull(mockServer?.takeRequest(2, TimeUnit.SECONDS), "Events before registration must be dropped") + } + + @Test + fun `the same throwable logged twice is captured once`() { + val logger = setup() + val boom = IllegalStateException("only once") + + logger.error("first", boom) + logger.error("second", boom) + + val first = takeBatch() + assertNotNull(first, "Expected the first capture") + assertNotNull(first.findEvent("\$exception")) + + // The second log of the identical instance is deduped ⇒ no further request. + assertNull(mockServer?.takeRequest(2, TimeUnit.SECONDS), "The same throwable instance must be captured once") + } + + @Test + fun `each self-configured appender sends through its own client`() { + // Two appenders configured for different projects must not cross-route: whoever started last + // owns the process-wide fallback slot, but each appender captures through its own client. + val firstServer = MockWebServer() + firstServer.enqueue(MockResponse().setResponseCode(200)) + firstServer.start() + val secondServer = MockWebServer() + secondServer.enqueue(MockResponse().setResponseCode(200)) + secondServer.start() + + val context = LoggerContext() + loggerContext = context + + fun appenderFor(server: MockWebServer) = + PostHogAppender().apply { + this.context = context + this.apiKey = "test-api-key" + this.host = server.url("/").toString() + start() + } + + val first = appenderFor(firstServer) + val second = appenderFor(secondServer) + + val logger = context.getLogger("com.example.Routed") + logger.level = Level.TRACE + logger.addAppender(first) + logger.isAdditive = false + + logger.error("routed", IllegalStateException("first project")) + + // Self-configured clients use the SDK's default buffering, so wait out one flush interval. + val request = firstServer.takeRequest(15, TimeUnit.SECONDS) + assertNotNull(request, "The event must go to the appender's own client") + assertNotNull(BatchRequest(request, gson).findEvent("\$exception")) + assertNull(secondServer.takeRequest(2, TimeUnit.SECONDS), "The other project must receive nothing") + + first.stop() + second.stop() + firstServer.shutdown() + secondServer.shutdown() + } + + @Test + fun `a self-configured appender keeps capturing after an earlier one stops`() { + // Logback config reload order: the replacement appender starts before the old one stops. Each + // self-configuring appender must own its own client, or the old one's stop() would take the + // shared client down and silently mute the replacement. + val server = MockWebServer() + server.enqueue(MockResponse().setResponseCode(200)) + server.start() + mockServer = server + + val context = LoggerContext() + loggerContext = context + + fun selfConfiguredAppender() = + PostHogAppender().apply { + this.context = context + this.apiKey = "test-api-key" + this.host = server.url("/").toString() + start() + } + + val old = selfConfiguredAppender() + val replacement = selfConfiguredAppender() + old.stop() + + val logger = context.getLogger("com.example.Reloaded") + logger.level = Level.TRACE + logger.addAppender(replacement) + logger.isAdditive = false + + logger.error("after reload", IllegalStateException("still captured")) + + // Self-configured clients use the SDK's default buffering, so wait out one flush interval + // instead of relying on flushAt=1 like the other tests. + val request = server.takeRequest(15, TimeUnit.SECONDS) + assertNotNull(request, "The replacement appender must still capture after the old one stops") + assertNotNull(BatchRequest(request, gson).findEvent("\$exception")) + + replacement.stop() + } + + /** Minimal gzip + JSON batch parser (the server module's test utils are not on this classpath). */ + private class BatchRequest(request: RecordedRequest, private val gson: Gson) { + private val batch: List + + init { + val body = + GzipSource(request.body).use { source -> + source.buffer().use { it.readUtf8() } + } + val json = gson.fromJson(body, JsonObject::class.java) + batch = json.getAsJsonArray("batch")?.map { it.asJsonObject } ?: emptyList() + } + + fun findEvent(eventName: String): JsonObject? = batch.find { it.get("event")?.asString == eventName } + + fun eventProperties(eventName: String): Map { + val props = findEvent(eventName)?.getAsJsonObject("properties") ?: return emptyMap() + return gson.fromJson(props, object : TypeToken>() {}.type) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9ccdc7f6d..b4c8284cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ include(":posthog") include(":posthog-android") include(":posthog-android-surveys-compose") include(":posthog-server") +include(":posthog-server-logback") // samples include(":posthog-samples:posthog-android-sample") From 4dced542f90fb174a43fb5afecb3e891569f69f7 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Sat, 8 Aug 2026 01:16:30 +0300 Subject: [PATCH 5/5] fix(logback): lock lint configurations for posthog-server-logback --- posthog-server-logback/gradle.lockfile | 76 +++++++++++++++----------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/posthog-server-logback/gradle.lockfile b/posthog-server-logback/gradle.lockfile index 219720402..f980b91de 100644 --- a/posthog-server-logback/gradle.lockfile +++ b/posthog-server-logback/gradle.lockfile @@ -1,8 +1,9 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -ch.qos.logback:logback-classic:1.3.14=compileClasspath,compileOnlyDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -ch.qos.logback:logback-core:1.3.14=compileClasspath,compileOnlyDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +# To regenerate this file, run: ./gradlew :posthog-server-logback:dependencies --write-locks +ch.qos.logback:logback-classic:1.3.14=compileClasspath,compileOnlyDependenciesMetadata,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +ch.qos.logback:logback-core:1.3.14=compileClasspath,compileOnlyDependenciesMetadata,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.jackson.core:jackson-core:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.jackson.core:jackson-databind:2.12.7.1=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime @@ -11,63 +12,72 @@ com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.12.7=dokkaGfmPart com.fasterxml.jackson.module:jackson-module-kotlin:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.jackson:jackson-bom:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.woodstox:woodstox-core:6.2.4=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime -com.google.code.gson:gson:2.10.1=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -com.squareup.okhttp3:mockwebserver:4.12.0=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -com.squareup.okhttp3:okhttp-bom:4.12.0=runtimeClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp:4.12.0=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.6.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okio:okio:3.6.0=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.google.code.gson:gson:2.10.1=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.squareup.okhttp3:mockwebserver:4.12.0=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.squareup.okhttp3:okhttp-bom:4.12.0=jvmTestRuntimeClasspathForLint,runtimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath it.unimi.dsi:fastutil-core:8.5.12=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin jakarta.activation:jakarta.activation-api:1.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime -junit:junit:4.13.2=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +junit:junit:4.13.2=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.codehaus.mojo.signature:java18:1.0=signature org.codehaus.mojo:animal-sniffer-annotations:1.23=compileClasspath,compileOnlyDependenciesMetadata org.codehaus.mojo:animal-sniffer-annotations:1.24=animalsniffer org.codehaus.mojo:animal-sniffer:1.24=animalsniffer org.codehaus.woodstox:stax2-api:4.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime org.freemarker:freemarker:2.3.32=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin -org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath org.jetbrains.dokka:analysis-kotlin-descriptors:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.dokka:analysis-markdown:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.dokka:dokka-base:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.dokka:dokka-core:1.9.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath -org.jetbrains.kotlin:kotlin-build-common:2.1.10=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.1.10=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.1.10=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.1.10=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-daemon-client:2.1.10=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.1.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.1.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.10=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.1.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.1.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.10=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.1.10=kotlinKlibCommonizerClasspath -org.jetbrains.kotlin:kotlin-metadata-jvm:2.1.10=bcv-rt-jvm-cp-resolver +org.jetbrains.kotlin:kotlin-metadata-jvm:2.1.21=bcv-rt-jvm-cp-resolver org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains.kotlin:kotlin-reflect:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime -org.jetbrains.kotlin:kotlin-script-runtime:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath -org.jetbrains.kotlin:kotlin-scripting-common:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest -org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest -org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest -org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-script-runtime:2.1.10=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-common:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime -org.jetbrains.kotlin:kotlin-stdlib-common:2.1.10=runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.1.10=testImplementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-common:2.1.21=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin -org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.10=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.10=apiDependenciesMetadata,implementationDependenciesMetadata,testImplementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21=compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin -org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.10=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.10=apiDependenciesMetadata,implementationDependenciesMetadata,testImplementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.21=compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime -org.jetbrains.kotlin:kotlin-stdlib:2.1.10=apiDependenciesMetadata,bcv-rt-jvm-cp-resolver,compileClasspath,implementationDependenciesMetadata,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-test-junit:2.1.10=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-test:2.1.10=testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.1.10=apiDependenciesMetadata,implementationDependenciesMetadata,kotlinKlibCommonizerClasspath,testImplementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib:2.1.21=bcv-rt-jvm-cp-resolver,compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit:2.1.10=testImplementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-junit:2.1.21=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.1.10=testImplementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-test:2.1.21=jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=kotlinKlibCommonizerClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.kotlinx:kover-jvm-agent:0.9.9=koverJvmAgent,koverJvmReporter -org.jetbrains:annotations:13.0=bcv-rt-jvm-cp-resolver,compileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=bcv-rt-jvm-cp-resolver,compileClasspath,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains:annotations:23.0.0=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime org.jetbrains:markdown-jvm:0.5.2=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains:markdown:0.5.2=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin @@ -75,5 +85,5 @@ org.jsoup:jsoup:1.16.1=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlug org.ow2.asm:asm-tree:9.6=bcv-rt-jvm-cp-resolver org.ow2.asm:asm:9.6=bcv-rt-jvm-cp-resolver org.ow2.asm:asm:9.7=animalsniffer -org.slf4j:slf4j-api:2.0.7=compileClasspath,compileOnlyDependenciesMetadata,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath -empty=annotationProcessor,dokkaPlugin,dokkaRuntime,intransitiveDependenciesMetadata,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,koverExternalArtifacts,koverExternalArtifactsJvm,lintChecks,testAnnotationProcessor,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testIntransitiveDependenciesMetadata,testKotlinScriptDefExtensions +org.slf4j:slf4j-api:2.0.7=compileClasspath,compileOnlyDependenciesMetadata,jvmTestCompileClasspathForLint,jvmTestRuntimeClasspathForLint,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath +empty=annotationProcessor,dokkaPlugin,dokkaRuntime,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,koverExternalArtifacts,koverExternalArtifactsJvm,lintChecks,testAnnotationProcessor,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testIntransitiveDependenciesMetadata,testKotlinScriptDefExtensions