Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = null,
) {
private val beforeSendCallbacks = mutableListOf<PostHogBeforeSend>()
private val integrations = mutableListOf<PostHogIntegration>()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String>? = null

public fun host(host: String): Builder = apply { this.host = host }

Expand Down Expand Up @@ -275,6 +290,8 @@ public open class PostHogConfig constructor(

public fun pollIntervalSeconds(pollIntervalSeconds: Int): Builder = apply { this.pollIntervalSeconds = pollIntervalSeconds }

public fun evaluationContexts(evaluationContexts: List<String>?): Builder = apply { this.evaluationContexts = evaluationContexts }

public fun build(): PostHogConfig =
PostHogConfig(
apiKey = apiKey,
Expand All @@ -296,6 +313,7 @@ public open class PostHogConfig constructor(
localEvaluation = localEvaluation ?: false,
personalApiKey = personalApiKey,
pollIntervalSeconds = pollIntervalSeconds,
evaluationContexts = evaluationContexts,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
57 changes: 57 additions & 0 deletions posthog-server/src/test/java/com/posthog/server/PostHogTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)

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()
}
}
11 changes: 7 additions & 4 deletions posthog/src/main/java/com/posthog/PostHogConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion posthog/src/main/java/com/posthog/PostHogStateless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
}
Expand Down
40 changes: 37 additions & 3 deletions posthog/src/main/java/com/posthog/internal/PostHogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public class PostHogApi(
config.serializer.serialize(batch, it.bufferedWriter())
}

logRequestHeaders(request)

client.newCall(request).execute().use {
val response = logResponse(it)

Expand All @@ -85,6 +87,8 @@ public class PostHogApi(
config.serializer.serialize(events, it.bufferedWriter())
}

logRequestHeaders(request)

client.newCall(request).execute().use {
val response = logResponse(it)

Expand All @@ -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()
}
Expand All @@ -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"
Expand All @@ -141,6 +147,8 @@ public class PostHogApi(
config.serializer.serialize(flagsRequest, it.bufferedWriter())
}

logRequestHeaders(request)

client.newCall(request).execute().use {
val response = logResponse(it)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")

Expand All @@ -223,6 +233,8 @@ public class PostHogApi(

val request = requestBuilder.get().build()

logRequestHeaders(request)

client.newCall(request).execute().use {
val response = logResponse(it)

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ internal class PostHogFlagsRequest(
personProperties: Map<String, Any?>? = null,
groupProperties: Map<String, Map<String, Any?>>? = null,
evaluationContexts: List<String>? = null,
lib: String? = null,
libVersion: String? = null,
) : HashMap<String, Any>() {
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
}
Expand Down
16 changes: 11 additions & 5 deletions posthog/src/test/java/com/posthog/PostHogConfigTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading