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..4e4ebda5 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,9 +188,15 @@ 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 + // 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 @@ -230,6 +244,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 +290,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 +313,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..e938158a 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,34 @@ internal class PostHogConfigTest { assertEquals("test-personal-api-key", config.personalApiKey) assertEquals(false, config.localEvaluation) } + + @Test + fun `evaluationContexts defaults to null and can be set via constructor`() { + val defaultConfig = PostHogConfig(apiKey = TEST_API_KEY) + assertNull(defaultConfig.evaluationContexts) + + val contexts = listOf("production", "web") + val configWithContexts = PostHogConfig(apiKey = TEST_API_KEY, evaluationContexts = contexts) + assertEquals(contexts, configWithContexts.evaluationContexts) + } + + @Test + fun `asCoreConfig propagates evaluationContexts`() { + val contexts = listOf("production", "web") + val config = PostHogConfig(apiKey = TEST_API_KEY, evaluationContexts = contexts) + assertEquals(contexts, config.asCoreConfig().evaluationContexts) + + val nullConfig = PostHogConfig(apiKey = TEST_API_KEY) + assertNull(nullConfig.asCoreConfig().evaluationContexts) + } + + @Test + fun `builder evaluationContexts method sets value`() { + val contexts = listOf("production", "web") + val config = PostHogConfig.builder(TEST_API_KEY).evaluationContexts(contexts).build() + assertEquals(contexts, config.evaluationContexts) + + 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 f877937f..c6eacf5a 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,61 @@ internal class PostHogTest { postHog.close() mockServer.shutdown() } + + @Test + fun `evaluationContexts is sent in flags request when configured`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createFlagsResponse("test-flag", enabled = true))) + mockServer.start() + + val contexts = listOf("production", "web", "checkout") + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .evaluationContexts(contexts) + .preloadFeatureFlags(false) + .sendFeatureFlagEvent(false) + .build(), + ) + + postHog.getFeatureFlag("user123", "test-flag") + + 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") + assertEquals(contexts, requestBody["evaluation_contexts"] as? List) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `evaluationContexts is not sent when not configured`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createFlagsResponse("test-flag", enabled = true))) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .preloadFeatureFlags(false) + .sendFeatureFlagEvent(false) + .build(), + ) + + postHog.getFeatureFlag("user123", "test-flag") + + 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")) + + postHog.close() + mockServer.shutdown() + } } 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/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..8fc8cea4 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) @@ -109,7 +113,7 @@ public class PostHogApi( return Request.Builder() .url(url) - .header("User-Agent", config.userAgent) + .header("User-Agent", config.getUserAgent()) .post(requestBody) .build() } @@ -131,6 +135,8 @@ public class PostHogApi( personProperties, groupProperties, config.evaluationContexts, + lib = config.sdkName, + libVersion = config.sdkVersion, ) val url = "$theHost/flags/?v=2&config=true" @@ -141,6 +147,8 @@ public class PostHogApi( config.serializer.serialize(flagsRequest, it.bufferedWriter()) } + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -172,11 +180,13 @@ 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() + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -212,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") @@ -223,6 +233,8 @@ public class PostHogApi( val request = requestBuilder.get().build() + logRequestHeaders(request) + client.newCall(request).execute().use { val response = logResponse(it) @@ -256,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 = @@ -290,4 +312,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/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 diff --git a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt index dea8871c..fcd5614d 100644 --- a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt @@ -811,6 +811,57 @@ internal class PostHogStatelessTest { assertEquals(true, event.properties!!["\$feature_flag_response"]) } + @Test + fun `feature flag called events propagate groups and personProperties filtering nulls`() { + 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", "nullable" to null) + + sut.getFeatureFlagStateless("user123", "test_flag", null, groups, personProperties, null) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("\$feature_flag_called", event.event) + assertEquals("test_flag", event.properties!!["\$feature_flag"]) + assertEquals("variant_a", event.properties!!["\$feature_flag_response"]) + assertEquals(mapOf("organization" to "org_123"), event.properties!!["\$groups"]) + + @Suppress("UNCHECKED_CAST") + val setProps = event.properties!!["\$set"] as Map + assertEquals("premium", setProps["plan"]) + assertFalse(setProps.containsKey("nullable")) + } + + @Test + fun `feature flag called events omit set when personProperties is null`() { + 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) + + sut.isFeatureEnabledStateless("user123", "test_flag", false, mapOf("org" to "123"), null, null) + + val event = mockQueue.events.first() + assertEquals(mapOf("org" to "123"), event.properties!!["\$groups"]) + 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..0aad1eaa 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,33 @@ internal class PostHogApiTest { } assertEquals(401, exc.statusCode) } + + // Debug Header Logging Tests + + @Test + 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) + + 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 `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) + + sut.batch(listOf(generateEvent())) + + assertFalse(logger.messages.any { it.contains("Request headers for") }) + } }