From bf6be0023b8d195eb281dfbaefd4b8633c91abe7 Mon Sep 17 00:00:00 2001 From: Igor Macedo Quintanilha Date: Wed, 21 Jan 2026 16:39:31 -0300 Subject: [PATCH 1/4] feat: propagate userProperties and groups in feature flag events & log request headers in debug mode This change addresses two issues: 1. When `getFeatureFlagStateless` calls `captureStateless` for the `$feature_flag_called` event, it now propagates the `personProperties` (as userProperties) and `groups` to the event. Previously these properties were available but not passed through, meaning the feature flag evaluation context was lost in the captured event. 2. In debug mode, the HTTP API now logs all request headers that are being sent. This helps developers troubleshoot issues by seeing exactly what headers are included in requests (User-Agent, Content-Type, Authorization, etc.). --- .../main/java/com/posthog/PostHogStateless.kt | 11 +- .../java/com/posthog/internal/PostHogApi.kt | 22 +++ .../java/com/posthog/PostHogStatelessTest.kt | 116 ++++++++++++++++ .../com/posthog/internal/PostHogApiTest.kt | 130 ++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index 5628b166..ae2cba28 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -454,7 +454,16 @@ public open class PostHogStateless protected constructor( groupProperties, )?.let { props["\$feature_flag_error"] = it } - captureStateless(PostHogEventName.FEATURE_FLAG_CALLED.event, distinctId, properties = props) + val userProps = personProperties + ?.filterValues { it != null } + ?.mapValues { it.value!! } + captureStateless( + PostHogEventName.FEATURE_FLAG_CALLED.event, + distinctId, + properties = props, + userProperties = userProps, + groups = groups, + ) } } } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 7e207a48..9972bea1 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -63,6 +63,8 @@ public class PostHogApi( config.serializer.serialize(batch, it.bufferedWriter()) } + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -85,6 +87,8 @@ public class PostHogApi( config.serializer.serialize(events, it.bufferedWriter()) } + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -141,6 +145,8 @@ public class PostHogApi( config.serializer.serialize(flagsRequest, it.bufferedWriter()) } + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -177,6 +183,8 @@ public class PostHogApi( .get() .build() + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -223,6 +231,8 @@ public class PostHogApi( val request = requestBuilder.get().build() + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -290,4 +300,16 @@ public class PostHogApi( } } } + + private fun logRequestHeaders(request: Request) { + if (config.debug) { + try { + val headers = request.headers + val headerStrings = headers.names().map { name -> "$name: ${headers[name]}" } + config.logger.log("Request headers for ${request.url}: ${headerStrings.joinToString(", ")}") + } catch (e: Throwable) { + // ignore + } + } + } } diff --git a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt index dea8871c..b3561792 100644 --- a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt @@ -811,6 +811,122 @@ internal class PostHogStatelessTest { assertEquals(true, event.properties!!["\$feature_flag_response"]) } + @Test + fun `feature flag called events propagate userProperties and groups`() { + val mockQueue = MockQueue() + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", "variant_a") + + sut = createStatelessInstance() + config = createConfig(sendFeatureFlagEvent = true) + + sut.setup(config) + sut.setMockQueue(mockQueue) + sut.setMockFeatureFlags(mockFeatureFlags) + + val groups = mapOf("organization" to "org_123") + val personProperties = mapOf("plan" to "premium", "role" to "admin") + + // Access feature flag with groups and person properties + sut.getFeatureFlagStateless( + "user123", + "test_flag", + null, + groups, + personProperties, + null, + ) + + // Should generate feature flag called event with propagated properties + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("\$feature_flag_called", event.event) + assertEquals("user123", event.distinctId) + assertEquals("test_flag", event.properties!!["\$feature_flag"]) + assertEquals("variant_a", event.properties!!["\$feature_flag_response"]) + + // Check that groups are propagated + assertEquals(mapOf("organization" to "org_123"), event.properties!!["\$groups"]) + + // Check that userProperties are propagated (as $set) + @Suppress("UNCHECKED_CAST") + val setProps = event.properties!!["\$set"] as? Map + assertNotNull(setProps) + assertEquals("premium", setProps["plan"]) + assertEquals("admin", setProps["role"]) + } + + @Test + fun `feature flag called events filter out null values from personProperties`() { + val mockQueue = MockQueue() + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", true) + + sut = createStatelessInstance() + config = createConfig(sendFeatureFlagEvent = true) + + sut.setup(config) + sut.setMockQueue(mockQueue) + sut.setMockFeatureFlags(mockFeatureFlags) + + val personProperties = mapOf("plan" to "premium", "nullable" to null) + + // Access feature flag with person properties containing null + sut.isFeatureEnabledStateless( + "user123", + "test_flag", + false, + null, + personProperties, + null, + ) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + + // Check that userProperties are propagated without null values + @Suppress("UNCHECKED_CAST") + val setProps = event.properties!!["\$set"] as? Map + assertNotNull(setProps) + assertEquals("premium", setProps["plan"]) + assertFalse(setProps.containsKey("nullable")) + } + + @Test + fun `feature flag called events handle null personProperties gracefully`() { + val mockQueue = MockQueue() + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", true) + + sut = createStatelessInstance() + config = createConfig(sendFeatureFlagEvent = true) + + sut.setup(config) + sut.setMockQueue(mockQueue) + sut.setMockFeatureFlags(mockFeatureFlags) + + val groups = mapOf("organization" to "org_123") + + // Access feature flag with null person properties + sut.isFeatureEnabledStateless( + "user123", + "test_flag", + false, + groups, + null, + null, + ) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + + // Check that groups are still propagated + assertEquals(mapOf("organization" to "org_123"), event.properties!!["\$groups"]) + + // Check that $set is not present when personProperties is null + assertNull(event.properties!!["\$set"]) + } + @Test fun `feature flag called events not sent when disabled`() { val mockQueue = MockQueue() diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index 0f85fa98..ec8d09c4 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -20,12 +20,28 @@ import kotlin.test.assertNull import kotlin.test.assertTrue internal class PostHogApiTest { + private class TestLogger : PostHogLogger { + val messages = mutableListOf() + + override fun log(message: String) { + messages.add(message) + } + + override fun isEnabled(): Boolean = true + } + private fun getSut( host: String, proxy: Proxy? = null, + debug: Boolean = false, + logger: PostHogLogger? = null, ): PostHogApi { val config = PostHogConfig(API_KEY, host) config.proxy = proxy + config.debug = debug + if (logger != null) { + config.logger = logger + } return PostHogApi(config) } @@ -310,4 +326,118 @@ internal class PostHogApiTest { } assertEquals(401, exc.statusCode) } + + // Debug Header Logging Tests + + @Test + fun `batch logs request headers in debug mode`() { + val http = mockHttp() + val url = http.url("/") + val logger = TestLogger() + + val sut = getSut(host = url.toString(), debug = true, logger = logger) + + val event = generateEvent() + sut.batch(listOf(event)) + + assertTrue( + logger.messages.any { it.contains("Request headers for") && it.contains("/batch") }, + "Should log request headers for /batch endpoint", + ) + assertTrue( + logger.messages.any { it.contains("User-Agent:") }, + "Should include User-Agent header in log", + ) + } + + @Test + fun `batch does not log headers when debug is disabled`() { + val http = mockHttp() + val url = http.url("/") + val logger = TestLogger() + + val sut = getSut(host = url.toString(), debug = false, logger = logger) + + val event = generateEvent() + sut.batch(listOf(event)) + + assertFalse( + logger.messages.any { it.contains("Request headers for") }, + "Should not log request headers when debug is disabled", + ) + } + + @Test + fun `flags logs request headers in debug mode`() { + val file = File("src/test/resources/json/flags-v1/basic-flags-no-errors.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + val logger = TestLogger() + + val sut = getSut(host = url.toString(), debug = true, logger = logger) + + sut.flags("distinctId", anonymousId = "anonId", emptyMap()) + + assertTrue( + logger.messages.any { it.contains("Request headers for") && it.contains("/flags") }, + "Should log request headers for /flags endpoint", + ) + } + + @Test + fun `localEvaluation logs request headers including Authorization in debug mode`() { + val http = + mockHttp( + response = + MockResponse() + .setBody(createLocalEvaluationJson()) + .setHeader("ETag", "\"test-etag\""), + ) + val url = http.url("/") + val logger = TestLogger() + + val sut = getSut(host = url.toString(), debug = true, logger = logger) + + sut.localEvaluation("test-personal-key") + + assertTrue( + logger.messages.any { it.contains("Request headers for") && it.contains("/local_evaluation") }, + "Should log request headers for /local_evaluation endpoint", + ) + assertTrue( + logger.messages.any { it.contains("Authorization:") }, + "Should include Authorization header in log", + ) + } + + @Test + fun `remoteConfig logs request headers in debug mode`() { + val file = File("src/test/resources/json/basic-remote-config.json") + val responseApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseApi), + ) + val url = http.url("/") + val logger = TestLogger() + + val sut = getSut(host = url.toString(), debug = true, logger = logger) + + sut.remoteConfig() + + assertTrue( + logger.messages.any { it.contains("Request headers for") && it.contains("/array/") }, + "Should log request headers for /array/ (remoteConfig) endpoint", + ) + } } From 1515dc2b9da8f4de4b92ffffdf5d07b2ad30cce3 Mon Sep 17 00:00:00 2001 From: Igor Macedo Quintanilha Date: Wed, 21 Jan 2026 16:50:52 -0300 Subject: [PATCH 2/4] feat: add evaluationContexts support to server PostHogConfig The server module's PostHogConfig was missing the evaluationContexts property that exists in the core PostHogConfig. This meant that even when users set evaluationContexts in their server config, it wasn't being propagated to the core config and therefore wasn't sent in /flags API requests. This change: - Adds evaluationContexts property to server PostHogConfig constructor - Propagates evaluationContexts to core config in asCoreConfig() - Adds evaluationContexts() method to the Builder class - Adds comprehensive tests for all the above --- .../java/com/posthog/server/PostHogConfig.kt | 15 ++++ .../com/posthog/server/PostHogConfigTest.kt | 70 +++++++++++++++++ .../java/com/posthog/server/PostHogTest.kt | 75 +++++++++++++++++++ 3 files changed, 160 insertions(+) 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 f6640493..ba7c8442 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -126,6 +126,14 @@ public open class PostHogConfig constructor( * Defaults to 30 seconds */ public var pollIntervalSeconds: Int = DEFAULT_POLL_INTERVAL_SECONDS, + /** + * Optional list of evaluation context tags for feature flag evaluation + * When specified, only feature flags that have at least one matching evaluation tag will be evaluated + * Feature flags with no evaluation tags will always be evaluated for backward compatibility + * Example: listOf("production", "web", "checkout") + * Defaults to null (evaluate all flags) + */ + public var evaluationContexts: List? = null, ) { private val beforeSendCallbacks = mutableListOf() private val integrations = mutableListOf() @@ -180,6 +188,9 @@ public open class PostHogConfig constructor( beforeSendCallbacks.forEach { coreConfig.addBeforeSend(it) } integrations.forEach { coreConfig.addIntegration(it) } + // Set evaluation contexts + coreConfig.evaluationContexts = evaluationContexts + // Set SDK identification coreConfig.sdkName = BuildConfig.SDK_NAME coreConfig.sdkVersion = BuildConfig.VERSION_NAME @@ -230,6 +241,7 @@ public open class PostHogConfig constructor( private var localEvaluation: Boolean? = null private var personalApiKey: String? = null private var pollIntervalSeconds: Int = DEFAULT_POLL_INTERVAL_SECONDS + private var evaluationContexts: List? = null public fun host(host: String): Builder = apply { this.host = host } @@ -275,6 +287,8 @@ public open class PostHogConfig constructor( public fun pollIntervalSeconds(pollIntervalSeconds: Int): Builder = apply { this.pollIntervalSeconds = pollIntervalSeconds } + public fun evaluationContexts(evaluationContexts: List?): Builder = apply { this.evaluationContexts = evaluationContexts } + public fun build(): PostHogConfig = PostHogConfig( apiKey = apiKey, @@ -296,6 +310,7 @@ public open class PostHogConfig constructor( localEvaluation = localEvaluation ?: false, personalApiKey = personalApiKey, pollIntervalSeconds = pollIntervalSeconds, + evaluationContexts = evaluationContexts, ) } } 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 2beccfaf..00a7d067 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -510,4 +510,74 @@ internal class PostHogConfigTest { assertEquals("test-personal-api-key", config.personalApiKey) assertEquals(false, config.localEvaluation) } + + @Test + fun `constructor sets evaluationContexts when provided`() { + val contexts = listOf("production", "web", "checkout") + val config = PostHogConfig(apiKey = TEST_API_KEY, evaluationContexts = contexts) + + assertEquals(contexts, config.evaluationContexts) + } + + @Test + fun `constructor defaults evaluationContexts to null`() { + val config = PostHogConfig(apiKey = TEST_API_KEY) + + assertNull(config.evaluationContexts) + } + + @Test + fun `asCoreConfig propagates evaluationContexts to core config`() { + val contexts = listOf("production", "web", "checkout") + val config = PostHogConfig(apiKey = TEST_API_KEY, evaluationContexts = contexts) + + val coreConfig = config.asCoreConfig() + + assertEquals(contexts, coreConfig.evaluationContexts) + } + + @Test + fun `asCoreConfig propagates null evaluationContexts to core config`() { + val config = PostHogConfig(apiKey = TEST_API_KEY) + + val coreConfig = config.asCoreConfig() + + assertNull(coreConfig.evaluationContexts) + } + + @Test + fun `builder evaluationContexts method sets value and returns builder`() { + val contexts = listOf("production", "web") + val builder = PostHogConfig.builder(TEST_API_KEY) + val result = builder.evaluationContexts(contexts) + assertEquals(builder, result) + + val config = builder.build() + assertEquals(contexts, config.evaluationContexts) + } + + @Test + fun `builder evaluationContexts method allows null value`() { + val config = + PostHogConfig.builder(TEST_API_KEY) + .evaluationContexts(null) + .build() + + assertNull(config.evaluationContexts) + } + + @Test + fun `builder allows method chaining with evaluationContexts`() { + val contexts = listOf("staging", "mobile") + val config = + PostHogConfig.builder(TEST_API_KEY) + .host("https://custom.host.com") + .evaluationContexts(contexts) + .debug(true) + .build() + + assertEquals("https://custom.host.com", config.host) + assertEquals(contexts, config.evaluationContexts) + assertEquals(true, config.debug) + } } 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 f877937f..09091e56 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt @@ -600,4 +600,79 @@ internal class PostHogTest { postHog.close() mockServer.shutdown() } + + @Test + fun `evaluationContexts is sent in flags request when configured`() { + val mockServer = MockWebServer() + // First request: /flags returns success + mockServer.enqueue(jsonResponse(createFlagsResponse("test-flag", enabled = true))) + mockServer.start() + + val url = mockServer.url("/").toString() + val contexts = listOf("production", "web", "checkout") + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(url) + .evaluationContexts(contexts) + .preloadFeatureFlags(false) + .sendFeatureFlagEvent(false) + .build(), + ) + + // This will hit /flags + postHog.getFeatureFlag("user123", "test-flag") + + // First request should be /flags + val flagsRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(flagsRequest, "Expected /flags request") + assertTrue(flagsRequest.path?.contains("/flags") == true, "Request should be /flags") + + // Parse the request body + val bodyString = flagsRequest.body.unGzip() + val gson = com.google.gson.Gson() + val requestBody = gson.fromJson(bodyString, Map::class.java) + + @Suppress("UNCHECKED_CAST") + val sentContexts = requestBody["evaluation_contexts"] as? List + assertEquals(contexts, sentContexts, "evaluation_contexts should be sent in /flags request") + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `evaluationContexts is not sent when not configured`() { + val mockServer = MockWebServer() + // First request: /flags returns success + mockServer.enqueue(jsonResponse(createFlagsResponse("test-flag", enabled = true))) + mockServer.start() + + val url = mockServer.url("/").toString() + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(url) + .preloadFeatureFlags(false) + .sendFeatureFlagEvent(false) + .build(), + ) + + // This will hit /flags + postHog.getFeatureFlag("user123", "test-flag") + + // First request should be /flags + val flagsRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(flagsRequest, "Expected /flags request") + + // Parse the request body + val bodyString = flagsRequest.body.unGzip() + val gson = com.google.gson.Gson() + val requestBody = gson.fromJson(bodyString, Map::class.java) + + assertFalse(requestBody.containsKey("evaluation_contexts"), "evaluation_contexts should not be sent when not configured") + + postHog.close() + mockServer.shutdown() + } } From 0a8beebebe15fe6065265529a77342450b1880f3 Mon Sep 17 00:00:00 2001 From: Igor Macedo Quintanilha Date: Wed, 21 Jan 2026 17:03:12 -0300 Subject: [PATCH 3/4] refactor: simplify tests for better clarity and reduced redundancy --- .../com/posthog/server/PostHogConfigTest.kt | 70 +++--------- .../java/com/posthog/server/PostHogTest.kt | 38 ++----- .../java/com/posthog/PostHogStatelessTest.kt | 79 ++------------ .../com/posthog/internal/PostHogApiTest.kt | 101 ++---------------- 4 files changed, 40 insertions(+), 248 deletions(-) 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 00a7d067..e938158a 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -512,72 +512,32 @@ internal class PostHogConfigTest { } @Test - fun `constructor sets evaluationContexts when provided`() { - val contexts = listOf("production", "web", "checkout") - val config = PostHogConfig(apiKey = TEST_API_KEY, evaluationContexts = contexts) - - assertEquals(contexts, config.evaluationContexts) - } - - @Test - fun `constructor defaults evaluationContexts to null`() { - val config = PostHogConfig(apiKey = TEST_API_KEY) + fun `evaluationContexts defaults to null and can be set via constructor`() { + val defaultConfig = PostHogConfig(apiKey = TEST_API_KEY) + assertNull(defaultConfig.evaluationContexts) - assertNull(config.evaluationContexts) + val contexts = listOf("production", "web") + val configWithContexts = PostHogConfig(apiKey = TEST_API_KEY, evaluationContexts = contexts) + assertEquals(contexts, configWithContexts.evaluationContexts) } @Test - fun `asCoreConfig propagates evaluationContexts to core config`() { - val contexts = listOf("production", "web", "checkout") + fun `asCoreConfig propagates evaluationContexts`() { + val contexts = listOf("production", "web") val config = PostHogConfig(apiKey = TEST_API_KEY, evaluationContexts = contexts) + assertEquals(contexts, config.asCoreConfig().evaluationContexts) - val coreConfig = config.asCoreConfig() - - assertEquals(contexts, coreConfig.evaluationContexts) - } - - @Test - fun `asCoreConfig propagates null evaluationContexts to core config`() { - val config = PostHogConfig(apiKey = TEST_API_KEY) - - val coreConfig = config.asCoreConfig() - - assertNull(coreConfig.evaluationContexts) + val nullConfig = PostHogConfig(apiKey = TEST_API_KEY) + assertNull(nullConfig.asCoreConfig().evaluationContexts) } @Test - fun `builder evaluationContexts method sets value and returns builder`() { + fun `builder evaluationContexts method sets value`() { val contexts = listOf("production", "web") - val builder = PostHogConfig.builder(TEST_API_KEY) - val result = builder.evaluationContexts(contexts) - assertEquals(builder, result) - - val config = builder.build() + val config = PostHogConfig.builder(TEST_API_KEY).evaluationContexts(contexts).build() assertEquals(contexts, config.evaluationContexts) - } - - @Test - fun `builder evaluationContexts method allows null value`() { - val config = - PostHogConfig.builder(TEST_API_KEY) - .evaluationContexts(null) - .build() - - assertNull(config.evaluationContexts) - } - @Test - fun `builder allows method chaining with evaluationContexts`() { - val contexts = listOf("staging", "mobile") - val config = - PostHogConfig.builder(TEST_API_KEY) - .host("https://custom.host.com") - .evaluationContexts(contexts) - .debug(true) - .build() - - assertEquals("https://custom.host.com", config.host) - assertEquals(contexts, config.evaluationContexts) - assertEquals(true, config.debug) + val nullConfig = PostHogConfig.builder(TEST_API_KEY).evaluationContexts(null).build() + assertNull(nullConfig.evaluationContexts) } } 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 09091e56..c6eacf5a 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt @@ -604,38 +604,28 @@ internal class PostHogTest { @Test fun `evaluationContexts is sent in flags request when configured`() { val mockServer = MockWebServer() - // First request: /flags returns success mockServer.enqueue(jsonResponse(createFlagsResponse("test-flag", enabled = true))) mockServer.start() - val url = mockServer.url("/").toString() val contexts = listOf("production", "web", "checkout") val postHog = PostHog.with( PostHogConfig.builder(TEST_API_KEY) - .host(url) + .host(mockServer.url("/").toString()) .evaluationContexts(contexts) .preloadFeatureFlags(false) .sendFeatureFlagEvent(false) .build(), ) - // This will hit /flags postHog.getFeatureFlag("user123", "test-flag") - // First request should be /flags - val flagsRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) - assertNotNull(flagsRequest, "Expected /flags request") - assertTrue(flagsRequest.path?.contains("/flags") == true, "Request should be /flags") - - // Parse the request body - val bodyString = flagsRequest.body.unGzip() - val gson = com.google.gson.Gson() - val requestBody = gson.fromJson(bodyString, Map::class.java) + val request = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(request) + val requestBody = com.google.gson.Gson().fromJson(request.body.unGzip(), Map::class.java) @Suppress("UNCHECKED_CAST") - val sentContexts = requestBody["evaluation_contexts"] as? List - assertEquals(contexts, sentContexts, "evaluation_contexts should be sent in /flags request") + assertEquals(contexts, requestBody["evaluation_contexts"] as? List) postHog.close() mockServer.shutdown() @@ -644,33 +634,25 @@ internal class PostHogTest { @Test fun `evaluationContexts is not sent when not configured`() { val mockServer = MockWebServer() - // First request: /flags returns success mockServer.enqueue(jsonResponse(createFlagsResponse("test-flag", enabled = true))) mockServer.start() - val url = mockServer.url("/").toString() val postHog = PostHog.with( PostHogConfig.builder(TEST_API_KEY) - .host(url) + .host(mockServer.url("/").toString()) .preloadFeatureFlags(false) .sendFeatureFlagEvent(false) .build(), ) - // This will hit /flags postHog.getFeatureFlag("user123", "test-flag") - // First request should be /flags - val flagsRequest = mockServer.takeRequest(5, TimeUnit.SECONDS) - assertNotNull(flagsRequest, "Expected /flags request") - - // Parse the request body - val bodyString = flagsRequest.body.unGzip() - val gson = com.google.gson.Gson() - val requestBody = gson.fromJson(bodyString, Map::class.java) + val request = mockServer.takeRequest(5, TimeUnit.SECONDS) + assertNotNull(request) + val requestBody = com.google.gson.Gson().fromJson(request.body.unGzip(), Map::class.java) - assertFalse(requestBody.containsKey("evaluation_contexts"), "evaluation_contexts should not be sent when not configured") + assertFalse(requestBody.containsKey("evaluation_contexts")) postHog.close() mockServer.shutdown() diff --git a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt index b3561792..fcd5614d 100644 --- a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt @@ -812,7 +812,7 @@ internal class PostHogStatelessTest { } @Test - fun `feature flag called events propagate userProperties and groups`() { + fun `feature flag called events propagate groups and personProperties filtering nulls`() { val mockQueue = MockQueue() val mockFeatureFlags = MockFeatureFlags() mockFeatureFlags.setFlag("test_flag", "variant_a") @@ -825,75 +825,25 @@ internal class PostHogStatelessTest { sut.setMockFeatureFlags(mockFeatureFlags) val groups = mapOf("organization" to "org_123") - val personProperties = mapOf("plan" to "premium", "role" to "admin") + val personProperties = mapOf("plan" to "premium", "nullable" to null) - // Access feature flag with groups and person properties - sut.getFeatureFlagStateless( - "user123", - "test_flag", - null, - groups, - personProperties, - null, - ) + sut.getFeatureFlagStateless("user123", "test_flag", null, groups, personProperties, null) - // Should generate feature flag called event with propagated properties assertEquals(1, mockQueue.events.size) val event = mockQueue.events.first() assertEquals("\$feature_flag_called", event.event) - assertEquals("user123", event.distinctId) assertEquals("test_flag", event.properties!!["\$feature_flag"]) assertEquals("variant_a", event.properties!!["\$feature_flag_response"]) - - // Check that groups are propagated assertEquals(mapOf("organization" to "org_123"), event.properties!!["\$groups"]) - // Check that userProperties are propagated (as $set) @Suppress("UNCHECKED_CAST") - val setProps = event.properties!!["\$set"] as? Map - assertNotNull(setProps) - assertEquals("premium", setProps["plan"]) - assertEquals("admin", setProps["role"]) - } - - @Test - fun `feature flag called events filter out null values from personProperties`() { - val mockQueue = MockQueue() - val mockFeatureFlags = MockFeatureFlags() - mockFeatureFlags.setFlag("test_flag", true) - - sut = createStatelessInstance() - config = createConfig(sendFeatureFlagEvent = true) - - sut.setup(config) - sut.setMockQueue(mockQueue) - sut.setMockFeatureFlags(mockFeatureFlags) - - val personProperties = mapOf("plan" to "premium", "nullable" to null) - - // Access feature flag with person properties containing null - sut.isFeatureEnabledStateless( - "user123", - "test_flag", - false, - null, - personProperties, - null, - ) - - assertEquals(1, mockQueue.events.size) - val event = mockQueue.events.first() - - // Check that userProperties are propagated without null values - @Suppress("UNCHECKED_CAST") - val setProps = event.properties!!["\$set"] as? Map - assertNotNull(setProps) + val setProps = event.properties!!["\$set"] as Map assertEquals("premium", setProps["plan"]) assertFalse(setProps.containsKey("nullable")) } @Test - fun `feature flag called events handle null personProperties gracefully`() { + fun `feature flag called events omit set when personProperties is null`() { val mockQueue = MockQueue() val mockFeatureFlags = MockFeatureFlags() mockFeatureFlags.setFlag("test_flag", true) @@ -905,25 +855,10 @@ internal class PostHogStatelessTest { sut.setMockQueue(mockQueue) sut.setMockFeatureFlags(mockFeatureFlags) - val groups = mapOf("organization" to "org_123") + sut.isFeatureEnabledStateless("user123", "test_flag", false, mapOf("org" to "123"), null, null) - // Access feature flag with null person properties - sut.isFeatureEnabledStateless( - "user123", - "test_flag", - false, - groups, - null, - null, - ) - - assertEquals(1, mockQueue.events.size) val event = mockQueue.events.first() - - // Check that groups are still propagated - assertEquals(mapOf("organization" to "org_123"), event.properties!!["\$groups"]) - - // Check that $set is not present when personProperties is null + assertEquals(mapOf("org" to "123"), event.properties!!["\$groups"]) assertNull(event.properties!!["\$set"]) } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index ec8d09c4..0aad1eaa 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -330,114 +330,29 @@ internal class PostHogApiTest { // Debug Header Logging Tests @Test - fun `batch logs request headers in debug mode`() { + fun `logs request headers in debug mode`() { val http = mockHttp() val url = http.url("/") val logger = TestLogger() val sut = getSut(host = url.toString(), debug = true, logger = logger) - val event = generateEvent() - sut.batch(listOf(event)) - - assertTrue( - logger.messages.any { it.contains("Request headers for") && it.contains("/batch") }, - "Should log request headers for /batch endpoint", - ) - assertTrue( - logger.messages.any { it.contains("User-Agent:") }, - "Should include User-Agent header in log", - ) + sut.batch(listOf(generateEvent())) + + assertTrue(logger.messages.any { it.contains("Request headers for") && it.contains("/batch") }) + assertTrue(logger.messages.any { it.contains("User-Agent:") }) } @Test - fun `batch does not log headers when debug is disabled`() { + fun `does not log headers when debug is disabled`() { val http = mockHttp() val url = http.url("/") val logger = TestLogger() val sut = getSut(host = url.toString(), debug = false, logger = logger) - val event = generateEvent() - sut.batch(listOf(event)) - - assertFalse( - logger.messages.any { it.contains("Request headers for") }, - "Should not log request headers when debug is disabled", - ) - } - - @Test - fun `flags logs request headers in debug mode`() { - val file = File("src/test/resources/json/flags-v1/basic-flags-no-errors.json") - val responseFlagsApi = file.readText() - - val http = - mockHttp( - response = - MockResponse() - .setBody(responseFlagsApi), - ) - val url = http.url("/") - val logger = TestLogger() - - val sut = getSut(host = url.toString(), debug = true, logger = logger) - - sut.flags("distinctId", anonymousId = "anonId", emptyMap()) - - assertTrue( - logger.messages.any { it.contains("Request headers for") && it.contains("/flags") }, - "Should log request headers for /flags endpoint", - ) - } - - @Test - fun `localEvaluation logs request headers including Authorization in debug mode`() { - val http = - mockHttp( - response = - MockResponse() - .setBody(createLocalEvaluationJson()) - .setHeader("ETag", "\"test-etag\""), - ) - val url = http.url("/") - val logger = TestLogger() - - val sut = getSut(host = url.toString(), debug = true, logger = logger) - - sut.localEvaluation("test-personal-key") - - assertTrue( - logger.messages.any { it.contains("Request headers for") && it.contains("/local_evaluation") }, - "Should log request headers for /local_evaluation endpoint", - ) - assertTrue( - logger.messages.any { it.contains("Authorization:") }, - "Should include Authorization header in log", - ) - } - - @Test - fun `remoteConfig logs request headers in debug mode`() { - val file = File("src/test/resources/json/basic-remote-config.json") - val responseApi = file.readText() - - val http = - mockHttp( - response = - MockResponse() - .setBody(responseApi), - ) - val url = http.url("/") - val logger = TestLogger() - - val sut = getSut(host = url.toString(), debug = true, logger = logger) - - sut.remoteConfig() + sut.batch(listOf(generateEvent())) - assertTrue( - logger.messages.any { it.contains("Request headers for") && it.contains("/array/") }, - "Should log request headers for /array/ (remoteConfig) endpoint", - ) + assertFalse(logger.messages.any { it.contains("Request headers for") }) } } From 76e6f3c8f89a5f872bc8095ab7fe805b4aba09fd Mon Sep 17 00:00:00 2001 From: Igor Macedo Quintanilha Date: Wed, 21 Jan 2026 17:38:39 -0300 Subject: [PATCH 4/4] fix: use posthog-java/server User-Agent for server-side runtime detection Workaround: PostHog's runtime detection uses User-Agent patterns to determine if requests come from server-side or client-side SDKs. The pattern "posthog-server/" is not recognized, so we use "posthog-java/server/" instead until PostHog adds support for "posthog-server/" in their detection patterns. Also adds $lib and $lib_version to flags requests for SDK identification, and makes userAgent configurable in PostHogConfig. --- .../java/com/posthog/server/PostHogConfig.kt | 3 +++ .../src/main/java/com/posthog/PostHogConfig.kt | 11 +++++++---- .../java/com/posthog/internal/PostHogApi.kt | 18 +++++++++++++++--- .../posthog/internal/PostHogFlagsRequest.kt | 8 ++++++++ .../test/java/com/posthog/PostHogConfigTest.kt | 16 +++++++++++----- 5 files changed, 44 insertions(+), 12 deletions(-) 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 ba7c8442..4e4ebda5 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -194,6 +194,9 @@ public open class PostHogConfig constructor( // Set SDK identification coreConfig.sdkName = BuildConfig.SDK_NAME coreConfig.sdkVersion = BuildConfig.VERSION_NAME + // Workaround: Use "posthog-java/server" for User-Agent for server-side runtime detection + // until PostHog supports "posthog-server/" in their runtime detection patterns. + coreConfig.userAgent = "posthog-java/server/${BuildConfig.VERSION_NAME}" coreConfig.context = PostHogServerContext(coreConfig) return coreConfig diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index 7745034b..679fcad5 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -277,10 +277,13 @@ public open class PostHogConfig( @PostHogInternal public var sdkVersion: String = BuildConfig.VERSION_NAME - internal val userAgent: String - get() { - return "$sdkName/$sdkVersion" - } + // Can be overridden by SDK modules for custom User-Agent header + @PostHogInternal + public var userAgent: String? = null + + internal fun getUserAgent(): String { + return userAgent ?: "$sdkName/$sdkVersion" + } @PostHogInternal public var legacyStoragePrefix: String? = null diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 9972bea1..8fc8cea4 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -113,7 +113,7 @@ public class PostHogApi( return Request.Builder() .url(url) - .header("User-Agent", config.userAgent) + .header("User-Agent", config.getUserAgent()) .post(requestBody) .build() } @@ -135,6 +135,8 @@ public class PostHogApi( personProperties, groupProperties, config.evaluationContexts, + lib = config.sdkName, + libVersion = config.sdkVersion, ) val url = "$theHost/flags/?v=2&config=true" @@ -178,7 +180,7 @@ public class PostHogApi( val request = Request.Builder() .url("$host/array/${config.apiKey}/config") - .header("User-Agent", config.userAgent) + .header("User-Agent", config.getUserAgent()) .header("Content-Type", APP_JSON_UTF_8) .get() .build() @@ -220,7 +222,7 @@ public class PostHogApi( val requestBuilder = Request.Builder() .url(url) - .header("User-Agent", config.userAgent) + .header("User-Agent", config.getUserAgent()) .header("Content-Type", APP_JSON_UTF_8) .header("Authorization", "Bearer $personalApiKey") @@ -266,6 +268,16 @@ public class PostHogApi( private fun logResponse(response: Response): Response { if (config.debug) { try { + // Log response headers + val responseHeaders = response.headers + val responseHeaderStrings = responseHeaders.names().map { name -> "$name: ${responseHeaders[name]}" } + config.logger.log("Response headers for ${response.request.url}: ${responseHeaderStrings.joinToString(", ")}") + + // Log the final request headers (after interceptors like gzip) + val finalRequestHeaders = response.request.headers + val finalRequestHeaderStrings = finalRequestHeaders.names().map { name -> "$name: ${finalRequestHeaders[name]}" } + config.logger.log("Final request headers for ${response.request.url}: ${finalRequestHeaderStrings.joinToString(", ")}") + val responseBody = response.body ?: return response val mediaType = responseBody.contentType() val content = diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt index 45458ae7..f72ea1c6 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt @@ -11,10 +11,18 @@ internal class PostHogFlagsRequest( personProperties: Map? = null, groupProperties: Map>? = null, evaluationContexts: List? = null, + lib: String? = null, + libVersion: String? = null, ) : HashMap() { init { this["api_key"] = apiKey this["distinct_id"] = distinctId + if (!lib.isNullOrBlank()) { + this["\$lib"] = lib + } + if (!libVersion.isNullOrBlank()) { + this["\$lib_version"] = libVersion + } if (!anonymousId.isNullOrBlank()) { this["\$anon_distinct_id"] = anonymousId } diff --git a/posthog/src/test/java/com/posthog/PostHogConfigTest.kt b/posthog/src/test/java/com/posthog/PostHogConfigTest.kt index 82246aeb..0eb4d417 100644 --- a/posthog/src/test/java/com/posthog/PostHogConfigTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogConfigTest.kt @@ -86,14 +86,20 @@ internal class PostHogConfigTest { } @Test - fun `user agent is returned correctly if changed`() { - config.sdkName = "posthog-android" - assertEquals("posthog-android/${BuildConfig.VERSION_NAME}", config.userAgent) + fun `user agent is returned correctly if overridden`() { + config.userAgent = "posthog-android/1.0.0" + assertEquals("posthog-android/1.0.0", config.getUserAgent()) } @Test - fun `user agent is set the java sdk by default`() { - assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", config.userAgent) + fun `user agent falls back to sdkName and sdkVersion by default`() { + assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", config.getUserAgent()) + } + + @Test + fun `user agent falls back to sdkName when userAgent is null`() { + config.sdkName = "posthog-android" + assertEquals("posthog-android/${BuildConfig.VERSION_NAME}", config.getUserAgent()) } @Test