Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/screen-name-on-all-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"posthog": minor
"posthog-android": minor
---

Auto-attach `$screen_name` to every captured event after `PostHog.screen()` has been called (manually or via Activity-lifecycle auto-capture). Cached value is cleared by `reset()` and `close()`. Closes #119.

**To opt out of `$screen_name` stamping entirely**, set `PostHogAndroidConfig.captureScreenViews = false` **and** stop calling `PostHog.screen()` manually. Disabling `captureScreenViews` alone is not sufficient — a single manual `PostHog.screen("Home")` call will re-enable stamping.

## Behavior changes

These all affect what your events carry on the wire. Review your dashboards/insights/HogQL queries:

- **Cross-event stamping.** `$exception`, `$identify`, `$autocapture`, `$create_alias`, `$groupidentify`, `$feature_flag_called`, custom events, etc. will start carrying `$screen_name` whenever a screen has been recorded in the session. Previously only `$screen` events carried it. `$snapshot` events are excluded.
- **`PostHog.screen("")` (and whitespace-only titles) are silently dropped.** No `$screen` event is emitted and the cached value is untouched, so the last useful screen name survives. Previously these emitted a `$screen` event with an empty/whitespace `$screen_name`. Customers using empty-string as a sentinel in dashboards will see those rows disappear.
- **Empty/whitespace `$screen_name` in `properties` falls back to the cache.** Passing `properties = mapOf("$screen_name" to "")` (or whitespace-only) on a `capture(...)` call no longer ships an empty `$screen_name` — the cached value wins. A meaningful caller-supplied value still wins.
- **No change for `screen()` override semantics.** `PostHog.screen("Home", properties = mapOf("$screen_name" to "Override"))` continues to ship `$screen_name = "Override"` on the `$screen` event itself (Kotlin's `putAll` already let the caller override).
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import com.posthog.internal.PostHogQueue
* @property apiKey the PostHog API Key
* @property captureApplicationLifecycleEvents captures lifecycle events such as app installed, app updated, app opened and backgrounded
* @property captureDeepLinks captures deep links events
* @property captureScreenViews captures screen views events
* @property captureScreenViews automatically captures a `$screen` event whenever a foreground
* Activity starts (via `ActivityLifecycleCallbacks.onActivityStarted`). When enabled, the most
* recent screen name is also attached as `$screen_name` to every subsequent event captured by
* the SDK. To opt out of `$screen_name` stamping entirely, set to `false` AND avoid calling
* `PostHog.screen(...)` manually. Default: `true`.
*/
public open class PostHogAndroidConfig
@JvmOverloads
Expand Down
35 changes: 32 additions & 3 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,19 @@ public class PostHog private constructor(
props.putAll(it)
}

// Stamp $screen_name from the cache only if the caller didn't supply a
// meaningful value. Runs after putAll so a real caller override (incl.
// posthog-flutter, which sets $screen_name in properties on capture)
// still takes precedence. Treat caller empty/blank as absent (almost
// always accidental) so the cache wins.
if (appendSharedProps) {
val cachedName = lastScreenName
val callerName = (props["\$screen_name"] as? String)?.trim()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!cachedName.isNullOrEmpty() && callerName.isNullOrEmpty()) {
props["\$screen_name"] = cachedName
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flutter also captures $screen_name, and uses the android sdk, so its better to check if we are not overwriting the flutter value here

}
}

// only Session replay needs distinct_id also in the props
// remove after https://github.com/PostHog/posthog/pull/18954 gets merged
val propDistinctId = props["distinct_id"] as? String
Expand Down Expand Up @@ -743,6 +756,18 @@ public class PostHog private constructor(
return config?.optOut ?: true
}

/**
* Records a screen view by capturing a `$screen` event with [screenTitle].
*
* The title is also cached and automatically attached as `$screen_name` to
* every subsequent event (until [reset] or [close] clears it).
*
* To override the auto-attached value on a specific event, pass `$screen_name`
* in that event's `properties` on the next [capture] call.
*
* @param screenTitle the screen name to record
* @param properties additional properties to attach to this `$screen` event
*/
public override fun screen(
screenTitle: String,
properties: Map<String, Any>?,
Expand All @@ -751,11 +776,15 @@ public class PostHog private constructor(
return
}

// Cache for capture-time context snapshot on log records.
this.lastScreenName = screenTitle
val trimmedTitle = screenTitle.trim()
if (trimmedTitle.isEmpty()) {
return
}

this.lastScreenName = trimmedTitle

val props = mutableMapOf<String, Any>()
props["\$screen_name"] = screenTitle
props["\$screen_name"] = trimmedTitle

properties?.let {
props.putAll(it)
Expand Down
219 changes: 219 additions & 0 deletions posthog/src/test/java/com/posthog/PostHogScreenNameTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package com.posthog

import com.posthog.internal.PostHogMemoryPreferences
import com.posthog.internal.PostHogThreadFactory
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import java.io.File
import java.util.concurrent.Executors
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse

internal class PostHogScreenNameTest {
@get:Rule
val tmpDir = TemporaryFolder()

private val queueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestQueue"))
private val replayQueueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestReplayQueue"))
private val remoteConfigExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestRemoteConfig"))
private val cachedEventsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestCachedEvents"))
private lateinit var config: PostHogConfig

private val captured = mutableListOf<PostHogEvent>()

@Suppress("DEPRECATION")
private fun getSut(): PostHogInterface {
val storage = tmpDir.newFolder().absolutePath
config =
PostHogConfig(API_KEY, "http://localhost").apply {
flushAt = 1
storagePrefix = File(storage, "events").absolutePath
replayStoragePrefix = File(storage, "snapshots").absolutePath
preloadFeatureFlags = false
cachePreferences = PostHogMemoryPreferences()
addBeforeSend(
PostHogBeforeSend { event ->
captured.add(event)
null
},
)
}
return PostHog.withInternal(
config,
queueExecutor,
replayQueueExecutor,
remoteConfigExecutor,
cachedEventsExecutor,
reloadFeatureFlags = true,
)
}

@AfterTest
fun `set down`() {
tmpDir.root.deleteRecursively()
}

private fun PostHogInterface.captureAndAwait(
event: String,
properties: Map<String, Any>? = null,
) {
capture(event, DISTINCT_ID, properties = properties)
queueExecutor.awaitExecution()
}

@Test
fun `event captured before screen has no screen_name`() {
val sut = getSut()

sut.captureAndAwait(EVENT)

val theEvent = captured.first { it.event == EVENT }
assertFalse(theEvent.properties!!.containsKey("\$screen_name"))

sut.close()
}

@Test
fun `event captured after screen carries screen_name`() {
val sut = getSut()

sut.screen("Home")
sut.captureAndAwait(EVENT)

val theEvent = captured.first { it.event == EVENT }
assertEquals("Home", theEvent.properties!!["\$screen_name"])

sut.close()
}

@Test
fun `caller-supplied screen_name overrides cached value`() {
val sut = getSut()

sut.screen("Home")
sut.captureAndAwait(EVENT, properties = mapOf("\$screen_name" to "Override"))

val theEvent = captured.first { it.event == EVENT }
assertEquals("Override", theEvent.properties!!["\$screen_name"])

sut.close()
}

@Test
fun `caller-supplied screen_name from posthog-flutter is preserved`() {
val sut = getSut()

sut.screen("AndroidScreen")
sut.captureAndAwait(EVENT, properties = mapOf("\$screen_name" to "FlutterHome"))

val theEvent = captured.first { it.event == EVENT }
assertEquals("FlutterHome", theEvent.properties!!["\$screen_name"])

sut.close()
}

@Test
fun `screen with whitespace-padded title is trimmed for cache and event payload`() {
val sut = getSut()

sut.screen(" Home ")
sut.captureAndAwait(EVENT)

val screenEvent = captured.first { it.event == PostHogEventName.SCREEN.event }
assertEquals("Home", screenEvent.properties!!["\$screen_name"])

val theEvent = captured.first { it.event == EVENT }
assertEquals("Home", theEvent.properties!!["\$screen_name"])

sut.close()
}

@Test
fun `screen with blank title is dropped and does not touch the cache`() {
val sut = getSut()

sut.screen("Home")
sut.screen("")
sut.screen(" ")
sut.captureAndAwait(EVENT)

// Blank screen() calls leave the cache intact at the last useful name.
val theEvent = captured.first { it.event == EVENT }
assertEquals("Home", theEvent.properties!!["\$screen_name"])
assertFalse(
captured.any { it.event == PostHogEventName.SCREEN.event && (it.properties?.get("\$screen_name") as? String).isNullOrBlank() },
)

sut.close()
}

@Test
fun `caller-supplied empty screen_name falls back to cached value`() {
val sut = getSut()

sut.screen("Home")
sut.captureAndAwait(EVENT, properties = mapOf("\$screen_name" to ""))

val theEvent = captured.first { it.event == EVENT }
assertEquals("Home", theEvent.properties!!["\$screen_name"])

sut.close()
}

@Test
fun `caller-supplied whitespace screen_name falls back to cached value`() {
val sut = getSut()

sut.screen("Home")
sut.captureAndAwait(EVENT, properties = mapOf("\$screen_name" to " "))

val theEvent = captured.first { it.event == EVENT }
assertEquals("Home", theEvent.properties!!["\$screen_name"])

sut.close()
}

@Test
fun `reset clears screen_name from subsequent events`() {
val sut = getSut()

sut.screen("Home")
sut.reset()
sut.captureAndAwait(EVENT)

val theEvent = captured.first { it.event == EVENT }
assertFalse(theEvent.properties!!.containsKey("\$screen_name"))

sut.close()
}

@Test
fun `exception event carries screen_name`() {
val sut = getSut()

sut.screen("Home")
sut.captureException(RuntimeException("boom"))
queueExecutor.awaitExecution()

val theEvent = captured.first { it.event == "\$exception" }
assertEquals("Home", theEvent.properties!!["\$screen_name"])

sut.close()
}

@Test
fun `snapshot event does not carry screen_name`() {
val sut = getSut()

sut.screen("Home")
sut.capture("\$snapshot", DISTINCT_ID, properties = mapOf("\$session_id" to "test-session-id"))
queueExecutor.awaitExecution()

val theEvent = captured.first { it.event == "\$snapshot" }
assertFalse(theEvent.properties!!.containsKey("\$screen_name"))

sut.close()
}
}