From 55a1c381b3a424e701377432466e0e2842cef542 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 24 Sep 2026 12:52:26 -0400 Subject: [PATCH 1/4] fix(errortracking): stamp native crashes on the SDK's clock Exit records carry wall-clock time, but on API 33+ the batch sent_at comes from the network-corrected date provider. Ingestion shifts each event by timestamp - sent_at, so a crash moved by however far the two clocks disagreed. Shift the exit timestamp into the date provider's clock; the watermark keeps the raw exit timestamp. --- .changeset/native-crash-timestamp-clock.md | 5 +++ .../PostHogNativeCrashIntegration.kt | 15 ++++++-- .../PostHogNativeCrashIntegrationTest.kt | 36 ++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 .changeset/native-crash-timestamp-clock.md diff --git a/.changeset/native-crash-timestamp-clock.md b/.changeset/native-crash-timestamp-clock.md new file mode 100644 index 000000000..70685bc17 --- /dev/null +++ b/.changeset/native-crash-timestamp-clock.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Fix native (NDK) crash events being stamped at the wrong time when the device clock disagrees with network time diff --git a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt index 9edf12a83..ad6d680ae 100644 --- a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt @@ -42,6 +42,7 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { private val context: Context private val config: PostHogAndroidConfig private val executorFactory: () -> ExecutorService + private val wallClockMs: () -> Long private var executor: ExecutorService? = null private var postHog: PostHogInterface? = null @@ -56,10 +57,16 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { { Executors.newSingleThreadExecutor(PostHogThreadFactory("PostHogNativeCrashThread")) }, ) - internal constructor(context: Context, config: PostHogAndroidConfig, executorFactory: () -> ExecutorService) { + internal constructor( + context: Context, + config: PostHogAndroidConfig, + executorFactory: () -> ExecutorService, + wallClockMs: () -> Long = System::currentTimeMillis, + ) { this.context = context this.config = config this.executorFactory = executorFactory + this.wallClockMs = wallClockMs } private companion object { @@ -207,6 +214,10 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { ) + (applicationInfo.splitSourceDirs?.toList() ?: emptyList()), ) var captured = 0 + // Exit records carry wall-clock time, but the batch's sent_at comes from config.dateProvider, + // which is network-corrected on API 33+. Ingestion shifts each event by timestamp - sent_at, + // so a timestamp left on the wall clock moves by however far the two clocks disagree. + val clockOffsetMs = config.dateProvider.currentTimeMillis() - wallClockMs() for ((index, exitInfo) in crashes.withIndex()) { // uninstall interrupts the scanner; stop before acknowledging more records @@ -240,7 +251,7 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { postHog.capture( PostHogEventName.EXCEPTION.event, properties = it, - timestamp = Date(exitInfo.timestamp), + timestamp = Date(exitInfo.timestamp + clockOffsetMs), ) captured++ } diff --git a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt index 00c5f511a..d04d38059 100644 --- a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt @@ -10,6 +10,7 @@ import com.posthog.android.API_KEY import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.errortracking.NativeCrashWatermarkStore import com.posthog.android.internal.errortracking.TestProtoWriter +import com.posthog.internal.PostHogDateProvider import com.posthog.internal.PostHogRemoteConfig import org.junit.After import org.junit.Before @@ -78,10 +79,24 @@ internal class PostHogNativeCrashIntegrationTest { } } + private class FixedDateProvider(var nowMs: Long) : PostHogDateProvider { + override fun currentDate(): Date = Date(nowMs) + + override fun addSecondsToCurrentDate(seconds: Int): Date = Date(nowMs + seconds * 1000L) + + override fun currentTimeMillis(): Long = nowMs + + override fun nanoTime(): Long = System.nanoTime() + } + + private val wallClockMs = 1_000_000L + private val dateProvider = FixedDateProvider(wallClockMs) + @Before fun setUp() { config = PostHogAndroidConfig(API_KEY).apply { + dateProvider = this@PostHogNativeCrashIntegrationTest.dateProvider errorTrackingConfig.captureNativeCrashes = true remoteConfigHolder = mock { @@ -100,7 +115,7 @@ internal class PostHogNativeCrashIntegrationTest { } private fun install(executor: DirectExecutorService = DirectExecutorService()): PostHogNativeCrashIntegration { - val integration = PostHogNativeCrashIntegration(context, config, { executor }) + val integration = PostHogNativeCrashIntegration(context, config, { executor }, { wallClockMs }) installed.add(integration) integration.install(postHog) return integration @@ -161,6 +176,25 @@ internal class PostHogNativeCrashIntegrationTest { assertEquals(200, watermark()) } + @Test + fun `stamps crashes on the date provider clock and acknowledges the raw exit timestamp`() { + dateProvider.nowMs = wallClockMs - 200_000 + addExitRecord(ApplicationExitInfo.REASON_CRASH_NATIVE, timestamp = 900_000, trace = tombstoneBytes()) + + install() + + verify(postHog).capture( + eq("\$exception"), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + eq(Date(700_000)), + ) + assertEquals(900_000, watermark()) + } + @Test fun `already acknowledged records are not reprocessed`() { NativeCrashWatermarkStore(context).advance(200) From d40a8ae1cdff4098e9059fc5bf83e49ab4e55e50 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 24 Sep 2026 14:12:16 -0400 Subject: [PATCH 2/4] fix(errortracking): use the crashed run's clock offset for native crashes Record each run's date-provider-to-wall-clock offset and apply the offset of the run a crash happened in, so a clock change between the crash and its recovery no longer shifts the event. --- .../PostHogNativeCrashIntegration.kt | 26 ++++++++-- .../NativeCrashClockOffsetStore.kt | 47 +++++++++++++++++++ .../PostHogNativeCrashIntegrationTest.kt | 29 ++++++++++++ .../NativeCrashClockOffsetStoreTest.kt | 43 +++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt diff --git a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt index ad6d680ae..1d06efed8 100644 --- a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt @@ -10,6 +10,7 @@ import com.posthog.PostHogEventName import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.errortracking.NativeCrashClockOffsetStore import com.posthog.android.internal.errortracking.NativeCrashEventCoercer import com.posthog.android.internal.errortracking.NativeCrashWatermarkStore import com.posthog.android.internal.errortracking.TombstoneParser @@ -184,6 +185,25 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { @RequiresApi(Build.VERSION_CODES.S) private fun scan(postHog: PostHogInterface) { + // Exit records carry wall-clock time, but the batch's sent_at comes from config.dateProvider, + // which is network-corrected on API 33+. Ingestion shifts each event by timestamp - sent_at, + // so each crash is moved onto the provider's clock with the offset of the run it crashed in. + val runStartWallClockMs = wallClockMs() + val currentClockOffsetMs = config.dateProvider.currentTimeMillis() - runStartWallClockMs + val clockOffsets = NativeCrashClockOffsetStore(context) + try { + scanCrashes(postHog, clockOffsets, currentClockOffsetMs) + } finally { + clockOffsets.record(runStartWallClockMs, currentClockOffsetMs) + } + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun scanCrashes( + postHog: PostHogInterface, + clockOffsets: NativeCrashClockOffsetStore, + currentClockOffsetMs: Long, + ) { val activityManager = getActivityManager(context) ?: return val watermarkStore = NativeCrashWatermarkStore(context) val watermark = watermarkStore.get() @@ -214,10 +234,6 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { ) + (applicationInfo.splitSourceDirs?.toList() ?: emptyList()), ) var captured = 0 - // Exit records carry wall-clock time, but the batch's sent_at comes from config.dateProvider, - // which is network-corrected on API 33+. Ingestion shifts each event by timestamp - sent_at, - // so a timestamp left on the wall clock moves by however far the two clocks disagree. - val clockOffsetMs = config.dateProvider.currentTimeMillis() - wallClockMs() for ((index, exitInfo) in crashes.withIndex()) { // uninstall interrupts the scanner; stop before acknowledging more records @@ -251,7 +267,7 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { postHog.capture( PostHogEventName.EXCEPTION.event, properties = it, - timestamp = Date(exitInfo.timestamp + clockOffsetMs), + timestamp = Date(exitInfo.timestamp + (clockOffsets.offsetAt(exitInfo.timestamp) ?: currentClockOffsetMs)), ) captured++ } diff --git a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt new file mode 100644 index 000000000..5b8058d75 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt @@ -0,0 +1,47 @@ +package com.posthog.android.internal.errortracking + +import android.content.Context + +/** + * Remembers, per app run, how far the SDK's clock was from the wall clock. + * + * Exit records only carry wall-clock time, and a native crash is recovered in a + * later run whose clocks may disagree differently (the user changed the time, or + * network time became available). Recovering with the crashed run's offset puts + * the crash on the SDK's clock as it was when the crash happened. + */ +internal class NativeCrashClockOffsetStore(context: Context) { + private val preferences = + context.getSharedPreferences("posthog-native-crash", Context.MODE_PRIVATE) + + /** The offset of the latest run that started at or before [wallClockMs], or null if none is known. */ + fun offsetAt(wallClockMs: Long): Long? = runs().lastOrNull { (startMs, _) -> startMs <= wallClockMs }?.second + + fun record( + runStartWallClockMs: Long, + offsetMs: Long, + ) { + val runs = (runs() + (runStartWallClockMs to offsetMs)).sortedBy { it.first }.takeLast(MAX_RUNS) + preferences.edit().putString(KEY, runs.joinToString(";") { "${it.first}:${it.second}" }).apply() + } + + private fun runs(): List> = + preferences.getString(KEY, null) + ?.split(';') + ?.mapNotNull { entry -> + val parts = entry.split(':') + val start = parts.getOrNull(0)?.toLongOrNull() + val offset = parts.getOrNull(1)?.toLongOrNull() + if (start != null && offset != null) start to offset else null + } + ?.sortedBy { it.first } + ?: emptyList() + + private companion object { + private const val KEY = "clockOffsets" + + // Crashes older than this many runs fall back to the oldest known offset, + // which is fine: the OS only keeps a handful of exit records anyway. + private const val MAX_RUNS = 16 + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt index d04d38059..287c969f5 100644 --- a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt @@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.posthog.PostHogInterface import com.posthog.android.API_KEY import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.errortracking.NativeCrashClockOffsetStore import com.posthog.android.internal.errortracking.NativeCrashWatermarkStore import com.posthog.android.internal.errortracking.TestProtoWriter import com.posthog.internal.PostHogDateProvider @@ -195,6 +196,34 @@ internal class PostHogNativeCrashIntegrationTest { assertEquals(900_000, watermark()) } + @Test + fun `stamps crashes with the offset recorded by the run they crashed in`() { + NativeCrashClockOffsetStore(context).record(runStartWallClockMs = 800_000, offsetMs = -50_000) + dateProvider.nowMs = wallClockMs - 200_000 + addExitRecord(ApplicationExitInfo.REASON_CRASH_NATIVE, timestamp = 900_000, trace = tombstoneBytes()) + + install() + + verify(postHog).capture( + eq("\$exception"), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + eq(Date(850_000)), + ) + } + + @Test + fun `records the current run's clock offset`() { + dateProvider.nowMs = wallClockMs - 200_000 + + install() + + assertEquals(-200_000, NativeCrashClockOffsetStore(context).offsetAt(wallClockMs)) + } + @Test fun `already acknowledged records are not reprocessed`() { NativeCrashWatermarkStore(context).advance(200) diff --git a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt new file mode 100644 index 000000000..e69d35fa8 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt @@ -0,0 +1,43 @@ +package com.posthog.android.internal.errortracking + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34]) +internal class NativeCrashClockOffsetStoreTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val store = NativeCrashClockOffsetStore(context) + + @Test + fun `returns null before any run is recorded`() { + assertNull(store.offsetAt(1_000)) + } + + @Test + fun `returns the offset of the latest run started at or before the time`() { + store.record(runStartWallClockMs = 100, offsetMs = -1) + store.record(runStartWallClockMs = 300, offsetMs = -3) + store.record(runStartWallClockMs = 200, offsetMs = -2) + + assertNull(store.offsetAt(99)) + assertEquals(-1, store.offsetAt(100)) + assertEquals(-2, store.offsetAt(250)) + assertEquals(-3, store.offsetAt(10_000)) + } + + @Test + fun `keeps only the most recent runs`() { + (1L..20L).forEach { store.record(runStartWallClockMs = it * 100, offsetMs = -it) } + + assertNull(store.offsetAt(400)) + assertEquals(-5, store.offsetAt(500)) + assertEquals(-20, store.offsetAt(2_000)) + } +} From 5a14e3ba87a0dc7c33e8d7328c379f1f9827e2b8 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 25 Sep 2026 09:50:37 -0400 Subject: [PATCH 3/4] fix(errortracking): key native crash clock offsets by pid and refresh them on time changes Runs were matched to crashes by wall-clock start time, which breaks when the clock moves backwards. The exit record carries the pid, so key runs by pid. A run's startup offset also went stale when the wall clock changed during the run, so the offset is now re-recorded on ACTION_TIME_CHANGED. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PostHogNativeCrashIntegration.kt | 71 ++++++++++++++++--- .../NativeCrashClockOffsetStore.kt | 22 +++--- .../PostHogNativeCrashIntegrationTest.kt | 57 +++++++++++++-- .../NativeCrashClockOffsetStoreTest.kt | 35 +++++---- 4 files changed, 148 insertions(+), 37 deletions(-) diff --git a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt index 1d06efed8..6dacc8fb4 100644 --- a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt @@ -2,8 +2,12 @@ package com.posthog.android.errortracking import android.app.Application import android.app.ApplicationExitInfo +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Build +import android.os.Process import android.os.UserManager import androidx.annotation.RequiresApi import com.posthog.PostHogEventName @@ -46,6 +50,7 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { private val wallClockMs: () -> Long private var executor: ExecutorService? = null private var postHog: PostHogInterface? = null + private var timeChangedReceiver: BroadcastReceiver? = null // Whether this instance owns the process-wide scanner guard, so only the // owner can release it on uninstall. @@ -137,6 +142,7 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { val executor = executorFactory() this.executor = executor executor.submit { scanSafely(postHog) } + registerTimeChangedReceiver(executor) } catch (e: Throwable) { config.logger.log("Native crash scan could not be scheduled: $e.") installedByThisInstance = false @@ -148,6 +154,14 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { if (!installedByThisInstance) { return } + timeChangedReceiver?.let { + try { + context.unregisterReceiver(it) + } catch (e: Throwable) { + config.logger.log("Unregistering the time change receiver failed: $e.") + } + } + timeChangedReceiver = null // Stop the scanner before releasing the process-wide guard, so a new // install cannot start a second scanner while this one is still running. executor?.shutdownNow() @@ -183,18 +197,57 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { } } + // The wall clock can change while the app runs (the user sets it, or automatic + // time corrects it), so the run's recorded offset is refreshed each time it does. + private fun registerTimeChangedReceiver(executor: ExecutorService) { + val receiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + try { + // off the main thread: the date provider may do a Binder IPC + executor.submit { recordCurrentClockOffset() } + } catch (e: Throwable) { + config.logger.log("Recording the clock offset failed: $e.") + } + } + } + val filter = IntentFilter(Intent.ACTION_TIME_CHANGED) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(receiver, filter) + } + timeChangedReceiver = receiver + } catch (e: Throwable) { + config.logger.log("Registering the time change receiver failed: $e.") + } + } + + // Exit records carry wall-clock time, but the batch's sent_at comes from config.dateProvider, + // which is network-corrected on API 33+. Ingestion shifts each event by timestamp - sent_at, + // so each crash is moved onto the provider's clock with the offset of the run it crashed in. + private fun currentClockOffsetMs(): Long = config.dateProvider.currentTimeMillis() - wallClockMs() + + private fun recordCurrentClockOffset() { + try { + NativeCrashClockOffsetStore(context).record(Process.myPid(), currentClockOffsetMs()) + } catch (e: Throwable) { + config.logger.log("Recording the clock offset failed: $e.") + } + } + @RequiresApi(Build.VERSION_CODES.S) private fun scan(postHog: PostHogInterface) { - // Exit records carry wall-clock time, but the batch's sent_at comes from config.dateProvider, - // which is network-corrected on API 33+. Ingestion shifts each event by timestamp - sent_at, - // so each crash is moved onto the provider's clock with the offset of the run it crashed in. - val runStartWallClockMs = wallClockMs() - val currentClockOffsetMs = config.dateProvider.currentTimeMillis() - runStartWallClockMs - val clockOffsets = NativeCrashClockOffsetStore(context) + val currentClockOffsetMs = currentClockOffsetMs() try { - scanCrashes(postHog, clockOffsets, currentClockOffsetMs) + scanCrashes(postHog, NativeCrashClockOffsetStore(context), currentClockOffsetMs) } finally { - clockOffsets.record(runStartWallClockMs, currentClockOffsetMs) + // after the scan: a pid reused from a crashed run must not overwrite that run's offset first + recordCurrentClockOffset() } } @@ -267,7 +320,7 @@ public class PostHogNativeCrashIntegration : PostHogIntegration { postHog.capture( PostHogEventName.EXCEPTION.event, properties = it, - timestamp = Date(exitInfo.timestamp + (clockOffsets.offsetAt(exitInfo.timestamp) ?: currentClockOffsetMs)), + timestamp = Date(exitInfo.timestamp + (clockOffsets.offsetFor(exitInfo.pid) ?: currentClockOffsetMs)), ) captured++ } diff --git a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt index 5b8058d75..0c879f7ed 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt @@ -9,38 +9,42 @@ import android.content.Context * later run whose clocks may disagree differently (the user changed the time, or * network time became available). Recovering with the crashed run's offset puts * the crash on the SDK's clock as it was when the crash happened. + * + * Runs are keyed by pid, which the exit record carries, rather than by start + * time: the wall clock can move backwards, so start times do not order runs. */ internal class NativeCrashClockOffsetStore(context: Context) { private val preferences = context.getSharedPreferences("posthog-native-crash", Context.MODE_PRIVATE) - /** The offset of the latest run that started at or before [wallClockMs], or null if none is known. */ - fun offsetAt(wallClockMs: Long): Long? = runs().lastOrNull { (startMs, _) -> startMs <= wallClockMs }?.second + /** The offset last recorded by the run with [pid], or null if none is known. */ + fun offsetFor(pid: Int): Long? = runs().lastOrNull { it.first == pid }?.second fun record( - runStartWallClockMs: Long, + pid: Int, offsetMs: Long, ) { - val runs = (runs() + (runStartWallClockMs to offsetMs)).sortedBy { it.first }.takeLast(MAX_RUNS) + // a reused pid belongs to the newer run, so drop the older entry + val runs = (runs().filter { it.first != pid } + (pid to offsetMs)).takeLast(MAX_RUNS) preferences.edit().putString(KEY, runs.joinToString(";") { "${it.first}:${it.second}" }).apply() } - private fun runs(): List> = + // oldest first + private fun runs(): List> = preferences.getString(KEY, null) ?.split(';') ?.mapNotNull { entry -> val parts = entry.split(':') - val start = parts.getOrNull(0)?.toLongOrNull() + val pid = parts.getOrNull(0)?.toIntOrNull() val offset = parts.getOrNull(1)?.toLongOrNull() - if (start != null && offset != null) start to offset else null + if (pid != null && offset != null) pid to offset else null } - ?.sortedBy { it.first } ?: emptyList() private companion object { private const val KEY = "clockOffsets" - // Crashes older than this many runs fall back to the oldest known offset, + // Crashes from runs older than this fall back to the current offset, // which is fine: the OS only keeps a handful of exit records anyway. private const val MAX_RUNS = 16 } diff --git a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt index 287c969f5..6d723dfc0 100644 --- a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt @@ -3,6 +3,9 @@ package com.posthog.android.errortracking import android.app.ActivityManager import android.app.ApplicationExitInfo import android.content.Context +import android.content.Intent +import android.os.Looper +import android.os.Process import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.posthog.PostHogInterface @@ -90,7 +93,7 @@ internal class PostHogNativeCrashIntegrationTest { override fun nanoTime(): Long = System.nanoTime() } - private val wallClockMs = 1_000_000L + private var wallClockMs = 1_000_000L private val dateProvider = FixedDateProvider(wallClockMs) @Before @@ -198,9 +201,9 @@ internal class PostHogNativeCrashIntegrationTest { @Test fun `stamps crashes with the offset recorded by the run they crashed in`() { - NativeCrashClockOffsetStore(context).record(runStartWallClockMs = 800_000, offsetMs = -50_000) + NativeCrashClockOffsetStore(context).record(pid = 7, offsetMs = -50_000) dateProvider.nowMs = wallClockMs - 200_000 - addExitRecord(ApplicationExitInfo.REASON_CRASH_NATIVE, timestamp = 900_000, trace = tombstoneBytes()) + addExitRecord(ApplicationExitInfo.REASON_CRASH_NATIVE, timestamp = 900_000, pid = 7, trace = tombstoneBytes()) install() @@ -215,13 +218,59 @@ internal class PostHogNativeCrashIntegrationTest { ) } + @Test + fun `picks the crashed run by pid even after the wall clock moved back`() { + // run 7 started with a fast wall clock at 800_000; run 8 started after + // the clock was corrected back to 400_000, then crashed at 900_000 + NativeCrashClockOffsetStore(context).record(pid = 7, offsetMs = -500_000) + NativeCrashClockOffsetStore(context).record(pid = 8, offsetMs = 0) + addExitRecord(ApplicationExitInfo.REASON_CRASH_NATIVE, timestamp = 900_000, pid = 8, trace = tombstoneBytes()) + + install() + + verify(postHog).capture( + eq("\$exception"), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + eq(Date(900_000)), + ) + } + @Test fun `records the current run's clock offset`() { dateProvider.nowMs = wallClockMs - 200_000 install() - assertEquals(-200_000, NativeCrashClockOffsetStore(context).offsetAt(wallClockMs)) + assertEquals(-200_000, NativeCrashClockOffsetStore(context).offsetFor(Process.myPid())) + } + + @Test + fun `re-records the current run's clock offset when the wall clock changes`() { + dateProvider.nowMs = wallClockMs - 200_000 + install() + + // the wall clock is corrected to match the provider while the app runs + wallClockMs = dateProvider.nowMs + context.sendBroadcast(Intent(Intent.ACTION_TIME_CHANGED)) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(0, NativeCrashClockOffsetStore(context).offsetFor(Process.myPid())) + } + + @Test + fun `stops re-recording the clock offset after uninstall`() { + dateProvider.nowMs = wallClockMs - 200_000 + install().uninstall() + + wallClockMs = dateProvider.nowMs + context.sendBroadcast(Intent(Intent.ACTION_TIME_CHANGED)) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(-200_000, NativeCrashClockOffsetStore(context).offsetFor(Process.myPid())) } @Test diff --git a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt index e69d35fa8..0d687cb08 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStoreTest.kt @@ -16,28 +16,33 @@ internal class NativeCrashClockOffsetStoreTest { private val store = NativeCrashClockOffsetStore(context) @Test - fun `returns null before any run is recorded`() { - assertNull(store.offsetAt(1_000)) + fun `returns null for a pid with no recorded run`() { + assertNull(store.offsetFor(1)) } @Test - fun `returns the offset of the latest run started at or before the time`() { - store.record(runStartWallClockMs = 100, offsetMs = -1) - store.record(runStartWallClockMs = 300, offsetMs = -3) - store.record(runStartWallClockMs = 200, offsetMs = -2) - - assertNull(store.offsetAt(99)) - assertEquals(-1, store.offsetAt(100)) - assertEquals(-2, store.offsetAt(250)) - assertEquals(-3, store.offsetAt(10_000)) + fun `returns the offset recorded by each pid`() { + store.record(pid = 1, offsetMs = -1) + store.record(pid = 2, offsetMs = -2) + + assertEquals(-1, store.offsetFor(1)) + assertEquals(-2, store.offsetFor(2)) + } + + @Test + fun `a later record for the same pid replaces the earlier one`() { + store.record(pid = 1, offsetMs = -1) + store.record(pid = 1, offsetMs = 0) + + assertEquals(0, store.offsetFor(1)) } @Test fun `keeps only the most recent runs`() { - (1L..20L).forEach { store.record(runStartWallClockMs = it * 100, offsetMs = -it) } + (1..20).forEach { store.record(pid = it, offsetMs = -it.toLong()) } - assertNull(store.offsetAt(400)) - assertEquals(-5, store.offsetAt(500)) - assertEquals(-20, store.offsetAt(2_000)) + assertNull(store.offsetFor(4)) + assertEquals(-5, store.offsetFor(5)) + assertEquals(-20, store.offsetFor(20)) } } From 2a6aaba3d818f5e20af34f58beb5a26d8fa6b885 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 25 Sep 2026 10:39:21 -0400 Subject: [PATCH 4/4] fix(errortracking): serialize native crash clock offset writes Co-Authored-By: Claude Opus 5.5 (1M context) --- .../errortracking/NativeCrashClockOffsetStore.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt index 0c879f7ed..28b7f73f4 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashClockOffsetStore.kt @@ -24,9 +24,13 @@ internal class NativeCrashClockOffsetStore(context: Context) { pid: Int, offsetMs: Long, ) { - // a reused pid belongs to the newer run, so drop the older entry - val runs = (runs().filter { it.first != pid } + (pid to offsetMs)).takeLast(MAX_RUNS) - preferences.edit().putString(KEY, runs.joinToString(";") { "${it.first}:${it.second}" }).apply() + // Scans on different executors can overlap after an uninstall and + // re-enable, so the read-modify-write must not lose either run's entry. + synchronized(lock) { + // a reused pid belongs to the newer run, so drop the older entry + val runs = (runs().filter { it.first != pid } + (pid to offsetMs)).takeLast(MAX_RUNS) + preferences.edit().putString(KEY, runs.joinToString(";") { "${it.first}:${it.second}" }).apply() + } } // oldest first @@ -44,6 +48,9 @@ internal class NativeCrashClockOffsetStore(context: Context) { private companion object { private const val KEY = "clockOffsets" + // every instance shares one SharedPreferences file, so the lock is shared too + private val lock = Any() + // Crashes from runs older than this fall back to the current offset, // which is fine: the OS only keeps a handful of exit records anyway. private const val MAX_RUNS = 16