From c3227844c72dacec9afa4766166bf06b020d0adb Mon Sep 17 00:00:00 2001 From: David Cornejo <106721994+cornejobarraza@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:18:31 -0600 Subject: [PATCH 01/17] [docs][ios][expo-location] Add `motionUsagePermission` configurable property (#49517) # Why Expo Documentation is missing an important configurable property for `expo-location` on iOS # How Added missing documentation for `motionUsagePermission` on iOS # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/pages/versions/unversioned/sdk/location.mdx | 7 +++++++ docs/pages/versions/v56.0.0/sdk/location.mdx | 7 +++++++ docs/pages/versions/v57.0.0/sdk/location.mdx | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/docs/pages/versions/unversioned/sdk/location.mdx b/docs/pages/versions/unversioned/sdk/location.mdx index 9bfdad01e7e75b..ebc2402ff70e8e 100644 --- a/docs/pages/versions/unversioned/sdk/location.mdx +++ b/docs/pages/versions/unversioned/sdk/location.mdx @@ -73,6 +73,13 @@ You can configure `expo-location` using its built-in [config plugin](/config-plu 'A string to set the [`NSLocationWhenInUseUsageDescription`](#permission-nslocationwheninuseusagedescription) permission message.', default: '"Allow $(PRODUCT_NAME) to use your location"', }, + { + name: 'motionUsagePermission', + platform: 'ios', + description: + 'A string to set the `NSMotionUsageDescription` permission message shown when `getMotionActivityAsync` or `watchMotionActivityAsync` is called for the first time.', + default: '"Allow $(PRODUCT_NAME) to detect your current motion activity"', + }, { name: 'isIosBackgroundLocationEnabled', platform: 'ios', diff --git a/docs/pages/versions/v56.0.0/sdk/location.mdx b/docs/pages/versions/v56.0.0/sdk/location.mdx index 0b2b25e6e51a1b..8c04cd97a1ea53 100644 --- a/docs/pages/versions/v56.0.0/sdk/location.mdx +++ b/docs/pages/versions/v56.0.0/sdk/location.mdx @@ -73,6 +73,13 @@ You can configure `expo-location` using its built-in [config plugin](/config-plu 'A string to set the [`NSLocationWhenInUseUsageDescription`](#permission-nslocationwheninuseusagedescription) permission message.', default: '"Allow $(PRODUCT_NAME) to use your location"', }, + { + name: 'motionUsagePermission', + platform: 'ios', + description: + 'A string to set the `NSMotionUsageDescription` permission message shown when `getMotionActivityAsync` or `watchMotionActivityAsync` is called for the first time.', + default: '"Allow $(PRODUCT_NAME) to detect your current motion activity"', + }, { name: 'isIosBackgroundLocationEnabled', platform: 'ios', diff --git a/docs/pages/versions/v57.0.0/sdk/location.mdx b/docs/pages/versions/v57.0.0/sdk/location.mdx index d984b01e81c0f8..0d400e45bafc30 100644 --- a/docs/pages/versions/v57.0.0/sdk/location.mdx +++ b/docs/pages/versions/v57.0.0/sdk/location.mdx @@ -73,6 +73,13 @@ You can configure `expo-location` using its built-in [config plugin](/config-plu 'A string to set the [`NSLocationWhenInUseUsageDescription`](#permission-nslocationwheninuseusagedescription) permission message.', default: '"Allow $(PRODUCT_NAME) to use your location"', }, + { + name: 'motionUsagePermission', + platform: 'ios', + description: + 'A string to set the `NSMotionUsageDescription` permission message shown when `getMotionActivityAsync` or `watchMotionActivityAsync` is called for the first time.', + default: '"Allow $(PRODUCT_NAME) to detect your current motion activity"', + }, { name: 'isIosBackgroundLocationEnabled', platform: 'ios', From 39aee7e9614388103140e4c08b3272bc5acae52d Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:13 +0200 Subject: [PATCH 02/17] [router] Remove redundant processing logic for menu icon (#49584) # Why Remove custom conversion logic for icon following https://github.com/react-navigation/react-navigation/pull/12987 # How # Test Plan # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com> --- packages/expo-router/CHANGELOG.md | 1 + .../__tests__/StackToolbarMenu.test.ios.tsx | 25 ++++++++------- .../toolbar/StackToolbarMenu/index.tsx | 31 ++----------------- .../useHeaderConfigProps.test.ios.tsx | 11 +++++-- 4 files changed, 26 insertions(+), 42 deletions(-) diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index ef71b708edc2bc..cd4eda04c0a598 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -85,6 +85,7 @@ ### 💡 Others +- Pass toolbar menu icons straight to react-navigation instead of converting them to react-native-screens icons. (by [@Ubax](https://github.com/Ubax)) ([#49584](https://github.com/expo/expo/pull/49584) by [@Ubax](https://github.com/Ubax)) - Remove module-level mutable navigation state from Expo Router. ([#49403](https://github.com/expo/expo/pull/49403) by [@Ubax](https://github.com/Ubax)) - Remove the dev-only `stack` field from the `__unsafe_action__` event in `expo-router/react-navigation`. ([#49431](https://github.com/expo/expo/pull/49431) by [@Ubax](https://github.com/Ubax)) - Read navigation state from the React tree instead of imperative refs. ([#49433](https://github.com/expo/expo/pull/49433) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/layouts/stack-utils/__tests__/StackToolbarMenu.test.ios.tsx b/packages/expo-router/src/layouts/stack-utils/__tests__/StackToolbarMenu.test.ios.tsx index 2c168f64e22db8..15a2bc5a0d46e3 100644 --- a/packages/expo-router/src/layouts/stack-utils/__tests__/StackToolbarMenu.test.ios.tsx +++ b/packages/expo-router/src/layouts/stack-utils/__tests__/StackToolbarMenu.test.ios.tsx @@ -208,26 +208,28 @@ describe(convertStackToolbarMenuActionPropsToRNHeaderItem, () => { }); describe('image icons', () => { - it('converts image icon to imageSource format by default', () => { + it('passes image icon as untinted by default', () => { const result = convertStackToolbarMenuActionPropsToRNHeaderItem({ icon: { uri: 'https://example.com/icon.png' }, }); expect(result.icon).toEqual({ - type: 'imageSource', - imageSource: { uri: 'https://example.com/icon.png' }, + type: 'image', + source: { uri: 'https://example.com/icon.png' }, + tinted: false, }); }); - it('converts image icon to templateSource format when iconRenderingMode is template', () => { + it('passes image icon as tinted when iconRenderingMode is template', () => { const result = convertStackToolbarMenuActionPropsToRNHeaderItem({ icon: { uri: 'https://example.com/icon.png' }, iconRenderingMode: 'template', }); expect(result.icon).toEqual({ - type: 'templateSource', - templateSource: { uri: 'https://example.com/icon.png' }, + type: 'image', + source: { uri: 'https://example.com/icon.png' }, + tinted: true, }); }); }); @@ -413,7 +415,7 @@ describe('submenu conversion', () => { }); }); - it('converts image icons to imageSource format in submenu', () => { + it('passes image icons in submenu', () => { const result = convertStackToolbarMenuPropsToRNHeaderItem({ children: ( @@ -424,13 +426,14 @@ describe('submenu conversion', () => { expect(result?.menu.items[0]).toMatchObject({ icon: { - type: 'imageSource', - imageSource: { uri: 'https://example.com/icon.png' }, + type: 'image', + source: { uri: 'https://example.com/icon.png' }, + tinted: false, }, }); }); - it('converts xcasset icons in submenu to imageSource format', () => { + it('passes xcasset icons in submenu as image icons', () => { const result = convertStackToolbarMenuPropsToRNHeaderItem({ children: ( @@ -441,7 +444,7 @@ describe('submenu conversion', () => { }); expect(result?.menu.items[0]).toMatchObject({ - icon: { type: 'imageSource', imageSource: { uri: 'custom-icon' } }, + icon: { type: 'image', source: { uri: 'custom-icon' }, tinted: false }, }); }); }); diff --git a/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/index.tsx b/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/index.tsx index c01106ecfa5173..0976c0fd6c25c0 100644 --- a/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/index.tsx +++ b/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/index.tsx @@ -1,7 +1,5 @@ 'use client'; import { Children, useMemo, type ReactNode } from 'react'; -import type { ImageSourcePropType } from 'react-native'; -import type { PlatformIconIOS } from 'react-native-screens'; import type { NativeStackHeaderItemMenu, @@ -190,19 +188,6 @@ export function convertStackToolbarMenuPropsToRNHeaderItem( return item; } -// Custom menu action icons are not supported in react-navigation yet -// But they are supported in react-native-screens -// TODO(@ubax): Remove this workaround once react-navigation supports custom icons for menu actions. -// https://linear.app/expo/issue/ENG-19853/remove-custom-conversion-logic-for-icon-from-packagesexpo -function convertImageIconToPlatformIcon(icon: { - source: ImageSourcePropType; - tinted?: boolean; -}): PlatformIconIOS { - return icon.tinted - ? { type: 'templateSource', templateSource: icon.source } - : { type: 'imageSource', imageSource: icon.source }; -} - function convertStackToolbarSubmenuMenuPropsToRNHeaderItem( props: StackToolbarMenuProps ): NativeStackHeaderItemMenuSubmenu | undefined { @@ -241,13 +226,7 @@ function convertStackToolbarSubmenuMenuPropsToRNHeaderItem( // TODO: Add elementSize to react-native-screens if (sharedProps.icon) { - if (sharedProps.icon.type === 'sfSymbol') { - item.icon = sharedProps.icon; - } else { - item.icon = convertImageIconToPlatformIcon( - sharedProps.icon - ) as unknown as NativeStackHeaderItemMenuSubmenu['icon']; - } + item.icon = sharedProps.icon; } return item; @@ -317,13 +296,7 @@ export function convertStackToolbarMenuActionPropsToRNHeaderItem( item.keepsMenuPresented = unstable_keepPresented; } if (sharedProps.icon) { - if (sharedProps.icon.type === 'sfSymbol') { - item.icon = sharedProps.icon; - } else { - item.icon = convertImageIconToPlatformIcon( - sharedProps.icon - ) as unknown as NativeStackHeaderItemMenuAction['icon']; - } + item.icon = sharedProps.icon; } return item; } diff --git a/packages/expo-router/src/react-navigation/native-stack/__tests__/useHeaderConfigProps.test.ios.tsx b/packages/expo-router/src/react-navigation/native-stack/__tests__/useHeaderConfigProps.test.ios.tsx index 908f7f91f0f4c2..b74c0ffebb3ee8 100644 --- a/packages/expo-router/src/react-navigation/native-stack/__tests__/useHeaderConfigProps.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/native-stack/__tests__/useHeaderConfigProps.test.ios.tsx @@ -895,7 +895,7 @@ describe('processBarButtonItems', () => { // ─── getMenuItem (via menu items) ─────────────────────────────────────────────── // TODO(@ubax): Consider refactoring getMenuItem to be a separate function that can be tested in isolation instead of testing it indirectly via headerLeft items describe('getMenuItem', () => { - test('action item: label becomes title, description becomes subtitle', () => { + test('action item transforms label, description, and icon', () => { const onPress = jest.fn(); const { result } = renderHook(() => useHeaderConfigProps( @@ -908,7 +908,13 @@ describe('getMenuItem', () => { onPress: jest.fn(), menu: { items: [ - { type: 'action', label: 'Copy', description: 'Copy to clipboard', onPress }, + { + type: 'action', + label: 'Copy', + description: 'Copy to clipboard', + icon: { type: 'image', source: { uri: 'copy.png' }, tinted: false }, + onPress, + }, ], }, }, @@ -919,6 +925,7 @@ describe('getMenuItem', () => { const menuItem = (result.current.headerLeftBarButtonItems![0] as any).menu.items[0]; expect(menuItem.title).toBe('Copy'); expect(menuItem.subtitle).toBe('Copy to clipboard'); + expect(menuItem.icon).toEqual({ type: 'imageSource', imageSource: { uri: 'copy.png' } }); expect(menuItem.label).toBeUndefined(); expect(menuItem.description).toBeUndefined(); }); From e2e9640cee637eeccd5dbe15a7339d1c25481c87 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:50 +0200 Subject: [PATCH 03/17] [observe] Remove pending metrics db table (#49547) # Why Simplify pending metrics mechanism on Android and unify it with iOS # How 1. Remove subscriber pattern and pending metrics db 2. Change the metric/log id type to auto-incremented integer 3. Use cursor based approach 4. Save the sent cursor in `SharedPreferences` # Test Plan 1. CI 2. Observe tester # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-app-metrics/CHANGELOG.md | 2 + .../appmetrics/storage/MetricsDatabase.kt | 33 +- .../appmetrics/storage/SessionManager.kt | 92 +- .../appmetrics/storage/SessionMappers.kt | 9 +- .../SessionManagerGlobalAttributesTest.kt | 4 - .../appmetrics/storage/SessionManagerTest.kt | 342 +-- .../appmetrics/storage/SessionMappersTest.kt | 17 +- .../storage/SessionSharedObjectTest.kt | 18 +- packages/expo-observe/CHANGELOG.md | 1 + packages/expo-observe/android/build.gradle | 25 - .../java/expo/modules/observe/Constants.kt | 1 - .../java/expo/modules/observe/CursorRepair.kt | 58 + .../expo/modules/observe/DispatchUtils.kt | 22 - .../main/java/expo/modules/observe/Event.kt | 2 +- .../observe/ObservabilityBackgroundWorker.kt | 13 +- .../modules/observe/ObservabilityManager.kt | 247 +- .../modules/observe/ObservePreferences.kt | 22 + .../observe/storage/ObserveDatabase.kt | 94 - .../observe/storage/PendingLogsManager.kt | 42 - .../observe/storage/PendingMetricsManager.kt | 42 - .../observe/BaseObservabilityManagerTest.kt | 2139 +++++------------ .../expo/modules/observe/CursorRepairTest.kt | 94 + .../expo/modules/observe/DispatchUtilsTest.kt | 41 - .../modules/observe/ObservePreferencesTest.kt | 34 + .../observe/storage/PendingLogsManagerTest.kt | 156 -- .../storage/PendingMetricsManagerTest.kt | 175 -- 26 files changed, 1004 insertions(+), 2721 deletions(-) create mode 100644 packages/expo-observe/android/src/main/java/expo/modules/observe/CursorRepair.kt delete mode 100644 packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt delete mode 100644 packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt delete mode 100644 packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt create mode 100644 packages/expo-observe/android/src/test/java/expo/modules/observe/CursorRepairTest.kt delete mode 100644 packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingLogsManagerTest.kt delete mode 100644 packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt diff --git a/packages/expo-app-metrics/CHANGELOG.md b/packages/expo-app-metrics/CHANGELOG.md index 837391eedab288..29529d66d222f4 100644 --- a/packages/expo-app-metrics/CHANGELOG.md +++ b/packages/expo-app-metrics/CHANGELOG.md @@ -4,6 +4,8 @@ ### 🛠 Breaking changes +- [Android] Remove `SessionManager` insert listeners and `JsMetric.metricId` ([#49547](https://github.com/expo/expo/pull/49547) by [@Ubax](https://github.com/Ubax)) + ### 🎉 New features - Add an optional `displayName` to `logEvent` ([#47289](https://github.com/expo/expo/pull/47289) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt index 520c31914c2967..8deac8ab7de60a 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/MetricsDatabase.kt @@ -3,20 +3,20 @@ package expo.modules.appmetrics.storage import androidx.room.Dao import androidx.room.Database import androidx.room.Delete +import androidx.room.Embedded import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.Index import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query -import androidx.room.ForeignKey +import androidx.room.Relation import androidx.room.Room import androidx.room.RoomDatabase -import androidx.room.Embedded -import androidx.room.Relation import androidx.room.Transaction -import kotlinx.serialization.Serializable import java.util.UUID +import kotlinx.serialization.Serializable object MetricsConstants { const val SECONDS_TO_REMOVE_OLD_METRICS: Long = 7 * 24 * 60 * 60 // 7 days in seconds @@ -24,7 +24,7 @@ object MetricsConstants { @Database( entities = [Metric::class, LogRecord::class, Session::class, CrashReportEntity::class], - version = 16, + version = 17, exportSchema = false ) abstract class MetricsDatabase : RoomDatabase() { @@ -105,7 +105,7 @@ data class Session( ) @Serializable data class Metric( - @PrimaryKey val metricId: String = UUID.randomUUID().toString(), + @PrimaryKey(autoGenerate = true) val id: Long = 0, val sessionId: String, // ISO 8601 date string val timestamp: String, @@ -147,7 +147,7 @@ data class SessionWithMetrics( ) @Serializable data class LogRecord( - @PrimaryKey val logId: String = UUID.randomUUID().toString(), + @PrimaryKey(autoGenerate = true) val id: Long = 0, val sessionId: String, // ISO 8601 date string val timestamp: String, @@ -160,11 +160,6 @@ data class LogRecord( val droppedAttributesCount: Int = 0 ) -data class SessionWithLogs( - val session: Session, - val logs: List -) - @Entity( tableName = "crash_reports", indices = [Index(value = ["sessionId"], unique = true)], @@ -216,8 +211,11 @@ interface MetricDao { @Delete suspend fun delete(metrics: List) - @Query("SELECT * FROM metrics WHERE metricId IN (:metricIds) ORDER BY timestamp ASC") - suspend fun getByIds(metricIds: List): List + @Query("SELECT * FROM metrics WHERE id > :afterId ORDER BY id ASC LIMIT :limit") + suspend fun getAfterId(afterId: Long, limit: Int): List + + @Query("SELECT MAX(id) FROM metrics") + suspend fun getMaxId(): Long? @Query("SELECT * FROM metrics WHERE sessionId = :sessionId ORDER BY timestamp ASC") suspend fun getMetricsForSession(sessionId: String): List @@ -231,8 +229,11 @@ interface LogDao { @Delete suspend fun delete(logs: List) - @Query("SELECT * FROM logs WHERE logId IN (:logIds) ORDER BY timestamp ASC") - suspend fun getByIds(logIds: List): List + @Query("SELECT * FROM logs WHERE id > :afterId ORDER BY id ASC LIMIT :limit") + suspend fun getAfterId(afterId: Long, limit: Int): List + + @Query("SELECT MAX(id) FROM logs") + suspend fun getMaxId(): Long? @Query("DELETE FROM logs WHERE timestamp < :cutoffTimestamp") suspend fun deleteLogsOlderThan(cutoffTimestamp: String) diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt index a564059e50cfc7..d1c0abaa63a044 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt @@ -1,19 +1,16 @@ package expo.modules.appmetrics.storage import android.content.Context -import android.util.Log import expo.modules.appmetrics.AppMetadata import expo.modules.appmetrics.AppMetricsPreferences -import expo.modules.appmetrics.SQLITE_MAX_BIND_VARIABLES -import expo.modules.appmetrics.TAG import expo.modules.appmetrics.GlobalAttributes +import expo.modules.appmetrics.SQLITE_MAX_BIND_VARIABLES import expo.modules.appmetrics.utils.JsonAny import expo.modules.appmetrics.utils.TimeUtils import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import java.util.UUID -import java.util.concurrent.CopyOnWriteArrayList class SessionManager( context: Context, @@ -22,33 +19,6 @@ class SessionManager( private val context: Context = context private val database: MetricsDatabase = database ?: MetricsDatabase.getDatabase(context) - fun interface MetricsInsertListener { - suspend fun onMetricsInserted(metricIds: List) - } - - fun interface LogsInsertListener { - suspend fun onLogsInserted(logIds: List) - } - - private val metricsInsertListeners = CopyOnWriteArrayList() - private val logsInsertListeners = CopyOnWriteArrayList() - - fun addMetricsInsertListener(listener: MetricsInsertListener) { - metricsInsertListeners.add(listener) - } - - fun removeMetricsInsertListener(listener: MetricsInsertListener) { - metricsInsertListeners.remove(listener) - } - - fun addLogsInsertListener(listener: LogsInsertListener) { - logsInsertListeners.add(listener) - } - - fun removeLogsInsertListener(listener: LogsInsertListener) { - logsInsertListeners.remove(listener) - } - fun createSessionId(): String = UUID.randomUUID().toString() suspend fun startSessionWithIdAt( @@ -103,14 +73,6 @@ class SessionManager( ) } database.metricDao().insertAll(metricsWithSession) - val metricIds = metricsWithSession.map { it.metricId } - metricsInsertListeners.forEach { listener -> - try { - listener.onMetricsInserted(metricIds) - } catch (e: Exception) { - Log.e(TAG, "MetricsInsertListener failed", e) - } - } } /** @@ -166,9 +128,19 @@ class SessionManager( suspend fun getMetricsForSession(sessionId: String): List = database.metricDao().getMetricsForSession(sessionId) + suspend fun getMetrics(afterId: Long, limit: Int): List = + database.metricDao().getAfterId(afterId, limit) + + suspend fun getMaxMetricId(): Long? = database.metricDao().getMaxId() + suspend fun getLogsForSession(sessionId: String): List = database.logDao().getLogsForSession(sessionId) + suspend fun getLogs(afterId: Long, limit: Int): List = + database.logDao().getAfterId(afterId, limit) + + suspend fun getMaxLogId(): Long? = database.logDao().getMaxId() + suspend fun clearAllData() { database.sessionDao().deleteAll() // Deleting the sessions cascades to their attributed reports, but orphan @@ -203,14 +175,6 @@ class SessionManager( ) } database.logDao().insertAll(logsWithSession) - val logIds = logsWithSession.map { it.logId } - logsInsertListeners.forEach { listener -> - try { - listener.onLogsInserted(logIds) - } catch (e: Exception) { - Log.e(TAG, "LogsInsertListener failed", e) - } - } } suspend fun cleanupOldLogs() { @@ -222,39 +186,7 @@ class SessionManager( database.sessionDao().updateEnvironmentForActiveSessions(environment) } - suspend fun getSessionsWithMetrics(metricIds: List): List { - val metricsBySessionId = metricIds - .distinct() - .chunked(SQLITE_MAX_BIND_VARIABLES) - .flatMap { database.metricDao().getByIds(it) } - .sortedBy { it.timestamp } - .groupBy { it.sessionId } - val sessionsById = getSessionsByIds(metricsBySessionId.keys).associateBy { it.id } - - return metricsBySessionId.mapNotNull { (sessionId, metrics) -> - sessionsById[sessionId]?.let { session -> - SessionWithMetrics(session = session, metrics = metrics) - } - } - } - - suspend fun getSessionsWithLogs(logIds: List): List { - val logsBySessionId = logIds - .distinct() - .chunked(SQLITE_MAX_BIND_VARIABLES) - .flatMap { database.logDao().getByIds(it) } - .sortedBy { it.timestamp } - .groupBy { it.sessionId } - val sessionsById = getSessionsByIds(logsBySessionId.keys).associateBy { it.id } - - return logsBySessionId.mapNotNull { (sessionId, logs) -> - sessionsById[sessionId]?.let { session -> - SessionWithLogs(session = session, logs = logs) - } - } - } - - private suspend fun getSessionsByIds(sessionIds: Collection): List = + suspend fun getSessions(sessionIds: Collection): List = sessionIds.chunked(SQLITE_MAX_BIND_VARIABLES).flatMap { database.sessionDao().getByIds(it) } /** diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionMappers.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionMappers.kt index 1ad81fc8c372db..0c12d6b7be74b3 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionMappers.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionMappers.kt @@ -5,7 +5,6 @@ import expo.modules.appmetrics.utils.TimeUtils import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record import expo.modules.kotlin.types.OptimizedRecord -import java.util.UUID import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -52,7 +51,6 @@ data class JsMetric( @Field val category: String, @Field val name: String, @Field val value: Double, - @Field val metricId: String? = UUID.randomUUID().toString(), @Field val timestamp: String = TimeUtils.getCurrentTimestampInISOFormat(), @Field val routeName: String? = null, @Field val updateId: String? = null, @@ -60,7 +58,6 @@ data class JsMetric( ) : Record { fun toMetric(): Metric = Metric( - metricId = metricId ?: UUID.randomUUID().toString(), sessionId = sessionId, timestamp = timestamp, category = category, @@ -74,7 +71,6 @@ data class JsMetric( companion object { fun fromMetric(metric: Metric): JsMetric = JsMetric( - metricId = metric.metricId, sessionId = metric.sessionId, timestamp = metric.timestamp, category = metric.category, @@ -90,7 +86,7 @@ data class JsMetric( /** * Payload for `Session.addMetric` — mirrors the TypeScript `MetricInput` type * (`Metric` minus `sessionId`). The owning session is implied by the shared - * object the metric is added to, so the id is injected via `toMetric(sessionId)` + * object the metric is added to, so the session id is injected via `toMetric(sessionId)` * rather than carried across the bridge; `updateId` is a native-side concern not * exposed to JS. */ @@ -105,7 +101,6 @@ data class SessionMetricInput( ) : Record { fun toMetric(sessionId: String): Metric = Metric( - metricId = UUID.randomUUID().toString(), sessionId = sessionId, timestamp = timestamp, category = category, @@ -121,7 +116,7 @@ data class SessionMetricInput( * JS-facing shape of a log event. Mirrors the TypeScript `LogRecord` type and * decodes the storage-only JSON `attributes` column into a typed map. * - * `logId`, `sessionId`, and `droppedAttributesCount` are storage- and + * `sessionId` and `droppedAttributesCount` are storage- and * dispatch-side concerns: JS consumers see the record under its parent * `Session.logs` (so the parent ID is implicit), and the dropped-attribute * bookkeeping is only meaningful on the OTel wire payload. diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerGlobalAttributesTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerGlobalAttributesTest.kt index 6d38d4b7572db6..5bfef371f00642 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerGlobalAttributesTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerGlobalAttributesTest.kt @@ -182,13 +182,11 @@ class SessionManagerGlobalAttributesTest { } private fun metric( - metricId: String = "m-1", name: String = "test-metric", params: Map? = null, rawParams: String? = null ): Metric = Metric( - metricId = metricId, sessionId = "", timestamp = "2025-01-01T00:00:00.000Z", category = "test", @@ -198,13 +196,11 @@ class SessionManagerGlobalAttributesTest { ) private fun log( - logId: String = "l-1", name: String = "test.event", attributes: Map? = null, rawAttributes: String? = null ): LogRecord = LogRecord( - logId = logId, sessionId = "", timestamp = "2025-01-01T00:00:00.000Z", name = name, diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt index 89c5e1cf796b6f..9cc3ec8baf5ff7 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionManagerTest.kt @@ -287,307 +287,59 @@ class SessionManagerTest { // endregion - // region MetricsInsertListener Tests + // region Cursor Reads @Test - fun `addMetrics notifies listeners with inserted metric IDs`() = - runTest { - // Arrange - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - - val receivedIds = mutableListOf() - sessionManager.addMetricsInsertListener { metricIds -> - receivedIds.addAll(metricIds) - } - - val metrics = listOf( - createMetric("metric-1", ""), - createMetric("metric-2", "") - ) - - // Act - sessionManager.addMetrics(metrics, sessionId) - - // Assert - assertEquals(2, receivedIds.size) - assertTrue(receivedIds.contains("metric-1")) - assertTrue(receivedIds.contains("metric-2")) - } - - @Test - fun `addMetrics notifies multiple listeners`() = - runTest { - // Arrange - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - - val listener1Ids = mutableListOf() - val listener2Ids = mutableListOf() - sessionManager.addMetricsInsertListener { metricIds -> - listener1Ids.addAll(metricIds) - } - sessionManager.addMetricsInsertListener { metricIds -> - listener2Ids.addAll(metricIds) - } - - val metrics = listOf(createMetric("metric-1", "")) - - // Act - sessionManager.addMetrics(metrics, sessionId) - - // Assert - assertEquals(1, listener1Ids.size) - assertEquals(1, listener2Ids.size) - } - - @Test - fun `removeMetricsInsertListener stops notifications`() = - runTest { - // Arrange - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - - val receivedIds = mutableListOf() - val listener = SessionManager.MetricsInsertListener { metricIds -> - receivedIds.addAll(metricIds) - } - sessionManager.addMetricsInsertListener(listener) - - // Act - add metrics, then remove listener, then add more metrics - sessionManager.addMetrics(listOf(createMetric("metric-1", "")), sessionId) - sessionManager.removeMetricsInsertListener(listener) - sessionManager.addMetrics(listOf(createMetric("metric-2", "")), sessionId) - - // Assert - only the first metric should have been received - assertEquals(1, receivedIds.size) - assertTrue(receivedIds.contains("metric-1")) - } - - // endregion - - // region getSessionsWithMetrics Tests - - @Test - fun `getSessionsWithMetrics returns sessions with only requested metrics`() = - runTest { - // Arrange - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - - val metrics = listOf( - createMetric("metric-1", sessionId), - createMetric("metric-2", sessionId), - createMetric("metric-3", sessionId) - ) - database.metricDao().insertAll(metrics) - - // Act - only request metric-1 and metric-3 - val result = sessionManager.getSessionsWithMetrics(listOf("metric-1", "metric-3")) - - // Assert - assertEquals(1, result.size) - assertEquals(sessionId, result[0].session.id) - assertEquals(2, result[0].metrics.size) - assertTrue(result[0].metrics.any { it.metricId == "metric-1" }) - assertTrue(result[0].metrics.any { it.metricId == "metric-3" }) - } - - @Test - fun `getSessionsWithMetrics returns unique metrics in chronological order`() = - runTest { - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - database.metricDao().insertAll( - listOf( - createMetric("metric-z", sessionId, timestamp = "2025-01-01T00:00:02.000Z"), - createMetric("metric-a", sessionId, timestamp = "2025-01-01T00:00:01.000Z") - ) - ) - val metricIds = listOf("metric-z") + - (1 until SQLITE_MAX_BIND_VARIABLES).map { "missing-$it" } + - listOf("metric-a", "metric-z") - - val result = sessionManager.getSessionsWithMetrics(metricIds) - - assertEquals(listOf("metric-a", "metric-z"), result.single().metrics.map { it.metricId }) - } - - @Test - fun `getSessionsWithMetrics returns empty when no metrics match`() = - runTest { - // Arrange - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - database.metricDao().insertAll(listOf(createMetric("metric-1", sessionId))) - - // Act - val result = sessionManager.getSessionsWithMetrics(listOf("nonexistent-metric")) - - // Assert - assertEquals(0, result.size) - } - - @Test - fun `getSessionsWithMetrics returns multiple sessions`() = - runTest { - // Arrange - val session1Id = "session-1" - val session2Id = "session-2" - sessionManager.startSessionWithIdAt(session1Id, "2025-01-01T00:00:00.000Z") - sessionManager.startSessionWithIdAt(session2Id, "2025-01-01T01:00:00.000Z") - - database.metricDao().insertAll( - listOf( - createMetric("metric-1", session1Id), - createMetric("metric-2", session2Id) - ) - ) - - // Act - val result = sessionManager.getSessionsWithMetrics(listOf("metric-1", "metric-2")) - - // Assert - assertEquals(2, result.size) - val sessionIds = result.map { it.session.id } - assertTrue(sessionIds.contains(session1Id)) - assertTrue(sessionIds.contains(session2Id)) - } - - @Test - fun `getSessionsWithMetrics returns only requested metrics across query chunks`() = - runTest { - // Arrange - session has 1200 metrics, but we only request 1100 of them - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - - val allMetricIds = (1..1200).map { "metric-$it" } - allMetricIds.chunked(500).forEach { chunk -> - val metrics = chunk.map { createMetric(it, sessionId) } - database.metricDao().insertAll(metrics) - } - - // Request only the first 1100 - val requestedIds = allMetricIds.take(1100) + fun `getMetrics returns auto-generated IDs after cursor in ascending order with limit`() = runTest { + val sessionId = "session-1" + sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") + sessionManager.addMetrics( + listOf(createMetric("first", sessionId), createMetric("second", sessionId), createMetric("third", sessionId)), + sessionId + ) - // Act - val result = sessionManager.getSessionsWithMetrics(requestedIds) - - // Assert - only the 1100 requested metrics should be returned, not all 1200 - assertEquals(1, result.size) - assertEquals(1100, result[0].metrics.size) - val returnedIds = result[0].metrics.map { it.metricId }.toSet() - assertFalse(returnedIds.contains("metric-1101")) - assertFalse(returnedIds.contains("metric-1200")) - } + val all = sessionManager.getMetrics(afterId = -1, limit = 10) + assertEquals(listOf("first", "second", "third"), all.map { it.name }) + assertTrue(all.zipWithNext().all { (first, second) -> first.id < second.id }) + assertEquals(listOf("second"), sessionManager.getMetrics(all.first().id, 1).map { it.name }) + assertEquals(all.last().id, sessionManager.getMaxMetricId()) + } @Test - fun `getSessionsWithMetrics groups multiple sessions across query chunks`() = - runTest { - // Arrange - two sessions, each with metrics spanning chunk boundaries - val session1Id = "session-1" - val session2Id = "session-2" - sessionManager.startSessionWithIdAt(session1Id, "2025-01-01T00:00:00.000Z") - sessionManager.startSessionWithIdAt(session2Id, "2025-01-01T01:00:00.000Z") - - // Session 1 gets metrics 1-600, session 2 gets metrics 601-1200 - val s1MetricIds = (1..600).map { "metric-$it" } - val s2MetricIds = (601..1200).map { "metric-$it" } - - s1MetricIds.chunked(500).forEach { chunk -> - database.metricDao().insertAll(chunk.map { createMetric(it, session1Id) }) - } - s2MetricIds.chunked(500).forEach { chunk -> - database.metricDao().insertAll(chunk.map { createMetric(it, session2Id) }) - } - - val allIds = s1MetricIds + s2MetricIds - - // Act - query all 1200 IDs - val result = sessionManager.getSessionsWithMetrics(allIds) - - // Assert - both sessions returned, each with correct metric count - assertEquals(2, result.size) - val s1 = result.find { it.session.id == session1Id }!! - val s2 = result.find { it.session.id == session2Id }!! - assertEquals(600, s1.metrics.size) - assertEquals(600, s2.metrics.size) - } + fun `getMaxMetricId returns null when metrics are empty`() = runTest { + assertNull(sessionManager.getMaxMetricId()) + } @Test - fun `getSessionsWithMetrics does not load logs`() = - runTest { - val sessionId = "session-with-logs" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - database.metricDao().insertAll(listOf(createMetric("metric-1", sessionId))) - database.logDao().insertAll(listOf(createLog("log-a", sessionId))) - - val result = sessionManager.getSessionsWithMetrics(listOf("metric-1")) - - assertEquals(1, result.size) - assertEquals(emptyList(), result[0].logs) - } - - // endregion + fun `getLogs returns auto-generated IDs after cursor in ascending order with limit`() = runTest { + val sessionId = "session-1" + sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") + sessionManager.addLogs( + listOf(createLog("first", sessionId), createLog("second", sessionId), createLog("third", sessionId)), + sessionId + ) - // region getSessionsWithLogs Tests + val all = sessionManager.getLogs(afterId = -1, limit = 10) + assertEquals(listOf("first", "second", "third"), all.map { it.name }) + assertTrue(all.zipWithNext().all { (first, second) -> first.id < second.id }) + assertEquals(listOf("second"), sessionManager.getLogs(all.first().id, 1).map { it.name }) + assertEquals(all.last().id, sessionManager.getMaxLogId()) + } @Test - fun `getSessionsWithLogs returns only requested logs`() = - runTest { - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - database.logDao().insertAll( - listOf( - createLog("log-1", sessionId), - createLog("log-2", sessionId), - createLog("log-3", sessionId) - ) - ) - - val result = sessionManager.getSessionsWithLogs(listOf("log-1", "log-3")) - - assertEquals(1, result.size) - assertEquals(sessionId, result[0].session.id) - assertEquals(setOf("log-1", "log-3"), result[0].logs.map { it.logId }.toSet()) - } + fun `getMaxLogId returns null when logs are empty`() = runTest { + assertNull(sessionManager.getMaxLogId()) + } @Test - fun `getSessionsWithLogs returns unique logs in chronological order`() = - runTest { - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - database.logDao().insertAll( - listOf( - createLog("log-z", sessionId, timestamp = "2025-01-01T00:00:02.000Z"), - createLog("log-a", sessionId, timestamp = "2025-01-01T00:00:01.000Z") - ) - ) - val logIds = listOf("log-z") + - (1 until SQLITE_MAX_BIND_VARIABLES).map { "missing-$it" } + - listOf("log-a", "log-z") - - val result = sessionManager.getSessionsWithLogs(logIds) - - assertEquals(listOf("log-a", "log-z"), result.single().logs.map { it.logId }) + fun `getSessions reads IDs across SQLite bind chunks`() = runTest { + val ids = (0..SQLITE_MAX_BIND_VARIABLES).map { "session-$it" } + ids.forEachIndexed { index, id -> + sessionManager.startSessionWithIdAt(id, "2025-01-01T00:00:${index % 60}.000Z") } - @Test - fun `getSessionsWithLogs groups requested logs spanning multiple query chunks`() = - runTest { - val sessionId = "session-1" - sessionManager.startSessionWithIdAt(sessionId, "2025-01-01T00:00:00.000Z") - val logIds = (1..1100).map { "log-$it" } - logIds.chunked(500).forEach { chunk -> - database.logDao().insertAll(chunk.map { createLog(it, sessionId) }) - } - - val result = sessionManager.getSessionsWithLogs(logIds) - - assertEquals(1, result.size) - assertEquals(sessionId, result[0].session.id) - assertEquals(logIds.toSet(), result[0].logs.map { it.logId }.toSet()) - } + assertEquals(ids.toSet(), sessionManager.getSessions(ids).map { it.id }.toSet()) + } // endregion @@ -698,7 +450,7 @@ class SessionManagerTest { // Assert assertEquals(2, session.metrics.size) assertEquals(3, session.logs.size) - assertEquals(setOf("log-a", "log-b", "log-c"), session.logs.map { it.logId }.toSet()) + assertEquals(setOf("auth.login_failed", "user.signed_in", "cache.miss"), session.logs.map { it.name }.toSet()) } @Test @@ -770,7 +522,7 @@ class SessionManagerTest { val metrics = sessionManager.getMetricsForSession(sessionId) // Assert - assertEquals(setOf("metric-1", "metric-2"), metrics.map { it.metricId }.toSet()) + assertEquals(setOf("metric-1", "metric-2"), metrics.map { it.name }.toSet()) assertTrue(metrics.all { it.sessionId == sessionId }) } @@ -794,7 +546,7 @@ class SessionManagerTest { val logs = sessionManager.getLogsForSession(sessionId) // Assert - assertEquals(setOf("log-1", "log-2"), logs.map { it.logId }.toSet()) + assertEquals(setOf("log-1", "log-2"), logs.map { it.name }.toSet()) assertTrue(logs.all { it.sessionId == sessionId }) } @@ -803,15 +555,14 @@ class SessionManagerTest { // region Helper Methods private fun createMetric( - metricId: String, + metricName: String, sessionId: String, - name: String = "test-metric", + name: String = metricName, category: String = "test", value: Double = 123.45, timestamp: String = "2025-01-01T00:00:00.000Z" ): Metric = Metric( - metricId = metricId, sessionId = sessionId, timestamp = timestamp, category = category, @@ -822,15 +573,14 @@ class SessionManagerTest { ) private fun createLog( - logId: String, + logName: String, sessionId: String, - name: String = "test.event", + name: String = logName, severity: String = "info", attributes: String? = null, timestamp: String = "2025-01-01T00:00:00.000Z" ): LogRecord = LogRecord( - logId = logId, sessionId = sessionId, timestamp = timestamp, name = name, diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionMappersTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionMappersTest.kt index 9a37a3d79354f9..47cf155a997c46 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionMappersTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionMappersTest.kt @@ -2,7 +2,6 @@ package expo.modules.appmetrics.storage import org.junit.Assert.assertEquals import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -23,11 +22,9 @@ class SessionMappersTest { ) private fun makeMetric( - metricId: String = "metric-1", sessionId: String = "session-1", params: String? = null ): Metric = Metric( - metricId = metricId, sessionId = sessionId, timestamp = "2025-01-01T00:00:01.000Z", category = "appStartup", @@ -37,12 +34,10 @@ class SessionMappersTest { ) private fun makeLog( - logId: String = "log-1", sessionId: String = "session-1", name: String = "auth.login_failed", attributes: String? = null ): LogRecord = LogRecord( - logId = logId, sessionId = sessionId, timestamp = "2025-01-01T00:00:02.000Z", name = name, @@ -107,9 +102,8 @@ class SessionMappersTest { @Test fun `JsMetric_fromMetric copies scalar fields verbatim`() { - val js = JsMetric.fromMetric(makeMetric(metricId = "m-42", sessionId = "session-1")) + val js = JsMetric.fromMetric(makeMetric(sessionId = "session-1")) - assertEquals("m-42", js.metricId) assertEquals("session-1", js.sessionId) assertEquals("appStartup", js.category) assertEquals("timeToInteractive", js.name) @@ -123,8 +117,8 @@ class SessionMappersTest { session = makeSession(), metrics = emptyList(), logs = listOf( - makeLog(logId = "l-1", name = "first.event"), - makeLog(logId = "l-2", name = "second.event") + makeLog(name = "first.event"), + makeLog(name = "second.event") ) ) @@ -213,7 +207,7 @@ class SessionMappersTest { } @Test - fun `SessionMetricInput_toMetric maps scalar fields verbatim and generates its own metricId`() { + fun `SessionMetricInput_toMetric maps scalar fields verbatim`() { val input = SessionMetricInput( category = "custom", name = "purchase", @@ -229,9 +223,6 @@ class SessionMappersTest { assertEquals(9.99, metric.value, 0.0) assertEquals("2025-03-01T12:00:00.000Z", metric.timestamp) assertEquals("Checkout", metric.routeName) - // `metricId` is generated natively and `updateId` isn't part of the - // JS-facing `MetricInput` contract — neither is caller-settable. - assertTrue(metric.metricId.isNotEmpty()) assertNull(metric.updateId) } diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionSharedObjectTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionSharedObjectTest.kt index 4e79a8de3589cb..65d05b345356b7 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionSharedObjectTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/storage/SessionSharedObjectTest.kt @@ -65,7 +65,7 @@ class SessionSharedObjectTest { assertNotNull(sessionManager.getSessionRow(session.sessionId)) assertEquals( setOf("metric-1"), - sessionManager.getMetricsForSession(session.sessionId).map { it.metricId }.toSet() + sessionManager.getMetricsForSession(session.sessionId).map { it.name }.toSet() ) } @@ -85,7 +85,7 @@ class SessionSharedObjectTest { assertNotNull(sessionManager.getSessionRow(session.sessionId)) assertEquals( setOf("log-1"), - sessionManager.getLogsForSession(session.sessionId).map { it.logId }.toSet() + sessionManager.getLogsForSession(session.sessionId).map { it.name }.toSet() ) } @@ -238,8 +238,8 @@ class SessionSharedObjectTest { session.addMetrics(listOf(createMetric("metric-1", session.sessionId))) session.addLogs(listOf(createLog("log-1", session.sessionId))) - assertEquals(setOf("metric-1"), session.getMetrics().map { it.metricId }.toSet()) - assertEquals(setOf("log-1"), session.getLogs().map { it.logId }.toSet()) + assertEquals(setOf("metric-1"), session.getMetrics().map { it.name }.toSet()) + assertEquals(setOf("log-1"), session.getLogs().map { it.name }.toSet()) } @Test @@ -268,29 +268,27 @@ class SessionSharedObjectTest { // region Helpers private fun createMetric( - metricId: String, + name: String, sessionId: String ): Metric = Metric( - metricId = metricId, sessionId = sessionId, timestamp = "2025-01-01T00:00:00.000Z", category = "test", - name = "test-metric", + name = name, value = 123.45, routeName = null, params = null ) private fun createLog( - logId: String, + name: String, sessionId: String ): LogRecord = LogRecord( - logId = logId, sessionId = sessionId, timestamp = "2025-01-01T00:00:00.000Z", - name = "test.event", + name = name, body = null, severity = "info", attributes = null, diff --git a/packages/expo-observe/CHANGELOG.md b/packages/expo-observe/CHANGELOG.md index dcc9d3377eb1e4..5b4f66c00f77b3 100644 --- a/packages/expo-observe/CHANGELOG.md +++ b/packages/expo-observe/CHANGELOG.md @@ -18,6 +18,7 @@ ### 💡 Others +- [Android] Replace pending telemetry queues with persisted row-id cursors. ([#49547](https://github.com/expo/expo/pull/49547) by [@Ubax](https://github.com/Ubax)) - [iOS] Dispatch pending metrics and logs in chunks of 200 and retry HTTP 413 responses with smaller batches. ([#49121](https://github.com/expo/expo/pull/49121) by [@Ubax](https://github.com/Ubax)) - [Android] Retry a dispatch that gets HTTP 413 ([#49016](https://github.com/expo/expo/pull/49016) by [@Ubax](https://github.com/Ubax)) - [Android] Dispatch pending metrics and logs in bounded, oldest-first chunks without replacing active background work. ([#49012](https://github.com/expo/expo/pull/49012) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-observe/android/build.gradle b/packages/expo-observe/android/build.gradle index 9652cc82463597..6d405945296b7a 100644 --- a/packages/expo-observe/android/build.gradle +++ b/packages/expo-observe/android/build.gradle @@ -1,22 +1,4 @@ buildscript { - ext { - getKspVersion = { - if (rootProject.hasProperty("kspVersion")) { - return rootProject["kspVersion"] - } - - def kotlinVersion = rootProject.hasProperty("kotlinVersion") ? rootProject["kotlinVersion"] : "2.0.21" - if (kotlinVersion == "2.1.20") { - return "2.1.20-2.0.1" - } else if (kotlinVersion == "2.0.21") { - return "2.0.21-1.0.28" - } else if (kotlinVersion == "1.9.25") { - return "1.9.25-1.0.20" - } - return "1.9.24-1.0.20" - } - } - repositories { google() @@ -26,13 +8,11 @@ buildscript { dependencies { classpath("org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin:${kotlinVersion}") - classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:${getKspVersion()}" } } apply plugin: 'com.android.library' apply plugin: 'expo-module-gradle-plugin' apply plugin: 'org.jetbrains.kotlin.plugin.serialization' -apply plugin: 'com.google.devtools.ksp' group = 'expo.modules.observe' @@ -69,11 +49,6 @@ dependencies { // This is a workaround for IDE warning - https://youtrack.jetbrains.com/issue/KTIJ-31549 implementation "androidx.annotation:annotation-experimental:1.5.1" - def room_version = "2.8.3" - implementation "androidx.room:room-runtime:$room_version" - implementation "androidx.room:room-ktx:$room_version" - ksp "androidx.room:room-compiler:$room_version" - implementation 'androidx.work:work-runtime-ktx:2.9.1' testImplementation 'junit:junit:4.13.2' diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt index 5381840e692c7d..fd6259d18e72a0 100644 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt +++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/Constants.kt @@ -4,4 +4,3 @@ internal const val OBSERVE_TAG = "EasObserve" internal const val OBSERVE_DEFAULT_BASE_URL = "https://o.expo.dev/" internal const val DISPATCH_CHUNK_SIZE = 200 -internal const val SQLITE_MAX_BIND_VARIABLES = 900 diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/CursorRepair.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/CursorRepair.kt new file mode 100644 index 00000000000000..505e9cc52bbccd --- /dev/null +++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/CursorRepair.kt @@ -0,0 +1,58 @@ +// Copyright 2025-present 650 Industries. All rights reserved. + +package expo.modules.observe + +import android.content.Context +import android.util.Log +import expo.modules.appmetrics.storage.SessionManager +import kotlinx.coroutines.CancellationException + +internal suspend fun repairCursorIfStale( + signalName: String, + readCursor: () -> Long, + writeCursor: (Long) -> Unit, + readMaxId: suspend () -> Long? +) { + val cursor = readCursor() + if (cursor < 0) return + + val maxId = try { + readMaxId() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w(OBSERVE_TAG, "Failed to read max $signalName id while repairing cursor: ${error.message}") + return + } + if (cursor > (maxId ?: -1)) { + Log.i( + OBSERVE_TAG, + "Resetting stale $signalName dispatch cursor (was $cursor, max id is ${maxId ?: ""})" + ) + writeCursor(-1) + } +} + +internal suspend fun repairMetricCursorIfStale( + context: Context, + sessionManager: SessionManager +) { + repairCursorIfStale( + signalName = "metric", + readCursor = { ObservePreferences.getLastDispatchedMetricId(context) }, + writeCursor = { ObservePreferences.setLastDispatchedMetricId(context, it) }, + readMaxId = sessionManager::getMaxMetricId + ) +} + +internal suspend fun repairLogCursorIfStale( + context: Context, + sessionManager: SessionManager +) { + repairCursorIfStale( + signalName = "log", + readCursor = { ObservePreferences.getLastDispatchedLogId(context) }, + writeCursor = { ObservePreferences.setLastDispatchedLogId(context, it) }, + readMaxId = sessionManager::getMaxLogId + ) +} diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt index b34a3018191e0c..7e1d1d42be3b8c 100644 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt +++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/DispatchUtils.kt @@ -88,28 +88,6 @@ object DispatchUtils { } } - /** - * Whether the dispatch caller should remove the just-sent pending IDs from the queue. - * - * - `Success` and `PartialSuccess` remove them — the rows have been accepted by the server - * (partial success rejects a subset server-side, but the bytes still landed; re-sending - * them would just re-trip the same rejection). - * - `NonRetryableFailure` ALSO removes them — the server has refused these rows - * permanently, so retrying would produce the same answer; removing them drops the batch - * so it can't wedge subsequent rounds. This is the acceptance-criterion behavior: a - * 400/403 must not be re-sent on the next cycle. - * - `PayloadTooLarge` removes the pending ID because multi-record batches are retried with - * smaller chunks before this check, so reaching it means a single record exceeded the limit. - * - `RetryableFailure` keeps them so they can be retried. - */ - fun shouldRemovePending(result: DispatchResult): Boolean = when (result) { - is DispatchResult.Success, - is DispatchResult.PartialSuccess, - is DispatchResult.NonRetryableFailure, - is DispatchResult.PayloadTooLarge -> true - is DispatchResult.RetryableFailure -> false - } - /** * Parses an HTTP `Retry-After` header into a delay in milliseconds from now. Accepts both * formats permitted by RFC 7231: an integer delta-seconds, or an HTTP-date. diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/Event.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/Event.kt index b0e44465501f82..72a7434b392a67 100644 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/Event.kt +++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/Event.kt @@ -109,7 +109,7 @@ data class EASMetric( * Wire shape of a log event ready for dispatch. Distinct from the storage-side * `LogRecord`: this form has the JSON `attributes` blob already parsed back * into a structured object (so the OTel encoder can map values to typed - * `OTAnyValue`s) and drops storage-only columns like `logId`. + * `OTAnyValue`s) and drops storage-only columns like the row id. */ @Serializable data class LogEvent( diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt index e41d7f3ceb58a6..dbe6aa680e2190 100644 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt +++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityBackgroundWorker.kt @@ -15,16 +15,10 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf -import expo.modules.observe.storage.PendingLogsManager -import expo.modules.observe.storage.PendingMetricsManager import expo.modules.appmetrics.storage.SessionManager /** - * Background worker that dispatches previously queued metrics to EAS Observe. - * - * This worker intentionally does NOT register a [SessionManager.MetricsInsertListener]. - * It only dispatches metrics that were already queued in the pending table by the foreground - * [ObservabilityManager]. + * Background worker that dispatches stored metrics and logs to EAS Observe. */ class ObservabilityBackgroundWorker( context: Context, @@ -42,15 +36,10 @@ class ObservabilityBackgroundWorker( context = context ) - val pendingMetricsManager = PendingMetricsManager(context) - val pendingLogsManager = PendingLogsManager(context) - BaseObservabilityManager( context = context, projectId = projectId, sessionManager = sessionManager, - pendingMetricsManager = pendingMetricsManager, - pendingLogsManager = pendingLogsManager, baseUrl = baseUrl, isDebugBuild = BuildConfig.DEBUG ) diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt index eaeab04e8e6bdc..3b825f76f2fb41 100644 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt +++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservabilityManager.kt @@ -3,8 +3,6 @@ package expo.modules.observe import android.content.Context import android.util.Log import expo.modules.easclient.EASClientID -import expo.modules.observe.storage.PendingLogsManager -import expo.modules.observe.storage.PendingMetricsManager import expo.modules.appmetrics.storage.SessionManager import expo.modules.appmetrics.utils.TimeUtils import expo.modules.interfaces.constants.ConstantsInterface @@ -34,25 +32,13 @@ class ObservabilityManager( } val baseUrl = manifest.baseUrl ?: OBSERVE_DEFAULT_BASE_URL - val pendingMetricsManager = PendingMetricsManager(context) - val pendingLogsManager = PendingLogsManager(context) - baseManager = BaseObservabilityManager( context = context, sessionManager = sessionManager, - pendingMetricsManager = pendingMetricsManager, - pendingLogsManager = pendingLogsManager, projectId = projectId, baseUrl = baseUrl, isDebugBuild = BuildConfig.DEBUG ) - - sessionManager.addMetricsInsertListener { metricIds -> - pendingMetricsManager.addPendingMetrics(metricIds) - } - sessionManager.addLogsInsertListener { logIds -> - pendingLogsManager.addPendingLogs(logIds) - } } suspend fun dispatchUnsentMetrics() { @@ -75,8 +61,6 @@ class ObservabilityManager( class BaseObservabilityManager( private val context: Context, private val sessionManager: SessionManager, - private val pendingMetricsManager: PendingMetricsManager, - private val pendingLogsManager: PendingLogsManager, val projectId: String, val baseUrl: String, private val isDebugBuild: Boolean = false, @@ -108,20 +92,6 @@ class BaseObservabilityManager( private var metricsRetryGate: DispatchUtils.RetryGateState = DispatchUtils.RetryGateState.initial private var logsRetryGate: DispatchUtils.RetryGateState = DispatchUtils.RetryGateState.initial - /** - * Per-signal mutexes that serialize same-signal dispatch calls. Two `dispatchEvents` - * invocations from JS can otherwise land on the same `BaseObservabilityManager` instance - * concurrently and race on the gate's read-modify-write (and double-POST the same pending - * rows). The metrics and logs paths take separate mutexes so they can still run in - * parallel — only same-signal calls serialize. - * - * Worst case without these would be benign (a dropped gate update or a duplicate dispatch), - * since the pending-ID stores are already DB-backed and telemetry is best-effort — but the - * gate is in-memory state with no other synchronization, so close the race explicitly. - */ - private val metricsDispatchMutex = Mutex() - private val logsDispatchMutex = Mutex() - /** * Returns true and logs when an active retry gate suppresses this dispatch round. Called * inside each per-signal dispatch method rather than at a shared entry point, so a backoff @@ -152,169 +122,156 @@ class BaseObservabilityManager( ) suspend fun dispatchUnsentMetrics(): Unit = metricsDispatchMutex.withLock { - if (!pendingMetricsManager.hasPendingMetrics()) { - return - } - if (retryGateBlocks(metricsRetryGate, "metrics")) { return } + repairMetricCursorIfStale(context, sessionManager) if (!shouldDispatch()) { - pendingMetricsManager.removeAllPendingMetrics() + val maxId = sessionManager.getMaxMetricId() ?: -1 + if (ObservePreferences.getLastDispatchedMetricId(context) != maxId) { + ObservePreferences.setLastDispatchedMetricId(context, maxId) + } return } + var cursor = ObservePreferences.getLastDispatchedMetricId(context) var chunkSize = dispatchChunkSize while (currentCoroutineContext().isActive) { - val pendingIds = pendingMetricsManager.getPendingMetricIds(chunkSize) - // Use the default for the next batch unless a 413 below overrides it. Re-discovering - // the limit each batch is fine: the server accepts payloads over 1 MB, so the default - // chunk stays far below the limit and a 413 is exceptional. + val metrics = sessionManager.getMetrics(cursor, chunkSize) chunkSize = dispatchChunkSize - if (pendingIds.isEmpty()) { + if (metrics.isEmpty()) { break } - val sessionsWithPendingMetrics = sessionManager.getSessionsWithMetrics(pendingIds) - - // Clean up orphaned pending IDs (metrics deleted from MetricsDatabase but still in pending table) - val resolvedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId }.toSet() - val orphanedIds = pendingIds.filter { it !in resolvedMetricIds } - if (orphanedIds.isNotEmpty()) { - pendingMetricsManager.removePendingMetrics(orphanedIds) - } - - if (sessionsWithPendingMetrics.isNotEmpty()) { - val events = sessionsWithPendingMetrics.map { sessionWithMetrics -> + val highestId = metrics.last().id + val metricsBySessionId = metrics.groupBy { it.sessionId } + val sessions = sessionManager.getSessions(metricsBySessionId.keys).associateBy { it.id } + val events = metricsBySessionId.mapNotNull { (sessionId, sessionMetrics) -> + sessions[sessionId]?.let { session -> Event( - metadata = Metadata.fromSessionMetadata(sessionWithMetrics.session), - metrics = sessionWithMetrics.metrics.map { EASMetric.fromMetric(it) } + metadata = Metadata.fromSessionMetadata(session), + metrics = sessionMetrics.map(EASMetric::fromMetric) ) } + } + if (events.isEmpty()) { + cursor = highestId + ObservePreferences.setLastDispatchedMetricId(context, cursor) + continue + } - val result = eventDispatcher.dispatch(events) - metricsRetryGate = nextGate(metricsRetryGate, result) - val dispatchedMetricIds = sessionsWithPendingMetrics.flatMap { it.metrics }.map { it.metricId } - when (result) { - is DispatchResult.PartialSuccess -> - Log.w( - OBSERVE_TAG, - "Partial success on batch of ${dispatchedMetricIds.size} metric event(s): " + - "server rejected ${result.partial.rejectedCount} " + - "(${result.partial.errorMessage ?: "no error message"})" - ) - is DispatchResult.NonRetryableFailure -> - Log.w( - OBSERVE_TAG, - "Dropping batch of ${dispatchedMetricIds.size} metric event(s): ${result.reason}" - ) - is DispatchResult.PayloadTooLarge -> - if (dispatchedMetricIds.size == 1) { - Log.w(OBSERVE_TAG, "Dropping metric event that exceeds the server's payload limit") - } - is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit - } - if (result is DispatchResult.PayloadTooLarge && dispatchedMetricIds.size > 1) { - chunkSize = dispatchedMetricIds.size / 2 - // Keep the pending metrics, but retry immediately with a smaller chunk. - continue + val result = eventDispatcher.dispatch(events) + metricsRetryGate = nextGate(metricsRetryGate, result) + when (result) { + is DispatchResult.PartialSuccess -> + Log.w( + OBSERVE_TAG, + "Partial success on batch of ${metrics.size} metric event(s): " + + "server rejected ${result.partial.rejectedCount} " + + "(${result.partial.errorMessage ?: "no error message"})" + ) + is DispatchResult.NonRetryableFailure -> + Log.w(OBSERVE_TAG, "Dropping batch of ${metrics.size} metric event(s): ${result.reason}") + DispatchResult.PayloadTooLarge -> if (metrics.size == 1) { + Log.w(OBSERVE_TAG, "Dropping metric event that exceeds the server's payload limit") } - if (!DispatchUtils.shouldRemovePending(result)) { - break + DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit + } + if (result is DispatchResult.PayloadTooLarge && metrics.size > 1) { + chunkSize = metrics.size / 2 + continue + } + when (result) { + DispatchResult.Success, is DispatchResult.PartialSuccess -> { + cursor = highestId + ObservePreferences.setLastDispatchedMetricId(context, cursor) } - pendingMetricsManager.removePendingMetrics(dispatchedMetricIds) - // A systematic rejection or an oversized record: leave the rest for the next run. - if (result is DispatchResult.NonRetryableFailure || result is DispatchResult.PayloadTooLarge) { + is DispatchResult.NonRetryableFailure, DispatchResult.PayloadTooLarge -> { + ObservePreferences.setLastDispatchedMetricId(context, highestId) break } + is DispatchResult.RetryableFailure -> break } } } /** * Dispatches log events to `/v1/logs`. Independent from the metrics path — - * a logs failure doesn't affect the metrics pending table and vice versa. + * a logs failure doesn't affect the metrics cursor and vice versa. */ suspend fun dispatchUnsentLogs(): Unit = logsDispatchMutex.withLock { - if (!pendingLogsManager.hasPendingLogs()) { - return - } - if (retryGateBlocks(logsRetryGate, "logs")) { return } + repairLogCursorIfStale(context, sessionManager) if (!shouldDispatch()) { - pendingLogsManager.removeAllPendingLogs() + val maxId = sessionManager.getMaxLogId() ?: -1 + if (ObservePreferences.getLastDispatchedLogId(context) != maxId) { + ObservePreferences.setLastDispatchedLogId(context, maxId) + } return } + var cursor = ObservePreferences.getLastDispatchedLogId(context) var chunkSize = dispatchChunkSize while (currentCoroutineContext().isActive) { - val pendingIds = pendingLogsManager.getPendingLogIds(chunkSize) - // Use the default for the next batch unless a 413 below overrides it. Re-discovering - // the limit each batch is fine: the server accepts payloads over 1 MB, so the default - // chunk stays far below the limit and a 413 is exceptional. + val logs = sessionManager.getLogs(cursor, chunkSize) chunkSize = dispatchChunkSize - if (pendingIds.isEmpty()) { + if (logs.isEmpty()) { break } - val sessionsWithPendingLogs = sessionManager.getSessionsWithLogs(pendingIds) - - // Clean up orphaned pending IDs (logs deleted from the `logs` table but - // still tracked in `pending_logs`). - val resolvedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId }.toSet() - val orphanedIds = pendingIds.filter { it !in resolvedLogIds } - if (orphanedIds.isNotEmpty()) { - pendingLogsManager.removePendingLogs(orphanedIds) - } - - if (sessionsWithPendingLogs.isNotEmpty()) { - val events = sessionsWithPendingLogs.map { sessionWithLogs -> + val highestId = logs.last().id + val logsBySessionId = logs.groupBy { it.sessionId } + val sessions = sessionManager.getSessions(logsBySessionId.keys).associateBy { it.id } + val events = logsBySessionId.mapNotNull { (sessionId, sessionLogs) -> + sessions[sessionId]?.let { session -> Event( - metadata = Metadata.fromSessionMetadata(sessionWithLogs.session), + metadata = Metadata.fromSessionMetadata(session), metrics = emptyList(), - logs = sessionWithLogs.logs.map { LogEvent.fromLogRecord(it) } + logs = sessionLogs.map(LogEvent::fromLogRecord) ) } + } + if (events.isEmpty()) { + cursor = highestId + ObservePreferences.setLastDispatchedLogId(context, cursor) + continue + } - val result = eventDispatcher.dispatchLogs(events) - logsRetryGate = nextGate(logsRetryGate, result) - val dispatchedLogIds = sessionsWithPendingLogs.flatMap { it.logs }.map { it.logId } - when (result) { - is DispatchResult.PartialSuccess -> - Log.w( - OBSERVE_TAG, - "Partial success on batch of ${dispatchedLogIds.size} log event(s): " + - "server rejected ${result.partial.rejectedCount} " + - "(${result.partial.errorMessage ?: "no error message"})" - ) - is DispatchResult.NonRetryableFailure -> - Log.w( - OBSERVE_TAG, - "Dropping batch of ${dispatchedLogIds.size} log event(s): ${result.reason}" - ) - is DispatchResult.PayloadTooLarge -> - if (dispatchedLogIds.size == 1) { - Log.w(OBSERVE_TAG, "Dropping log event that exceeds the server's payload limit") - } - is DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit - } - if (result is DispatchResult.PayloadTooLarge && dispatchedLogIds.size > 1) { - chunkSize = dispatchedLogIds.size / 2 - // Keep the pending logs, but retry immediately with a smaller chunk. - continue + val result = eventDispatcher.dispatchLogs(events) + logsRetryGate = nextGate(logsRetryGate, result) + when (result) { + is DispatchResult.PartialSuccess -> + Log.w( + OBSERVE_TAG, + "Partial success on batch of ${logs.size} log event(s): " + + "server rejected ${result.partial.rejectedCount} " + + "(${result.partial.errorMessage ?: "no error message"})" + ) + is DispatchResult.NonRetryableFailure -> + Log.w(OBSERVE_TAG, "Dropping batch of ${logs.size} log event(s): ${result.reason}") + DispatchResult.PayloadTooLarge -> if (logs.size == 1) { + Log.w(OBSERVE_TAG, "Dropping log event that exceeds the server's payload limit") } - if (!DispatchUtils.shouldRemovePending(result)) { - break + DispatchResult.Success, is DispatchResult.RetryableFailure -> Unit + } + if (result is DispatchResult.PayloadTooLarge && logs.size > 1) { + chunkSize = logs.size / 2 + continue + } + when (result) { + DispatchResult.Success, is DispatchResult.PartialSuccess -> { + cursor = highestId + ObservePreferences.setLastDispatchedLogId(context, cursor) } - pendingLogsManager.removePendingLogs(dispatchedLogIds) - // A systematic rejection or an oversized record: leave the rest for the next run. - if (result is DispatchResult.NonRetryableFailure || result is DispatchResult.PayloadTooLarge) { + is DispatchResult.NonRetryableFailure, DispatchResult.PayloadTooLarge -> { + ObservePreferences.setLastDispatchedLogId(context, highestId) break } + is DispatchResult.RetryableFailure -> break } } } @@ -338,10 +295,16 @@ class BaseObservabilityManager( } suspend fun cleanup() { - pendingMetricsManager.cleanupOldPendingMetrics() - pendingLogsManager.cleanupOldPendingLogs() // TODO(@ubax): Move sessionManager.cleanupOldSessions out of eas observe sessionManager.cleanupOldSessions() + // Remove the database used by the old pending telemetry queues. + context.deleteDatabase("eas_observe") sessionManager.cleanupOldLogs() } + + companion object { + // Serialize foreground and background dispatches without blocking the other signal. + private val metricsDispatchMutex = Mutex() + private val logsDispatchMutex = Mutex() + } } diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservePreferences.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservePreferences.kt index 0cc18fbd56999b..9d23e89df40c45 100644 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservePreferences.kt +++ b/packages/expo-observe/android/src/main/java/expo/modules/observe/ObservePreferences.kt @@ -8,6 +8,8 @@ import kotlinx.serialization.json.Json private const val PREFS_NAME = "dev.expo.observe" private const val KEY_CONFIG = "config" private const val KEY_BUNDLE_DEFAULTS = "bundleDefaults" +private const val KEY_LAST_DISPATCHED_METRIC_ID = "lastDispatchedMetricId" +private const val KEY_LAST_DISPATCHED_LOG_ID = "lastDispatchedLogId" /** * Snapshot of the last `configure(...)` payload @@ -54,4 +56,24 @@ object ObservePreferences { putString(KEY_BUNDLE_DEFAULTS, Json.encodeToString(defaults)) } } + + fun getLastDispatchedMetricId(context: Context): Long = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getLong(KEY_LAST_DISPATCHED_METRIC_ID, -1) + + fun setLastDispatchedMetricId(context: Context, id: Long) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit(commit = true) { + putLong(KEY_LAST_DISPATCHED_METRIC_ID, id) + } + } + + fun getLastDispatchedLogId(context: Context): Long = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getLong(KEY_LAST_DISPATCHED_LOG_ID, -1) + + fun setLastDispatchedLogId(context: Context, id: Long) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit(commit = true) { + putLong(KEY_LAST_DISPATCHED_LOG_ID, id) + } + } } diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt deleted file mode 100644 index 3292453a69881e..00000000000000 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/ObserveDatabase.kt +++ /dev/null @@ -1,94 +0,0 @@ -package expo.modules.observe.storage - -import android.content.Context -import androidx.room.Dao -import androidx.room.Database -import androidx.room.Entity -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.PrimaryKey -import androidx.room.Query -import androidx.room.Room -import androidx.room.RoomDatabase - -// TODO: Draining large backlogs from both pending tables reruns `ORDER BY addedAt` -// for every chunk. Consider `Index("addedAt")` and a version bump if this matters in practice. -@Entity(tableName = "pending_metrics") -data class PendingMetric( - @PrimaryKey val metricId: String, - // ISO 8601 timestamp - val addedAt: String -) - -@Entity(tableName = "pending_logs") -data class PendingLog( - @PrimaryKey val logId: String, - // ISO 8601 timestamp - val addedAt: String -) - -@Dao -interface PendingMetricDao { - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertAll(metrics: List) - - @Query("SELECT metricId FROM pending_metrics ORDER BY addedAt ASC LIMIT :limit") - suspend fun getMetricIds(limit: Int): List - - @Query("SELECT EXISTS(SELECT 1 FROM pending_metrics)") - suspend fun hasMetricIds(): Boolean - - @Query("DELETE FROM pending_metrics") - suspend fun deleteAll() - - @Query("DELETE FROM pending_metrics WHERE metricId IN (:metricIds)") - suspend fun deleteByIds(metricIds: List) - - @Query("DELETE FROM pending_metrics WHERE addedAt < :cutoffTimestamp") - suspend fun deleteOlderThan(cutoffTimestamp: String) -} - -@Dao -interface PendingLogDao { - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertAll(logs: List) - - @Query("SELECT logId FROM pending_logs ORDER BY addedAt ASC LIMIT :limit") - suspend fun getLogIds(limit: Int): List - - @Query("SELECT EXISTS(SELECT 1 FROM pending_logs)") - suspend fun hasLogIds(): Boolean - - @Query("DELETE FROM pending_logs") - suspend fun deleteAll() - - @Query("DELETE FROM pending_logs WHERE logId IN (:logIds)") - suspend fun deleteByIds(logIds: List) - - @Query("DELETE FROM pending_logs WHERE addedAt < :cutoffTimestamp") - suspend fun deleteOlderThan(cutoffTimestamp: String) -} - -@Database(entities = [PendingMetric::class, PendingLog::class], version = 2, exportSchema = false) -abstract class ObserveDatabase : RoomDatabase() { - abstract fun pendingMetricDao(): PendingMetricDao - - abstract fun pendingLogDao(): PendingLogDao - - companion object { - @Volatile - private var INSTANCE: ObserveDatabase? = null - - fun getDatabase(context: Context): ObserveDatabase = - INSTANCE ?: synchronized(this) { - INSTANCE ?: Room - .databaseBuilder( - context.applicationContext, - ObserveDatabase::class.java, - "eas_observe" - ).fallbackToDestructiveMigration() - .build() - .also { INSTANCE = it } - } - } -} diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt deleted file mode 100644 index 542e2268242ebc..00000000000000 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingLogsManager.kt +++ /dev/null @@ -1,42 +0,0 @@ -package expo.modules.observe.storage - -import android.content.Context -import androidx.room.withTransaction -import expo.modules.appmetrics.utils.TimeUtils -import expo.modules.observe.SQLITE_MAX_BIND_VARIABLES - -class PendingLogsManager( - context: Context, - database: ObserveDatabase? = null -) { - private val database: ObserveDatabase = database ?: ObserveDatabase.getDatabase(context) - - suspend fun addPendingLogs(logIds: List) { - val now = TimeUtils.getCurrentTimestampInISOFormat() - val pendingLogs = logIds.map { PendingLog(logId = it, addedAt = now) } - database.pendingLogDao().insertAll(pendingLogs) - } - - suspend fun getPendingLogIds(limit: Int): List = database.pendingLogDao().getLogIds(limit) - - suspend fun hasPendingLogs(): Boolean = database.pendingLogDao().hasLogIds() - - suspend fun removeAllPendingLogs() = database.pendingLogDao().deleteAll() - - suspend fun removePendingLogs(logIds: List) { - database.withTransaction { - logIds.chunked(SQLITE_MAX_BIND_VARIABLES).forEach { chunk -> - database.pendingLogDao().deleteByIds(chunk) - } - } - } - - suspend fun cleanupOldPendingLogs() { - val cutoffTimestamp = TimeUtils.getTimestampInISOFormatFromPast(SECONDS_TO_REMOVE_OLD_PENDING_LOGS) - database.pendingLogDao().deleteOlderThan(cutoffTimestamp) - } - - companion object { - private const val SECONDS_TO_REMOVE_OLD_PENDING_LOGS: Long = 7 * 24 * 60 * 60 // 7 days in seconds - } -} diff --git a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt b/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt deleted file mode 100644 index 7db4f82fbf3190..00000000000000 --- a/packages/expo-observe/android/src/main/java/expo/modules/observe/storage/PendingMetricsManager.kt +++ /dev/null @@ -1,42 +0,0 @@ -package expo.modules.observe.storage - -import android.content.Context -import androidx.room.withTransaction -import expo.modules.appmetrics.utils.TimeUtils -import expo.modules.observe.SQLITE_MAX_BIND_VARIABLES - -class PendingMetricsManager( - context: Context, - database: ObserveDatabase? = null -) { - private val database: ObserveDatabase = database ?: ObserveDatabase.getDatabase(context) - - suspend fun addPendingMetrics(metricIds: List) { - val now = TimeUtils.getCurrentTimestampInISOFormat() - val pendingMetrics = metricIds.map { PendingMetric(metricId = it, addedAt = now) } - database.pendingMetricDao().insertAll(pendingMetrics) - } - - suspend fun getPendingMetricIds(limit: Int): List = database.pendingMetricDao().getMetricIds(limit) - - suspend fun hasPendingMetrics(): Boolean = database.pendingMetricDao().hasMetricIds() - - suspend fun removeAllPendingMetrics() = database.pendingMetricDao().deleteAll() - - suspend fun removePendingMetrics(metricIds: List) { - database.withTransaction { - metricIds.chunked(SQLITE_MAX_BIND_VARIABLES).forEach { chunk -> - database.pendingMetricDao().deleteByIds(chunk) - } - } - } - - suspend fun cleanupOldPendingMetrics() { - val cutoffTimestamp = TimeUtils.getTimestampInISOFormatFromPast(SECONDS_TO_REMOVE_OLD_PENDING_METRICS) - database.pendingMetricDao().deleteOlderThan(cutoffTimestamp) - } - - companion object { - private const val SECONDS_TO_REMOVE_OLD_PENDING_METRICS: Long = 7 * 24 * 60 * 60 // 7 days in seconds - } -} diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt index a4089870f26cba..7f7352ec2ba18d 100644 --- a/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt +++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/BaseObservabilityManagerTest.kt @@ -1,55 +1,56 @@ package expo.modules.observe import android.content.Context -import expo.modules.observe.storage.PendingMetricsManager +import expo.modules.appmetrics.storage.LogRecord import expo.modules.appmetrics.storage.Metric import expo.modules.appmetrics.storage.Session -import expo.modules.appmetrics.storage.LogRecord import expo.modules.appmetrics.storage.SessionManager -import expo.modules.appmetrics.storage.SessionWithLogs -import expo.modules.appmetrics.storage.SessionWithMetrics -import expo.modules.appmetrics.utils.TimeUtils -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE, sdk = [28]) class BaseObservabilityManagerTest { - private lateinit var mockContext: Context - private lateinit var mockSessionManager: SessionManager - private lateinit var mockEventDispatcher: EventDispatcher - private lateinit var mockPendingMetricsManager: PendingMetricsManager - private lateinit var mockPendingLogsManager: expo.modules.observe.storage.PendingLogsManager - - private val testProjectId = "test-project-123" - private val testBaseUrl = "https://test.example.com/" + private val context = mockk(relaxed = true) + private val sessionManager = mockk(relaxed = true) + private val eventDispatcher = mockk(relaxed = true) + private var metricCursor = -1L + private var logCursor = -1L @Before fun setUp() { - mockContext = mockk(relaxed = true) - mockSessionManager = mockk(relaxed = true) - mockEventDispatcher = mockk(relaxed = true) - mockPendingMetricsManager = mockk(relaxed = true) - mockPendingLogsManager = mockk(relaxed = true) - coEvery { mockPendingMetricsManager.hasPendingMetrics() } returns true - coEvery { mockPendingLogsManager.hasPendingLogs() } returns true - - // Default to enabled so existing tests aren't short-circuited mockkObject(ObservePreferences) - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = true, sampleRate = null) + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = true) every { ObservePreferences.getBundleDefaults(any()) } returns null + every { ObservePreferences.getLastDispatchedMetricId(any()) } answers { metricCursor } + every { ObservePreferences.setLastDispatchedMetricId(any(), any()) } answers { + metricCursor = secondArg() + } + every { ObservePreferences.getLastDispatchedLogId(any()) } answers { logCursor } + every { ObservePreferences.setLastDispatchedLogId(any(), any()) } answers { + logCursor = secondArg() + } + coEvery { sessionManager.getMaxMetricId() } returns null + coEvery { sessionManager.getMaxLogId() } returns null } @After @@ -57,1281 +58,532 @@ class BaseObservabilityManagerTest { unmockkAll() } - // region dispatchingEnabled tests + // region Dispatching enabled tests @Test - fun `when dispatchingEnabled is false, pending metrics are removed without dispatching`() = - runTest { - // Arrange - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = false) - - val manager = createManager() + fun `disabled dispatch fast forwards both cursors`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = false) + coEvery { sessionManager.getMaxMetricId() } returns 12 + coEvery { sessionManager.getMaxLogId() } returns 34 + val manager = createManager() - // Act - manager.dispatchUnsentMetrics() + manager.dispatchUnsentMetrics() + manager.dispatchUnsentLogs() - // Assert - dispatch is never called - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - - // Assert - sessions are never fetched - coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) } - - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - coVerify(exactly = 0) { mockPendingMetricsManager.getPendingMetricIds(any()) } - } + assertEquals(12, metricCursor) + assertEquals(34, logCursor) + coVerify(exactly = 0) { sessionManager.getMetrics(any(), any()) } + coVerify(exactly = 0) { sessionManager.getLogs(any(), any()) } + } @Test - fun `when dispatchingEnabled is null on a stored config and isDebugBuild is false, metrics are dispatched`() = - runTest { - // Arrange — a stored config without an explicit dispatchingEnabled doesn't suppress dispatch on release builds. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = null) - val devMetric = createMetric("metric1", metricId = "dev-metric-id") - val devSession = createSessionWithMetrics( - sessionId = "dev-session", - environment = "development", - metrics = listOf(devMetric) - ) + fun `disabled dispatch does not rewrite unchanged cursors`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = false) + metricCursor = 12 + logCursor = 34 + coEvery { sessionManager.getMaxMetricId() } returns 12 + coEvery { sessionManager.getMaxLogId() } returns 34 + val manager = createManager() - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIds.addAll(firstArg>()) - } - - val manager = createManager(isDebugBuild = false) - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify { - mockEventDispatcher.dispatch( - match { events -> - events.size == 1 && events[0].metadata.environment == "development" - } - ) - } - assertEquals(1, removedIds.size) - assertTrue(removedIds.contains("dev-metric-id")) - } - - @Test - fun `when dispatchingEnabled is true, and isDebugBuild is false, metrics are dispatched`() = - runTest { - // Arrange — explicit opt-in lifts the debug default. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = true) - val devMetric = createMetric("metric1", metricId = "dev-metric-id") - val devSession = createSessionWithMetrics( - sessionId = "dev-session", - environment = "development", - metrics = listOf(devMetric) - ) + manager.dispatchUnsentMetrics() + manager.dispatchUnsentLogs() - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIds.addAll(firstArg>()) - } - - val manager = createManager(isDebugBuild = false) - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify { - mockEventDispatcher.dispatch( - match { events -> - events.size == 1 && events[0].metadata.environment == "development" - } - ) - } - assertEquals(1, removedIds.size) - assertTrue(removedIds.contains("dev-metric-id")) - } + assertEquals(12, metricCursor) + assertEquals(34, logCursor) + verify(exactly = 0) { ObservePreferences.setLastDispatchedMetricId(any(), any()) } + verify(exactly = 0) { ObservePreferences.setLastDispatchedLogId(any(), any()) } + } @Test - fun `when stored config is absent and isDebugBuild is false, metrics are dispatched`() = - runTest { - // Arrange — release builds default to on. - every { ObservePreferences.getConfig(any()) } returns null - val prodMetric = createMetric("metric1", metricId = "prod-metric-id") - val prodSession = createSessionWithMetrics( - sessionId = "prod-session", - environment = "production", - metrics = listOf(prodMetric) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("prod-metric-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(prodSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success + fun `when dispatchingEnabled is null on a stored config and isDebugBuild is false, metrics are dispatched`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = null) + stubMetricDispatch() - val manager = createManager(isDebugBuild = false) + createManager().dispatchUnsentMetrics() - // Act - manager.dispatchUnsentMetrics() + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } - // Assert - coVerify { - mockEventDispatcher.dispatch( - match { events -> - events.size == 1 && events[0].metadata.environment == "production" - } - ) - } - } + @Test + fun `when stored config is absent and isDebugBuild is false, metrics are dispatched`() = runTest { + every { ObservePreferences.getConfig(any()) } returns null + stubMetricDispatch() - // endregion + createManager().dispatchUnsentMetrics() - // region dispatchInDebug tests + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } @Test - fun `when dispatchInDebug is true on debug build, metrics are dispatched`() = - runTest { - // Arrange — explicit opt-in lifts the debug-build gate. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = true) - val devMetric = createMetric("metric1", metricId = "dev-metric-id") - val devSession = createSessionWithMetrics( - sessionId = "dev-session", - environment = "development", - metrics = listOf(devMetric) - ) + fun `when dispatchingEnabled is false, dispatchInDebug and sampleRate have no effect`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig( + dispatchingEnabled = false, + dispatchInDebug = true, + sampleRate = 1.0 + ) + coEvery { sessionManager.getMaxMetricId() } returns 10 - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success + createManager(isDebugBuild = true, deterministicUniformValue = 0.0).dispatchUnsentMetrics() - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIds.addAll(firstArg>()) - } + assertEquals(10, metricCursor) + coVerify(exactly = 0) { eventDispatcher.dispatch(any()) } + } - val manager = createManager(isDebugBuild = true) + // endregion - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - assertEquals(listOf("dev-metric-id"), removedIds) - } + // region Dispatch in debug tests @Test - fun `when dispatchInDebug is false explicitly on debug build, pending metrics are removed without dispatching`() = - runTest { - // Arrange — explicit opt-out behaves like the default on debug builds. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false) - - val manager = createManager(isDebugBuild = true) + fun `when dispatchInDebug is true on debug build, metrics are dispatched`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = true) + stubMetricDispatch() - // Act - manager.dispatchUnsentMetrics() + createManager(isDebugBuild = true).dispatchUnsentMetrics() - // Assert — short-circuit: no session lookup, no dispatch, and the pending table is cleared. - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } @Test - fun `when dispatchInDebug is true on release build, metrics dispatch normally`() = - runTest { - // Arrange — dispatchInDebug is a no-op on release builds; release always dispatches (subject to other gates). - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = true) - val prodMetric = createMetric("metric1", metricId = "prod-metric-id") - val prodSession = createSessionWithMetrics( - sessionId = "prod-session", - environment = "production", - metrics = listOf(prodMetric) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("prod-metric-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(prodSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success + fun `when dispatchInDebug is false explicitly on debug build, metric cursor is fast forwarded without dispatching`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false) + coEvery { sessionManager.getMaxMetricId() } returns 10 - val manager = createManager(isDebugBuild = false) + createManager(isDebugBuild = true).dispatchUnsentMetrics() - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + assertEquals(10, metricCursor) + coVerify(exactly = 0) { eventDispatcher.dispatch(any()) } + } @Test - fun `when dispatchingEnabled is false, dispatchInDebug being true has no effect`() = - runTest { - // Arrange — dispatchingEnabled=false wins over dispatchInDebug=true. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig( - dispatchingEnabled = false, - dispatchInDebug = true - ) - val manager = createManager(isDebugBuild = true) + fun `when dispatchInDebug is false on release build, metrics dispatch normally`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false) + stubMetricDispatch() - // Act - manager.dispatchUnsentMetrics() + createManager(isDebugBuild = false).dispatchUnsentMetrics() - // Assert - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } // endregion - // region Combined isDev signal tests (isJsDev || isDebugBuild) + // region Combined dev mode tests @Test - fun `when isJsDev is true on release native build, dispatchInDebug=false discards metrics`() = - runTest { - // Arrange — release native binary running a Metro dev JS bundle. JS dev alone gates dispatch. - every { ObservePreferences.getBundleDefaults(any()) } returns - PersistedBundleDefaults(environment = "development", isJsDev = true) - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false) - val manager = createManager(isDebugBuild = false) - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + fun `when isJsDev is true on release native build, dispatchInDebug false fast forwards the metric cursor`() = runTest { + every { ObservePreferences.getBundleDefaults(any()) } returns + PersistedBundleDefaults(environment = "development", isJsDev = true) + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false) + coEvery { sessionManager.getMaxMetricId() } returns 10 - @Test - fun `when isJsDev is true and isDebugBuild is true, dispatchInDebug=true dispatches`() = - runTest { - // Arrange — both signals say dev; explicit override lifts the gate. - every { ObservePreferences.getBundleDefaults(any()) } returns - PersistedBundleDefaults(environment = "development", isJsDev = true) - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = true) - val devMetric = createMetric("metric1", metricId = "dev-metric-id") - val devSession = createSessionWithMetrics( - sessionId = "dev-session", - environment = "development", - metrics = listOf(devMetric) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("dev-metric-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(devSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs - - val manager = createManager(isDebugBuild = true) - - // Act - manager.dispatchUnsentMetrics() + createManager(isDebugBuild = false).dispatchUnsentMetrics() - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + assertEquals(10, metricCursor) + coVerify(exactly = 0) { eventDispatcher.dispatch(any()) } + } @Test - fun `when isJsDev is false and isDebugBuild is false, metrics dispatch (release everywhere)`() = - runTest { - // Arrange — full release path. - every { ObservePreferences.getBundleDefaults(any()) } returns - PersistedBundleDefaults(environment = "production", isJsDev = false) - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false) - val prodMetric = createMetric("metric1", metricId = "prod-metric-id") - val prodSession = createSessionWithMetrics( - sessionId = "prod-session", - environment = "production", - metrics = listOf(prodMetric) - ) + fun `when isJsDev is true and isDebugBuild is true, dispatchInDebug true dispatches`() = runTest { + every { ObservePreferences.getBundleDefaults(any()) } returns + PersistedBundleDefaults(environment = "development", isJsDev = true) + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = true) + stubMetricDispatch() - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("prod-metric-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(prodSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } just runs + createManager(isDebugBuild = true).dispatchUnsentMetrics() - val manager = createManager(isDebugBuild = false) - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } - - @Test - fun `when getBundleDefaults returns null, gate is determined by isDebugBuild alone`() = - runTest { - // Arrange — cold start before JS has run. isJsDev defaults to false. - every { ObservePreferences.getBundleDefaults(any()) } returns null - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchInDebug = false) - val manager = createManager(isDebugBuild = true) - - // Act - manager.dispatchUnsentMetrics() - - // Assert — isDebugBuild alone gates dispatch. - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } // endregion - // region sampleRate tests + // region Sample rate tests @Test - fun `when sampleRate is null, metrics dispatch normally`() = - runTest { - // Arrange - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = null) - val metric = createMetric("metric1", metricId = "id1") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - // `deterministicUniformValue` must not matter when sampleRate is null. - val manager = createManager(deterministicUniformValue = 0.999) + fun `when sampleRate is null, metrics dispatch normally`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = null) + stubMetricDispatch() - // Act - manager.dispatchUnsentMetrics() + createManager(deterministicUniformValue = 0.999).dispatchUnsentMetrics() - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } @Test - fun `when deterministicUniformValue is less than sampleRate, metrics dispatch`() = - runTest { - // Arrange — sampleRate = 0.5, device value = 0.2 → in-sample - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5) - val metric = createMetric("metric1", metricId = "id1") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val manager = createManager(deterministicUniformValue = 0.2) + fun `when deterministicUniformValue is less than sampleRate, metrics dispatch`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5) + stubMetricDispatch() - // Act - manager.dispatchUnsentMetrics() + createManager(deterministicUniformValue = 0.2).dispatchUnsentMetrics() - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } @Test - fun `when deterministicUniformValue is greater than sampleRate, metrics are dropped without dispatching`() = - runTest { - // Arrange — sampleRate = 0.5, device value = 0.8 → out-of-sample - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5) + fun `when deterministicUniformValue is equal to sampleRate, metric cursor is fast forwarded`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5) + coEvery { sessionManager.getMaxMetricId() } returns 10 - val manager = createManager(deterministicUniformValue = 0.8) + createManager(deterministicUniformValue = 0.5).dispatchUnsentMetrics() - // Act - manager.dispatchUnsentMetrics() - - // Assert — no dispatch, pending is cleared - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + assertEquals(10, metricCursor) + coVerify(exactly = 0) { eventDispatcher.dispatch(any()) } + } @Test - fun `when deterministicUniformValue is equal to sampleRate, metrics are dropped without dispatching`() = - runTest { - // Arrange — sampleRate = 0.5, device value = 0.5 → out-of-sample (comparison is strict <). - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5) - - val manager = createManager(deterministicUniformValue = 0.5) + fun `when deterministicUniformValue is greater than sampleRate, metric cursor is fast forwarded`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.5) + coEvery { sessionManager.getMaxMetricId() } returns 10 - // Act - manager.dispatchUnsentMetrics() + createManager(deterministicUniformValue = 0.8).dispatchUnsentMetrics() - // Assert — no dispatch, pending is cleared - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } - - @Test - fun `when sampleRate is 0_0, metrics are always dropped`() = - runTest { - // Arrange — any deterministic value is >= 0, so sampleRate=0 → out. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.0) - val manager = createManager(deterministicUniformValue = 0.0) - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + assertEquals(10, metricCursor) + coVerify(exactly = 0) { eventDispatcher.dispatch(any()) } + } @Test - fun `when sampleRate is 1_0, metrics always dispatch`() = - runTest { - // Arrange — deterministic value of 0.999... is always < 1.0. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 1.0) - val metric = createMetric("metric1", metricId = "id1") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val manager = createManager(deterministicUniformValue = 0.9999999) + fun `when sampleRate is 0_0, metric cursor is always fast forwarded`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 0.0) + coEvery { sessionManager.getMaxMetricId() } returns 10 - // Act - manager.dispatchUnsentMetrics() + createManager(deterministicUniformValue = 0.0).dispatchUnsentMetrics() - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + assertEquals(10, metricCursor) + coVerify(exactly = 0) { eventDispatcher.dispatch(any()) } + } @Test - fun `sampleRate above 1_0 is clamped and metrics dispatch`() = - runTest { - // Arrange — 2.0 → clamped to 1.0 → in-sample. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 2.0) - val metric = createMetric("metric1", metricId = "id1") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val manager = createManager(deterministicUniformValue = 0.95) + fun `when sampleRate is 1_0, metrics always dispatch`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 1.0) + stubMetricDispatch() - // Act - manager.dispatchUnsentMetrics() + createManager(deterministicUniformValue = 0.999).dispatchUnsentMetrics() - // Assert - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } @Test - fun `sampleRate below 0_0 is clamped and metrics are dropped`() = - runTest { - // Arrange — -0.5 → clamped to 0.0 → out-of-sample. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = -0.5) - val manager = createManager(deterministicUniformValue = 0.0) - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + fun `sampleRate outside 0_0 to 1_0 is clamped`() = runTest { + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = 2.0) + stubMetricDispatch() + createManager(deterministicUniformValue = 0.95).dispatchUnsentMetrics() + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } - @Test - fun `when dispatchingEnabled is false, sampleRate of 1_0 still drops metrics`() = - runTest { - // Arrange — dispatchingEnabled=false wins over sampleRate=1.0. - every { ObservePreferences.getConfig(any()) } returns PersistedConfig(dispatchingEnabled = false, sampleRate = 1.0) - val manager = createManager(deterministicUniformValue = 0.0) - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.removeAllPendingMetrics() } - } + metricCursor = -1 + every { ObservePreferences.getConfig(any()) } returns PersistedConfig(sampleRate = -0.5) + coEvery { sessionManager.getMaxMetricId() } returns 10 + createManager(deterministicUniformValue = 0.0).dispatchUnsentMetrics() + + assertEquals(10, metricCursor) + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } // endregion - // region Fetching metrics tests + // region Fetching and dispatch tests @Test - fun `dispatchUnsentMetrics does nothing when no pending metrics exist`() = - runTest { - // Arrange - coEvery { mockPendingMetricsManager.hasPendingMetrics() } returns false + fun `dispatchUnsentMetrics transforms grouped metrics to events and advances the cursor`() = runTest { + val metrics = listOf(metric(1, "one"), metric(2, "two")) + coEvery { sessionManager.getMaxMetricId() } returns 2 + coEvery { sessionManager.getMetrics(-1, 2) } returns metrics + coEvery { sessionManager.getMetrics(2, 2) } returns emptyList() + coEvery { sessionManager.getSessions(setOf("session")) } returns listOf(session()) + coEvery { eventDispatcher.dispatch(any()) } returns DispatchResult.Success - val manager = createManager() + createManager(chunkSize = 2).dispatchUnsentMetrics() - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 0) { mockPendingMetricsManager.removePendingMetrics(any()) } - coVerify(exactly = 0) { mockPendingMetricsManager.getPendingMetricIds(any()) } - coVerify(exactly = 0) { mockSessionManager.getSessionsWithMetrics(any()) } + assertEquals(2, metricCursor) + coVerify { + eventDispatcher.dispatch( + match { events -> events.single().metrics.map { it.name } == listOf("one", "two") } + ) } + } @Test - fun `dispatchUnsentMetrics fetches pending IDs then sessions from SessionManager`() = - runTest { - // Arrange - val pendingIds = listOf("metric-1", "metric-2") - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(pendingIds, emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(pendingIds) } returns emptyList() - - val manager = createManager() + fun `dispatchUnsentMetrics advances past all rows with no matching sessions`() = runTest { + coEvery { sessionManager.getMaxMetricId() } returns 1 + coEvery { sessionManager.getMetrics(-1, any()) } returns listOf(metric(1, "orphan")) + coEvery { sessionManager.getMetrics(1, any()) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } returns emptyList() - // Act - manager.dispatchUnsentMetrics() + createManager().dispatchUnsentMetrics() - // Assert - coVerify(exactly = 2) { mockPendingMetricsManager.getPendingMetricIds(any()) } - coVerify(exactly = 1) { mockSessionManager.getSessionsWithMetrics(pendingIds) } - } + assertEquals(1, metricCursor) + coVerify(exactly = 0) { eventDispatcher.dispatch(any()) } + } @Test - fun `dispatchUnsentMetrics cleans up orphaned pending IDs with no matching metrics`() = - runTest { - // Arrange - pending IDs exist but only some map to actual metrics - val metric1 = createMetric("metric1", metricId = "id1") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric1) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "orphaned-id"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val removedIdBatches = mutableListOf>() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIdBatches.add(firstArg>().toList()) - } - - val manager = createManager() - - // Act - manager.dispatchUnsentMetrics() + fun `dispatchUnsentMetrics dispatches valid rows and advances past rows with no matching sessions`() = runTest { + val metrics = listOf( + metric(1, "valid", sessionId = "valid-session"), + metric(2, "orphan", sessionId = "missing-session") + ) + coEvery { sessionManager.getMaxMetricId() } returns 2 + coEvery { sessionManager.getMetrics(-1, 2) } returns metrics + coEvery { sessionManager.getMetrics(2, 2) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } returns listOf(session(id = "valid-session")) + coEvery { eventDispatcher.dispatch(any()) } returns DispatchResult.Success - // Assert - orphaned ID is cleaned up in first batch, dispatched ID in second - assertEquals(2, removedIdBatches.size) - assertEquals(listOf("orphaned-id"), removedIdBatches[0]) - assertEquals(listOf("id1"), removedIdBatches[1]) - } + createManager(chunkSize = 2).dispatchUnsentMetrics() - @Test - fun `dispatchUnsentMetrics cleans up all orphaned pending IDs when no sessions match`() = - runTest { - // Arrange - all pending IDs are orphaned - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("orphan-1", "orphan-2"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns emptyList() - - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIds.addAll(firstArg>()) - } - - val manager = createManager() - - // Act - manager.dispatchUnsentMetrics() - - // Assert - orphaned IDs are cleaned up, no dispatch - coVerify(exactly = 0) { mockEventDispatcher.dispatch(any()) } - assertEquals(2, removedIds.size) - assertTrue(removedIds.contains("orphan-1")) - assertTrue(removedIds.contains("orphan-2")) - } - - @Test - fun `dispatchUnsentMetrics removes all metric IDs from pending after successful dispatch`() = - runTest { - // Arrange - val metric1 = createMetric("metric1", metricId = "id1") - val metric2 = createMetric("metric2", metricId = "id2") - val metric3 = createMetric("metric3", metricId = "id3") - val session1 = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric1, metric2) - ) - val session2 = createSessionWithMetrics( - sessionId = "session-2", - environment = "production", - metrics = listOf(metric3) + assertEquals(2, metricCursor) + coVerify { + eventDispatcher.dispatch( + match { events -> events.single().metrics.map { it.name } == listOf("valid") } ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "id2", "id3"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session1, session2) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIds.addAll(firstArg>()) - } - - val manager = createManager() - - // Act - manager.dispatchUnsentMetrics() - - // Assert - ALL metric IDs from ALL sessions are removed from pending - assertEquals("All 3 metric IDs should be removed from pending", 3, removedIds.size) - assertTrue("Metric id1 should be removed from pending", removedIds.contains("id1")) - assertTrue("Metric id2 should be removed from pending", removedIds.contains("id2")) - assertTrue("Metric id3 should be removed from pending", removedIds.contains("id3")) } + } @Test - fun `dispatchUnsentMetrics removes metric IDs from pending on NonRetryable result`() = - runTest { - // OTLP non-retryable responses (e.g. 400, 403, 404) drop the batch: the pending IDs - // are removed so the same rows don't keep getting refused on every dispatch round. - val metric1 = createMetric("metric1", metricId = "id1") - val metric2 = createMetric("metric2", metricId = "id2") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric1, metric2) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "id2"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.NonRetryableFailure("HTTP 400") - - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIds.addAll(firstArg>()) - } - - val manager = createManager() + fun `dispatchUnsentMetrics halves the chunk after 413 then resets to the default size`() = runTest { + val metrics = (1L..4L).map { metric(it, "metric-$it") } + coEvery { sessionManager.getMaxMetricId() } returns 4 + coEvery { sessionManager.getMetrics(-1, 4) } returns metrics + coEvery { sessionManager.getMetrics(-1, 2) } returns metrics.take(2) + coEvery { sessionManager.getMetrics(2, 4) } returns metrics.drop(2) + coEvery { sessionManager.getMetrics(4, 4) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatch(any()) } returnsMany listOf( + DispatchResult.PayloadTooLarge, + DispatchResult.Success, + DispatchResult.Success + ) - manager.dispatchUnsentMetrics() + createManager(chunkSize = 4).dispatchUnsentMetrics() - assertEquals(2, removedIds.size) - assertTrue(removedIds.contains("id1")) - assertTrue(removedIds.contains("id2")) - } + assertEquals(4, metricCursor) + coVerify(exactly = 1) { sessionManager.getMetrics(-1, 2) } + coVerify(exactly = 1) { sessionManager.getMetrics(2, 4) } + } @Test - fun `dispatchUnsentMetrics does not remove metric IDs from pending on Retryable result`() = - runTest { - // A retryable response (e.g. 503, transport error) should leave the pending IDs alone - // so the next dispatch round picks the same rows up again. - val metric1 = createMetric("metric1", metricId = "id1") - val metric2 = createMetric("metric2", metricId = "id2") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric1, metric2) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1", "id2"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.RetryableFailure() - - val manager = createManager() + fun `dispatchUnsentMetrics halves the fetched count after missing sessions`() = runTest { + val metrics = listOf( + metric(1, "one", sessionId = "valid-session"), + metric(2, "two", sessionId = "valid-session"), + metric(3, "orphan-three", sessionId = "missing-session"), + metric(4, "orphan-four", sessionId = "missing-session") + ) + coEvery { sessionManager.getMaxMetricId() } returns 4 + coEvery { sessionManager.getMetrics(-1, 4) } returns metrics + coEvery { sessionManager.getMetrics(-1, 2) } returns metrics.take(2) + coEvery { sessionManager.getMetrics(2, 4) } returns metrics.drop(2) + coEvery { sessionManager.getMetrics(4, 4) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } answers { + if ("valid-session" in firstArg>()) listOf(session(id = "valid-session")) else emptyList() + } + coEvery { eventDispatcher.dispatch(any()) } returnsMany listOf( + DispatchResult.PayloadTooLarge, + DispatchResult.Success + ) - // Act - manager.dispatchUnsentMetrics() + createManager(chunkSize = 4).dispatchUnsentMetrics() - // Assert - coVerify(exactly = 0) { mockPendingMetricsManager.removePendingMetrics(any()) } - } + assertEquals(4, metricCursor) + coVerify(exactly = 1) { sessionManager.getMetrics(-1, 2) } + coVerify(exactly = 0) { sessionManager.getMetrics(-1, 1) } + } @Test - fun `dispatchUnsentMetrics dispatches a backlog in successive chunks`() = - runTest { - val pendingIds = mutableListOf("metric-1", "metric-2", "metric-3") - val requestedChunks = mutableListOf>() - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { pendingIds.take(2) } - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - val ids = firstArg>() - requestedChunks.add(ids) - ids.map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - val ids = firstArg>() - removedIds.addAll(ids) - pendingIds.removeAll(ids.toSet()) - } - - createManager(dispatchChunkSize = 2).dispatchUnsentMetrics() - - assertEquals( - listOf(listOf("metric-1", "metric-2"), listOf("metric-3")), - requestedChunks - ) - assertEquals(listOf("metric-1", "metric-2", "metric-3"), removedIds) - coVerify(exactly = 2) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 3) { mockPendingMetricsManager.getPendingMetricIds(2) } - } + fun `dispatchUnsentMetrics drops a non-retryable chunk and stops`() = runTest { + val metrics = listOf(metric(1, "one"), metric(2, "two")) + coEvery { sessionManager.getMaxMetricId() } returns 3 + coEvery { sessionManager.getMetrics(-1, 2) } returns metrics + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatch(any()) } returns DispatchResult.NonRetryableFailure("HTTP 400") - @Test - fun `dispatchUnsentMetrics stops reading chunks after cancellation`() = - runTest { - val pendingIds = mutableListOf("metric-1", "metric-2", "metric-3") - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { pendingIds.take(2) } - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - firstArg>().map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } coAnswers { - pendingIds.removeAll(firstArg>().toSet()) - currentCoroutineContext().cancel() - } - - launch { - createManager(dispatchChunkSize = 2).dispatchUnsentMetrics() - }.join() - - assertEquals(listOf("metric-3"), pendingIds) - coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(2) } - } + createManager(chunkSize = 2).dispatchUnsentMetrics() - @Test - fun `dispatchUnsentMetrics halves the chunk after 413 then resets to the default size`() = - runTest { - val requestedLimits = mutableListOf() - val defaultChunks = ArrayDeque( - listOf( - listOf("metric-1", "metric-2", "metric-3", "metric-4"), - listOf("metric-3", "metric-4"), - emptyList() - ) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(4) } answers { - requestedLimits.add(4) - defaultChunks.removeFirst() - } - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { - requestedLimits.add(2) - listOf("metric-1", "metric-2") - } - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - firstArg>().map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf( - DispatchResult.PayloadTooLarge, - DispatchResult.Success, - DispatchResult.Success - ) - val removedChunks = mutableListOf>() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedChunks.add(firstArg>()) - } - - createManager(dispatchChunkSize = 4).dispatchUnsentMetrics() - - assertEquals(listOf(4, 2, 4, 4), requestedLimits) - assertEquals( - listOf(listOf("metric-1", "metric-2"), listOf("metric-3", "metric-4")), - removedChunks - ) - } + assertEquals(2, metricCursor) + coVerify(exactly = 0) { sessionManager.getMetrics(2, 2) } + } @Test - fun `dispatchUnsentMetrics halves the dispatched count after removing orphans`() = - runTest { - val requestedLimits = mutableListOf() - val defaultChunks = ArrayDeque( - listOf( - listOf("metric-1", "metric-2", "orphan-1", "orphan-2"), - emptyList() - ) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(4) } answers { - requestedLimits.add(4) - defaultChunks.removeFirst() - } - coEvery { mockPendingMetricsManager.getPendingMetricIds(1) } answers { - requestedLimits.add(1) - listOf("metric-1") - } - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - firstArg>().filter { it.startsWith("metric-") }.map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf( - DispatchResult.PayloadTooLarge, - DispatchResult.Success - ) - - createManager(dispatchChunkSize = 4).dispatchUnsentMetrics() - - assertEquals(listOf(4, 1, 4), requestedLimits) + fun `dispatchUnsentMetrics stops reading chunks after cancellation`() = runTest { + val metrics = listOf(metric(1, "one"), metric(2, "two")) + coEvery { sessionManager.getMaxMetricId() } returns 3 + coEvery { sessionManager.getMetrics(-1, 2) } returns metrics + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatch(any()) } coAnswers { + currentCoroutineContext().cancel() + DispatchResult.Success } - @Test - fun `dispatchUnsentMetrics drops a single metric that still gets 413`() = - runTest { - coEvery { mockPendingMetricsManager.getPendingMetricIds(4) } returnsMany - listOf(listOf("metric-1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf( - createSessionWithMetrics( - "session-1", - "production", - listOf(createMetric("metric-1", metricId = "metric-1")) - ) - ) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.PayloadTooLarge - - createManager(dispatchChunkSize = 4).dispatchUnsentMetrics() + launch { createManager(chunkSize = 2).dispatchUnsentMetrics() }.join() - coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("metric-1")) } - coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(4) } - } + assertEquals(2, metricCursor) + coVerify(exactly = 0) { sessionManager.getMetrics(2, 2) } + } @Test - fun `dispatchUnsentMetrics halves to one then drops the oversized metric`() = - runTest { - val requestedLimits = mutableListOf() - val defaultChunks = ArrayDeque( - listOf(listOf("metric-1", "metric-2"), emptyList()) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { - requestedLimits.add(2) - defaultChunks.removeFirst() - } - coEvery { mockPendingMetricsManager.getPendingMetricIds(1) } answers { - requestedLimits.add(1) - listOf("metric-1") - } - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - firstArg>().map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.PayloadTooLarge + fun `dispatchUnsentMetrics dispatches a backlog in successive chunks`() = runTest { + val first = listOf(metric(1, "one"), metric(2, "two")) + val second = listOf(metric(3, "three"), metric(4, "four")) + coEvery { sessionManager.getMaxMetricId() } returns 4 + coEvery { sessionManager.getMetrics(-1, 2) } returns first + coEvery { sessionManager.getMetrics(2, 2) } returns second + coEvery { sessionManager.getMetrics(4, 2) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatch(any()) } returns DispatchResult.Success - createManager(dispatchChunkSize = 2).dispatchUnsentMetrics() + createManager(chunkSize = 2).dispatchUnsentMetrics() - assertEquals(listOf(2, 1), requestedLimits) - coVerify(exactly = 1) { mockPendingMetricsManager.removePendingMetrics(listOf("metric-1")) } - } + assertEquals(4, metricCursor) + coVerify(exactly = 2) { eventDispatcher.dispatch(any()) } + } @Test - fun `dispatchUnsentMetrics does not set the retry gate after 413`() = - runTest { - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } returnsMany - listOf(listOf("metric-1"), listOf("metric-2"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - firstArg>().map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf( - DispatchResult.PayloadTooLarge, - DispatchResult.Success - ) - val manager = createManager( - currentTimeMs = { 1_700_000_000_000L }, - dispatchChunkSize = 2 - ) - - manager.dispatchUnsentMetrics() - manager.dispatchUnsentMetrics() - - coVerify(exactly = 2) { mockEventDispatcher.dispatch(any()) } - } + fun `dispatchUnsentMetrics drops a single metric that still gets 413`() = runTest { + stubMetricDispatch(result = DispatchResult.PayloadTooLarge) - @Test - fun `dispatchUnsentMetrics stops after a retryable chunk and sets the gate`() = - runTest { - val session = createSessionWithMetrics( - "session-1", - "production", - listOf( - createMetric("metric-1", metricId = "metric-1"), - createMetric("metric-2", metricId = "metric-2") - ) - ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } returns listOf("metric-1", "metric-2") - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns - DispatchResult.RetryableFailure(retryAfterMs = 60_000L) - - val manager = createManager( - currentTimeMs = { 1_700_000_000_000L }, - dispatchChunkSize = 2 - ) - manager.dispatchUnsentMetrics() - manager.dispatchUnsentMetrics() + createManager().dispatchUnsentMetrics() - coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(2) } - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 0) { mockPendingMetricsManager.removePendingMetrics(any()) } - } + assertEquals(1, metricCursor) + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + coVerify(exactly = 0) { sessionManager.getMetrics(1, any()) } + } @Test - fun `dispatchUnsentMetrics drops a non-retryable chunk and stops`() = - runTest { - val removedChunks = mutableListOf>() - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } returnsMany - listOf(listOf("metric-1", "metric-2"), listOf("metric-3"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - firstArg>().map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf( - DispatchResult.NonRetryableFailure("HTTP 400"), - DispatchResult.Success - ) - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedChunks.add(firstArg>()) - } + fun `dispatchUnsentLogs transforms grouped logs to events and advances independently`() = runTest { + val logs = listOf(log(4, "first"), log(5, "second")) + coEvery { sessionManager.getMaxLogId() } returns 5 + coEvery { sessionManager.getLogs(-1, 2) } returns logs + coEvery { sessionManager.getLogs(5, 2) } returns emptyList() + coEvery { sessionManager.getSessions(setOf("session")) } returns listOf(session()) + coEvery { eventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success - createManager(dispatchChunkSize = 2).dispatchUnsentMetrics() - - assertEquals(listOf(listOf("metric-1", "metric-2")), removedChunks) - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - coVerify(exactly = 1) { mockPendingMetricsManager.getPendingMetricIds(2) } - } + createManager(chunkSize = 2).dispatchUnsentLogs() - @Test - fun `dispatchUnsentMetrics removes an orphaned chunk and continues`() = - runTest { - val pendingIds = mutableListOf("orphan-1", "metric-1", "metric-2") - val removedChunks = mutableListOf>() - coEvery { mockPendingMetricsManager.getPendingMetricIds(2) } answers { pendingIds.take(2) } - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } answers { - firstArg>() - .filter { it.startsWith("metric-") } - .map { id -> - createSessionWithMetrics("session-$id", "production", listOf(createMetric(id, metricId = id))) - } - } - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - val ids = firstArg>() - removedChunks.add(ids) - pendingIds.removeAll(ids.toSet()) - } - - createManager(dispatchChunkSize = 2).dispatchUnsentMetrics() - - assertEquals( - listOf(listOf("orphan-1"), listOf("metric-1"), listOf("metric-2")), - removedChunks + assertEquals(5, logCursor) + assertEquals(-1, metricCursor) + coVerify { + eventDispatcher.dispatchLogs( + match { events -> events.single().logs.map { it.name } == listOf("first", "second") } ) - coVerify(exactly = 2) { mockEventDispatcher.dispatch(any()) } } + } @Test - fun `dispatchUnsentLogs dispatches a backlog in successive chunks`() = - runTest { - val pendingIds = mutableListOf("log-1", "log-2", "log-3") - val requestedChunks = mutableListOf>() - val removedIds = mutableListOf() - coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { pendingIds.take(2) } - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - val ids = firstArg>() - requestedChunks.add(ids) - ids.map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success - coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers { - val ids = firstArg>() - removedIds.addAll(ids) - pendingIds.removeAll(ids.toSet()) - } - - createManager(dispatchChunkSize = 2).dispatchUnsentLogs() - - assertEquals(listOf(listOf("log-1", "log-2"), listOf("log-3")), requestedChunks) - assertEquals(listOf("log-1", "log-2", "log-3"), removedIds) - coVerify(exactly = 2) { mockEventDispatcher.dispatchLogs(any()) } - coVerify(exactly = 3) { mockPendingLogsManager.getPendingLogIds(2) } - } + fun `dispatchUnsentLogs dispatches valid rows and advances past rows with no matching sessions`() = runTest { + val logs = listOf( + log(1, "valid", sessionId = "valid-session"), + log(2, "orphan", sessionId = "missing-session") + ) + coEvery { sessionManager.getMaxLogId() } returns 2 + coEvery { sessionManager.getLogs(-1, 2) } returns logs + coEvery { sessionManager.getLogs(2, 2) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } returns listOf(session(id = "valid-session")) + coEvery { eventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success - @Test - fun `dispatchUnsentLogs stops reading chunks after cancellation`() = - runTest { - val pendingIds = mutableListOf("log-1", "log-2", "log-3") - coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { pendingIds.take(2) } - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - firstArg>().map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success - coEvery { mockPendingLogsManager.removePendingLogs(any()) } coAnswers { - pendingIds.removeAll(firstArg>().toSet()) - currentCoroutineContext().cancel() - } - - launch { - createManager(dispatchChunkSize = 2).dispatchUnsentLogs() - }.join() - - assertEquals(listOf("log-3"), pendingIds) - coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(2) } - } + createManager(chunkSize = 2).dispatchUnsentLogs() - @Test - fun `dispatchUnsentLogs halves the chunk after 413 then resets to the default size`() = - runTest { - val requestedLimits = mutableListOf() - val defaultChunks = ArrayDeque( - listOf( - listOf("log-1", "log-2", "log-3", "log-4"), - listOf("log-3", "log-4"), - emptyList() - ) + assertEquals(2, logCursor) + coVerify { + eventDispatcher.dispatchLogs( + match { events -> events.single().logs.map { it.name } == listOf("valid") } ) - coEvery { mockPendingLogsManager.getPendingLogIds(4) } answers { - requestedLimits.add(4) - defaultChunks.removeFirst() - } - coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { - requestedLimits.add(2) - listOf("log-1", "log-2") - } - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - firstArg>().map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf( - DispatchResult.PayloadTooLarge, - DispatchResult.Success, - DispatchResult.Success - ) - val removedChunks = mutableListOf>() - coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers { - removedChunks.add(firstArg>()) - } - - createManager(dispatchChunkSize = 4).dispatchUnsentLogs() - - assertEquals(listOf(4, 2, 4, 4), requestedLimits) - assertEquals(listOf(listOf("log-1", "log-2"), listOf("log-3", "log-4")), removedChunks) } + } @Test - fun `dispatchUnsentLogs drops a single log that still gets 413`() = - runTest { - coEvery { mockPendingLogsManager.getPendingLogIds(4) } returnsMany - listOf(listOf("log-1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf( - createSessionWithLogs( - "session-1", - "production", - listOf(createLog("log-1", logId = "log-1")) - ) - ) - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.PayloadTooLarge + fun `dispatchUnsentLogs dispatches a backlog in successive chunks`() = runTest { + val first = listOf(log(1, "one"), log(2, "two")) + val second = listOf(log(3, "three"), log(4, "four")) + coEvery { sessionManager.getMaxLogId() } returns 4 + coEvery { sessionManager.getLogs(-1, 2) } returns first + coEvery { sessionManager.getLogs(2, 2) } returns second + coEvery { sessionManager.getLogs(4, 2) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success - createManager(dispatchChunkSize = 4).dispatchUnsentLogs() + createManager(chunkSize = 2).dispatchUnsentLogs() - coVerify(exactly = 1) { mockPendingLogsManager.removePendingLogs(listOf("log-1")) } - coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(4) } - } + assertEquals(4, logCursor) + coVerify(exactly = 2) { eventDispatcher.dispatchLogs(any()) } + } @Test - fun `dispatchUnsentLogs halves the dispatched count after removing orphans`() = - runTest { - val requestedLimits = mutableListOf() - val defaultChunks = ArrayDeque( - listOf( - listOf("log-1", "log-2", "orphan-1", "orphan-2"), - emptyList() - ) - ) - coEvery { mockPendingLogsManager.getPendingLogIds(4) } answers { - requestedLimits.add(4) - defaultChunks.removeFirst() - } - coEvery { mockPendingLogsManager.getPendingLogIds(1) } answers { - requestedLimits.add(1) - listOf("log-1") - } - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - firstArg>().filter { it.startsWith("log-") }.map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf( - DispatchResult.PayloadTooLarge, - DispatchResult.Success - ) - - createManager(dispatchChunkSize = 4).dispatchUnsentLogs() - - assertEquals(listOf(4, 1, 4), requestedLimits) + fun `dispatchUnsentLogs stops reading chunks after cancellation`() = runTest { + val logs = listOf(log(1, "one"), log(2, "two")) + coEvery { sessionManager.getMaxLogId() } returns 3 + coEvery { sessionManager.getLogs(-1, 2) } returns logs + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatchLogs(any()) } coAnswers { + currentCoroutineContext().cancel() + DispatchResult.Success } - @Test - fun `dispatchUnsentLogs halves to one then drops the oversized log`() = - runTest { - val requestedLimits = mutableListOf() - val defaultChunks = ArrayDeque( - listOf(listOf("log-1", "log-2"), emptyList()) - ) - coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { - requestedLimits.add(2) - defaultChunks.removeFirst() - } - coEvery { mockPendingLogsManager.getPendingLogIds(1) } answers { - requestedLimits.add(1) - listOf("log-1") - } - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - firstArg>().map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.PayloadTooLarge - - createManager(dispatchChunkSize = 2).dispatchUnsentLogs() + launch { createManager(chunkSize = 2).dispatchUnsentLogs() }.join() - assertEquals(listOf(2, 1), requestedLimits) - coVerify(exactly = 1) { mockPendingLogsManager.removePendingLogs(listOf("log-1")) } - } + assertEquals(2, logCursor) + coVerify(exactly = 0) { sessionManager.getLogs(2, 2) } + } @Test - fun `dispatchUnsentLogs does not set the retry gate after 413`() = - runTest { - coEvery { mockPendingLogsManager.getPendingLogIds(2) } returnsMany - listOf(listOf("log-1"), listOf("log-2"), emptyList()) - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - firstArg>().map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf( - DispatchResult.PayloadTooLarge, - DispatchResult.Success - ) - val manager = createManager( - currentTimeMs = { 1_700_000_000_000L }, - dispatchChunkSize = 2 - ) + fun `dispatchUnsentLogs halves the fetched count after missing sessions`() = runTest { + val logs = listOf( + log(1, "one", sessionId = "valid-session"), + log(2, "two", sessionId = "valid-session"), + log(3, "orphan-three", sessionId = "missing-session"), + log(4, "orphan-four", sessionId = "missing-session") + ) + coEvery { sessionManager.getMaxLogId() } returns 4 + coEvery { sessionManager.getLogs(-1, 4) } returns logs + coEvery { sessionManager.getLogs(-1, 2) } returns logs.take(2) + coEvery { sessionManager.getLogs(2, 4) } returns logs.drop(2) + coEvery { sessionManager.getLogs(4, 4) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } answers { + if ("valid-session" in firstArg>()) listOf(session(id = "valid-session")) else emptyList() + } + coEvery { eventDispatcher.dispatchLogs(any()) } returnsMany listOf( + DispatchResult.PayloadTooLarge, + DispatchResult.Success + ) - manager.dispatchUnsentLogs() - manager.dispatchUnsentLogs() + createManager(chunkSize = 4).dispatchUnsentLogs() - coVerify(exactly = 2) { mockEventDispatcher.dispatchLogs(any()) } - } + assertEquals(4, logCursor) + coVerify(exactly = 1) { sessionManager.getLogs(-1, 2) } + coVerify(exactly = 0) { sessionManager.getLogs(-1, 1) } + } @Test - fun `dispatchUnsentLogs stops after a retryable chunk and sets the gate`() = - runTest { - val session = createSessionWithLogs( - "session-1", - "production", - listOf( - createLog("log-1", logId = "log-1"), - createLog("log-2", logId = "log-2") - ) - ) - coEvery { mockPendingLogsManager.getPendingLogIds(2) } returns listOf("log-1", "log-2") - coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns - DispatchResult.RetryableFailure(retryAfterMs = 60_000L) - - val manager = createManager( - currentTimeMs = { 1_700_000_000_000L }, - dispatchChunkSize = 2 - ) - manager.dispatchUnsentLogs() - manager.dispatchUnsentLogs() + fun `dispatchUnsentLogs drops a single log that still gets 413`() = runTest { + stubLogDispatch(result = DispatchResult.PayloadTooLarge) - coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(2) } - coVerify(exactly = 1) { mockEventDispatcher.dispatchLogs(any()) } - coVerify(exactly = 0) { mockPendingLogsManager.removePendingLogs(any()) } - } + createManager().dispatchUnsentLogs() - @Test - fun `dispatchUnsentLogs drops a non-retryable chunk and stops`() = - runTest { - val removedChunks = mutableListOf>() - coEvery { mockPendingLogsManager.getPendingLogIds(2) } returnsMany - listOf(listOf("log-1", "log-2"), listOf("log-3"), emptyList()) - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - firstArg>().map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returnsMany listOf( - DispatchResult.NonRetryableFailure("HTTP 400"), - DispatchResult.Success - ) - coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers { - removedChunks.add(firstArg>()) - } + assertEquals(1, logCursor) + coVerify(exactly = 1) { eventDispatcher.dispatchLogs(any()) } + coVerify(exactly = 0) { sessionManager.getLogs(1, any()) } + } - createManager(dispatchChunkSize = 2).dispatchUnsentLogs() + @Test + fun `dispatchUnsentLogs drops a non-retryable chunk and stops`() = runTest { + val logs = listOf(log(1, "one"), log(2, "two")) + coEvery { sessionManager.getMaxLogId() } returns 3 + coEvery { sessionManager.getLogs(-1, 2) } returns logs + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatchLogs(any()) } returns DispatchResult.NonRetryableFailure("HTTP 400") - assertEquals(listOf(listOf("log-1", "log-2")), removedChunks) - coVerify(exactly = 1) { mockEventDispatcher.dispatchLogs(any()) } - coVerify(exactly = 1) { mockPendingLogsManager.getPendingLogIds(2) } - } + createManager(chunkSize = 2).dispatchUnsentLogs() - @Test - fun `dispatchUnsentLogs removes an orphaned chunk and continues`() = - runTest { - val pendingIds = mutableListOf("orphan-1", "log-1", "log-2") - val removedChunks = mutableListOf>() - coEvery { mockPendingLogsManager.getPendingLogIds(2) } answers { pendingIds.take(2) } - coEvery { mockSessionManager.getSessionsWithLogs(any()) } answers { - firstArg>() - .filter { it.startsWith("log-") } - .map { id -> - createSessionWithLogs("session-$id", "production", listOf(createLog(id, logId = id))) - } - } - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success - coEvery { mockPendingLogsManager.removePendingLogs(any()) } answers { - val ids = firstArg>() - removedChunks.add(ids) - pendingIds.removeAll(ids.toSet()) - } - - createManager(dispatchChunkSize = 2).dispatchUnsentLogs() - - assertEquals(listOf(listOf("orphan-1"), listOf("log-1"), listOf("log-2")), removedChunks) - coVerify(exactly = 2) { mockEventDispatcher.dispatchLogs(any()) } - } + assertEquals(2, logCursor) + coVerify(exactly = 0) { sessionManager.getLogs(2, 2) } + } // endregion // region Cleanup tests @Test - fun `cleanup prunes pending metrics, pending logs, stale sessions, and stale logs`() = - runTest { - // Arrange - val manager = createManager() - - // Act - manager.cleanup() - - // Assert - coVerify(exactly = 1) { mockPendingMetricsManager.cleanupOldPendingMetrics() } - coVerify(exactly = 1) { mockPendingLogsManager.cleanupOldPendingLogs() } - coVerify(exactly = 1) { mockSessionManager.cleanupOldSessions() } - coVerify(exactly = 1) { mockSessionManager.cleanupOldLogs() } - } + fun `cleanup prunes app metrics storage and deletes the legacy database`() = runTest { + createManager().cleanup() + + coVerify(exactly = 1) { sessionManager.cleanupOldSessions() } + coVerify(exactly = 1) { sessionManager.cleanupOldLogs() } + verify(exactly = 1) { context.deleteDatabase("eas_observe") } + } // endregion @@ -1339,74 +591,55 @@ class BaseObservabilityManagerTest { @Test fun `Metadata fromSessionMetadata transforms all fields correctly`() { - // Arrange - val session = Session( - id = "test-session", - startTimestamp = "2025-01-01T00:00:00.000Z", - isActive = true, - environment = "production", - appName = "TestApp", - appIdentifier = "com.test.app", - appVersion = "1.2.3", - appBuildNumber = "42", - appUpdateId = "update-123", - deviceOs = "Android", - deviceOsVersion = "14", - deviceModel = "Pixel 8", - deviceName = "oriole", - expoSdkVersion = "52.0.0", - reactNativeVersion = "0.76.0", - clientVersion = "1.0.0", - languageTag = "en-US" + val metadata = Metadata.fromSessionMetadata( + Session( + id = "session", + startTimestamp = "2025-01-01T00:00:00Z", + environment = "production", + appName = "TestApp", + appIdentifier = "dev.expo.test", + appVersion = "1.2.3", + appBuildNumber = "42", + appUpdateId = "update", + appUpdateRuntimeVersion = "1", + appUpdateRequestHeaders = """{"expo-channel-name":"production"}""", + appEasBuildId = "build", + deviceOs = "Android", + deviceOsVersion = "16", + deviceModel = "Pixel", + deviceName = "pixel", + expoSdkVersion = "55", + reactNativeVersion = "0.81", + clientVersion = "1", + languageTag = "en-US" + ) ) - // Act - val metadata = Metadata.fromSessionMetadata(session) - - // Assert assertEquals("TestApp", metadata.appName) - assertEquals("com.test.app", metadata.appIdentifier) + assertEquals("dev.expo.test", metadata.appIdentifier) assertEquals("1.2.3", metadata.appVersion) assertEquals("42", metadata.appBuildNumber) - assertEquals("update-123", metadata.appUpdatesInfo?.updateId) + assertEquals("update", metadata.appUpdatesInfo?.updateId) + assertEquals("1", metadata.appUpdatesInfo?.runtimeVersion) + assertEquals("production", metadata.appUpdatesInfo?.channel) + assertEquals("build", metadata.appEasBuildId) assertEquals("Android", metadata.deviceOs) - assertEquals("14", metadata.deviceOsVersion) - assertEquals("Pixel 8", metadata.deviceModel) - assertEquals("oriole", metadata.deviceName) - assertEquals("52.0.0", metadata.expoSdkVersion) - assertEquals("0.76.0", metadata.reactNativeVersion) - assertEquals("1.0.0", metadata.clientVersion) + assertEquals("16", metadata.deviceOsVersion) + assertEquals("Pixel", metadata.deviceModel) + assertEquals("pixel", metadata.deviceName) + assertEquals("55", metadata.expoSdkVersion) + assertEquals("0.81", metadata.reactNativeVersion) + assertEquals("1", metadata.clientVersion) assertEquals("en-US", metadata.languageTag) assertEquals("production", metadata.environment) } @Test fun `Metadata fromSessionMetadata handles null fields`() { - // Arrange - val session = Session( - id = "test-session", - startTimestamp = "2025-01-01T00:00:00.000Z", - isActive = true, - environment = null, - appName = null, - appIdentifier = null, - appVersion = null, - appBuildNumber = null, - appUpdateId = null, - deviceOs = null, - deviceOsVersion = null, - deviceModel = null, - deviceName = null, - expoSdkVersion = null, - reactNativeVersion = null, - clientVersion = null, - languageTag = null + val metadata = Metadata.fromSessionMetadata( + Session(id = "session", startTimestamp = "2025-01-01T00:00:00Z") ) - // Act - val metadata = Metadata.fromSessionMetadata(session) - - // Assert assertNull(metadata.appName) assertEquals("", metadata.appIdentifier) assertNull(metadata.appVersion) @@ -1425,406 +658,228 @@ class BaseObservabilityManagerTest { @Test fun `EASMetric fromMetric transforms all fields correctly`() { - // Arrange - val metric = Metric( - metricId = "metric-123", - sessionId = "session-456", - timestamp = "2025-01-01T12:00:00.000Z", - category = "performance", - name = "app_start", - value = 1500.5, - routeName = "/home", - params = """{"key":"value"}""" + val easMetric = EASMetric.fromMetric( + Metric( + id = 1, + sessionId = "session", + timestamp = "2025-01-01T00:00:00Z", + category = "navigation", + name = "route", + value = 1.5, + routeName = "/home", + updateId = "update", + params = """{"key":"value"}""" + ) ) - // Act - val easMetric = EASMetric.fromMetric(metric) - - // Assert - assertEquals("session-456", easMetric.sessionId) - assertEquals("2025-01-01T12:00:00.000Z", easMetric.timestamp) - assertEquals("performance", easMetric.category) - assertEquals("app_start", easMetric.name) - assertEquals(1500.5, easMetric.value, 0.001) + assertEquals("session", easMetric.sessionId) + assertEquals("2025-01-01T00:00:00Z", easMetric.timestamp) + assertEquals("navigation", easMetric.category) + assertEquals("route", easMetric.name) + assertEquals(1.5, easMetric.value, 0.0) assertEquals("/home", easMetric.routeName) - assertEquals( - buildJsonObject { put("key", "value") }, - easMetric.customParams - ) + assertEquals("update", easMetric.updateId) + assertEquals(buildJsonObject { put("key", "value") }, easMetric.customParams) } @Test fun `EASMetric fromMetric handles null optional fields`() { - // Arrange - val metric = Metric( - metricId = "metric-123", - sessionId = "session-456", - timestamp = "2025-01-01T12:00:00.000Z", - category = "performance", - name = "app_start", - value = 1500.5, - routeName = null, - params = null - ) + val easMetric = EASMetric.fromMetric(metric(1, "metric")) - // Act - val easMetric = EASMetric.fromMetric(metric) - - // Assert assertNull(easMetric.routeName) + assertNull(easMetric.updateId) assertNull(easMetric.customParams) } @Test - fun `dispatchUnsentMetrics transforms SessionWithMetrics to Event correctly`() = - runTest { - // Arrange - val metric1 = createMetric("loadTime", value = 0.5, category = "appStartup", metricId = "m1") - val metric2 = createMetric("launchTime", value = 1.2, category = "appStartup", metricId = "m2") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - appName = "MyApp", - appVersion = "2.0.0", - metrics = listOf(metric1, metric2) + fun `dispatchUnsentMetrics creates separate events for each session`() = runTest { + val metrics = listOf( + metric(1, "one", sessionId = "session-1"), + metric(2, "two", sessionId = "session-2") + ) + stubMetricDispatch( + metrics = metrics, + sessions = listOf( + session(id = "session-1", appVersion = "1.0.0"), + session(id = "session-2", appVersion = "2.0.0") ) + ) - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("m1", "m2"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val manager = createManager() - - // Act - manager.dispatchUnsentMetrics() - - // Assert - coVerify { - mockEventDispatcher.dispatch( - match { events -> - events.size == 1 && - events[0].metadata.appName == "MyApp" && - events[0].metadata.appVersion == "2.0.0" && - events[0].metadata.environment == "production" && - events[0].metrics.size == 2 && - events[0].metrics.any { it.name == "loadTime" && it.value == 0.5 } && - events[0].metrics.any { it.name == "launchTime" && it.value == 1.2 } - } - ) - } - } + createManager().dispatchUnsentMetrics() - @Test - fun `dispatchUnsentMetrics creates separate events for each session`() = - runTest { - // Arrange - val metric1 = createMetric("metric1", metricId = "metric-id-1") - val metric2 = createMetric("metric2", metricId = "metric-id-2") - val session1 = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - appVersion = "1.0.0", - metrics = listOf(metric1) - ) - val session2 = createSessionWithMetrics( - sessionId = "session-2", - environment = "production", - appVersion = "2.0.0", - metrics = listOf(metric2) + coVerify { + eventDispatcher.dispatch( + match { events -> + events.size == 2 && events.map { it.metadata.appVersion }.toSet() == setOf("1.0.0", "2.0.0") + } ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("metric-id-1", "metric-id-2"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session1, session2) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success - - val removedIds = mutableListOf() - coEvery { mockPendingMetricsManager.removePendingMetrics(any()) } answers { - removedIds.addAll(firstArg>()) - } - - val manager = createManager() - - // Act - manager.dispatchUnsentMetrics() - - // Assert - separate events created - coVerify { - mockEventDispatcher.dispatch( - match { events -> - events.size == 2 && - events.any { it.metadata.appVersion == "1.0.0" } && - events.any { it.metadata.appVersion == "2.0.0" } - } - ) - } - - // Assert - ALL metric IDs from both sessions are removed from pending - assertEquals(2, removedIds.size) - assertTrue("Metric 1 should be removed from pending", removedIds.contains("metric-id-1")) - assertTrue("Metric 2 should be removed from pending", removedIds.contains("metric-id-2")) } + } // endregion // region Retry-gate tests @Test - fun `dispatchUnsentMetrics is suppressed when retry gate is active`() = - runTest { - // First dispatch returns Retryable with Retry-After=60s; that sets the gate. Second - // dispatch (same simulated clock) should short-circuit before calling the dispatcher - // again. Verifies the C2 acceptance criterion: a Retryable response must defer the - // next round, not just leave the rows pending. - val metric = createMetric("metric1", metricId = "id1") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returns - DispatchResult.RetryableFailure(retryAfterMs = 60_000L) + fun `dispatchUnsentMetrics stops after a retryable chunk and sets the gate`() = runTest { + coEvery { sessionManager.getMaxMetricId() } returns 1 + coEvery { sessionManager.getMetrics(-1, any()) } returns listOf(metric(1, "one")) + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatch(any()) } returns DispatchResult.RetryableFailure(60_000) + val manager = createManager(currentTimeMs = { 1_000 }) - val fixedNow = 1_700_000_000_000L - val manager = createManager(currentTimeMs = { fixedNow }) + manager.dispatchUnsentMetrics() + manager.dispatchUnsentMetrics() - manager.dispatchUnsentMetrics() - manager.dispatchUnsentMetrics() - - // The second call must NOT reach the dispatcher — only one call total. - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + assertEquals(-1, metricCursor) + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } @Test - fun `metrics Retryable does not gate logs (per-signal gates are independent)`() = - runTest { - // The metrics and logs endpoints fail independently in practice — one schema - // disagreement on the metrics side shouldn't suppress a healthy logs stream. After a - // metrics dispatch sets its own gate, a logs dispatch must still proceed (and reach - // the dispatcher). - val metric = createMetric("metric1", metricId = "id1") - val metricSession = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - val logRecord = createLog("log1", logId = "log-1") - val logSession = createSessionWithLogs( - sessionId = "session-2", - environment = "production", - logs = listOf(logRecord) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(metricSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns - DispatchResult.RetryableFailure(retryAfterMs = 60_000L) - - coEvery { mockPendingLogsManager.getPendingLogIds(any()) } returnsMany listOf(listOf("log-1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf(logSession) - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns DispatchResult.Success + fun `dispatchUnsentLogs stops after a retryable chunk and sets the gate`() = runTest { + stubLogDispatch(result = DispatchResult.RetryableFailure(60_000)) + val manager = createManager(currentTimeMs = { 1_000 }) - val fixedNow = 1_700_000_000_000L - val manager = createManager(currentTimeMs = { fixedNow }) + manager.dispatchUnsentLogs() + manager.dispatchUnsentLogs() - manager.dispatchUnsentMetrics() - manager.dispatchUnsentLogs() - - // Logs MUST reach the dispatcher; the metrics gate doesn't suppress it. - coVerify(exactly = 1) { mockEventDispatcher.dispatchLogs(any()) } - } + assertEquals(-1, logCursor) + coVerify(exactly = 1) { eventDispatcher.dispatchLogs(any()) } + } @Test - fun `logs Retryable does not gate metrics (per-signal gates are independent)`() = - runTest { - // Symmetric test: a logs failure doesn't suppress metrics. - val metric = createMetric("metric1", metricId = "id1") - val metricSession = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - val logRecord = createLog("log1", logId = "log-1") - val logSession = createSessionWithLogs( - sessionId = "session-2", - environment = "production", - logs = listOf(logRecord) - ) + fun `metrics Retryable does not gate logs (per-signal gates are independent)`() = runTest { + stubMetricDispatch(result = DispatchResult.RetryableFailure(60_000)) + stubLogDispatch() + val manager = createManager(currentTimeMs = { 1_000 }) - coEvery { mockPendingLogsManager.getPendingLogIds(any()) } returnsMany listOf(listOf("log-1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithLogs(any()) } returns listOf(logSession) - coEvery { mockEventDispatcher.dispatchLogs(any()) } returns - DispatchResult.RetryableFailure(retryAfterMs = 60_000L) + manager.dispatchUnsentMetrics() + manager.dispatchUnsentLogs() - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany listOf(listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(metricSession) - coEvery { mockEventDispatcher.dispatch(any()) } returns DispatchResult.Success + assertEquals(-1, metricCursor) + assertEquals(1, logCursor) + coVerify(exactly = 1) { eventDispatcher.dispatchLogs(any()) } + } - val fixedNow = 1_700_000_000_000L - val manager = createManager(currentTimeMs = { fixedNow }) + @Test + fun `logs Retryable does not gate metrics (per-signal gates are independent)`() = runTest { + stubLogDispatch(result = DispatchResult.RetryableFailure(60_000)) + stubMetricDispatch() + val manager = createManager(currentTimeMs = { 1_000 }) - manager.dispatchUnsentLogs() - manager.dispatchUnsentMetrics() + manager.dispatchUnsentLogs() + manager.dispatchUnsentMetrics() - coVerify(exactly = 1) { mockEventDispatcher.dispatch(any()) } - } + assertEquals(-1, logCursor) + assertEquals(1, metricCursor) + coVerify(exactly = 1) { eventDispatcher.dispatch(any()) } + } @Test - fun `dispatchUnsentMetrics resumes after the retry gate expires`() = - runTest { - // First dispatch sets a gate at now+60s. We then advance the simulated clock past the - // gate; the second dispatch should proceed. - val metric = createMetric("metric1", metricId = "id1") - val session = createSessionWithMetrics( - sessionId = "session-1", - environment = "production", - metrics = listOf(metric) - ) - - coEvery { mockPendingMetricsManager.getPendingMetricIds(any()) } returnsMany - listOf(listOf("id1"), listOf("id1"), emptyList()) - coEvery { mockSessionManager.getSessionsWithMetrics(any()) } returns listOf(session) - coEvery { mockEventDispatcher.dispatch(any()) } returnsMany listOf( - DispatchResult.RetryableFailure(retryAfterMs = 60_000L), - DispatchResult.Success - ) - - var nowMs = 1_700_000_000_000L - val manager = createManager(currentTimeMs = { nowMs }) + fun `dispatchUnsentMetrics resumes after the retry gate expires`() = runTest { + var now = 1_000L + coEvery { sessionManager.getMaxMetricId() } returns 1 + coEvery { sessionManager.getMetrics(-1, any()) } returns listOf(metric(1, "metric")) + coEvery { sessionManager.getMetrics(1, any()) } returns emptyList() + coEvery { sessionManager.getSessions(any()) } returns listOf(session()) + coEvery { eventDispatcher.dispatch(any()) } returnsMany listOf( + DispatchResult.RetryableFailure(60_000), + DispatchResult.Success + ) + val manager = createManager(currentTimeMs = { now }) - manager.dispatchUnsentMetrics() - nowMs += 120_000L // jump past the 60-second gate - manager.dispatchUnsentMetrics() + manager.dispatchUnsentMetrics() + now += 60_001 + manager.dispatchUnsentMetrics() - coVerify(exactly = 2) { mockEventDispatcher.dispatch(any()) } - } + assertEquals(1, metricCursor) + coVerify(exactly = 2) { eventDispatcher.dispatch(any()) } + } // endregion // region Helper methods + private fun stubMetricDispatch( + metrics: List = listOf(metric(1, "metric")), + sessions: List = listOf(session()), + result: DispatchResult = DispatchResult.Success + ) { + val highestId = metrics.maxOf { it.id } + coEvery { sessionManager.getMaxMetricId() } returns highestId + coEvery { sessionManager.getMetrics(-1, any()) } returns metrics + coEvery { sessionManager.getMetrics(highestId, any()) } returns emptyList() + coEvery { sessionManager.getSessions(metrics.mapTo(linkedSetOf()) { it.sessionId }) } returns sessions + coEvery { eventDispatcher.dispatch(any()) } returns result + } + + private fun stubLogDispatch( + logs: List = listOf(log(1, "log")), + sessions: List = listOf(session()), + result: DispatchResult = DispatchResult.Success + ) { + val highestId = logs.maxOf { it.id } + coEvery { sessionManager.getMaxLogId() } returns highestId + coEvery { sessionManager.getLogs(-1, any()) } returns logs + coEvery { sessionManager.getLogs(highestId, any()) } returns emptyList() + coEvery { sessionManager.getSessions(logs.mapTo(linkedSetOf()) { it.sessionId }) } returns sessions + coEvery { eventDispatcher.dispatchLogs(any()) } returns result + } + private fun createManager( + chunkSize: Int = DISPATCH_CHUNK_SIZE, + currentTimeMs: () -> Long = { 0 }, isDebugBuild: Boolean = false, - deterministicUniformValue: Double = 0.0, - currentTimeMs: () -> Long = { TimeUtils.getWallClockMillis() }, - dispatchChunkSize: Int = DISPATCH_CHUNK_SIZE + deterministicUniformValue: Double = 0.0 ): BaseObservabilityManager { val manager = BaseObservabilityManager( - context = mockContext, - sessionManager = mockSessionManager, - pendingMetricsManager = mockPendingMetricsManager, - pendingLogsManager = mockPendingLogsManager, - projectId = testProjectId, - baseUrl = testBaseUrl, + context = context, + sessionManager = sessionManager, + projectId = "project", + baseUrl = "https://example.com/", isDebugBuild = isDebugBuild, deterministicUniformValueProvider = { deterministicUniformValue }, currentTimeMs = currentTimeMs, - dispatchChunkSize = dispatchChunkSize + dispatchChunkSize = chunkSize ) - // Replace the internal EventDispatcher with our mock val field = BaseObservabilityManager::class.java.getDeclaredField("eventDispatcher") field.isAccessible = true - field.set(manager, mockEventDispatcher) + field.set(manager, eventDispatcher) return manager } - private fun createSessionWithMetrics( - sessionId: String, - environment: String?, - metrics: List, - appName: String = "TestApp", - appVersion: String = "1.0.0" - ): SessionWithMetrics { - val session = Session( - id = sessionId, - startTimestamp = "2025-01-01T00:00:00.000Z", - isActive = true, - environment = environment, - appName = appName, - appIdentifier = "com.test.app", - appVersion = appVersion, - appBuildNumber = "1", - appUpdateId = null, - deviceOs = "Android", - deviceOsVersion = "14", - deviceModel = "Test Device", - deviceName = "test", - expoSdkVersion = "52.0.0", - reactNativeVersion = "0.76.0", - clientVersion = null, - languageTag = "en-US" - ) - return SessionWithMetrics( - session = session, - metrics = metrics.map { it.copy(sessionId = sessionId) } - ) - } - - private fun createMetric( - name: String, - metricId: String = "metric-${System.nanoTime()}", - value: Double = 123.45, - category: String = "test" - ): Metric = - Metric( - metricId = metricId, - sessionId = "", - timestamp = "2025-01-01T00:00:00.000Z", - category = category, - name = name, - value = value, - routeName = null, - params = null - ) - - private fun createSessionWithLogs( - sessionId: String, - environment: String?, - logs: List, - appName: String = "TestApp", - appVersion: String = "1.0.0" - ): SessionWithLogs { - val session = Session( - id = sessionId, - startTimestamp = "2025-01-01T00:00:00.000Z", - isActive = true, - environment = environment, - appName = appName, - appIdentifier = "com.test.app", - appVersion = appVersion, - appBuildNumber = "1", - appUpdateId = null, - deviceOs = "Android", - deviceOsVersion = "14", - deviceModel = "Test Device", - deviceName = "test", - expoSdkVersion = "52.0.0", - reactNativeVersion = "0.76.0", - clientVersion = null, - languageTag = "en-US" - ) - return SessionWithLogs( - session = session, - logs = logs.map { it.copy(sessionId = sessionId) } - ) - } - - private fun createLog( - name: String, - logId: String = "log-${System.nanoTime()}", - body: String = "test log body", - severity: String = "info" - ): LogRecord = - LogRecord( - logId = logId, - sessionId = "", - timestamp = "2025-01-01T00:00:00.000Z", - name = name, - body = body, - severity = severity - ) + private fun session( + id: String = "session", + appName: String? = null, + appVersion: String? = null, + environment: String? = null + ) = Session( + id = id, + startTimestamp = "2025-01-01T00:00:00Z", + environment = environment, + appName = appName, + appIdentifier = "dev.expo.test", + appVersion = appVersion, + expoSdkVersion = "55", + reactNativeVersion = "0.81" + ) + + private fun metric(id: Long, name: String, sessionId: String = "session") = Metric( + sessionId = sessionId, + timestamp = "2025-01-01T00:00:00Z", + category = "test", + name = name, + value = 1.0, + id = id + ) + + private fun log(id: Long, name: String, sessionId: String = "session") = LogRecord( + sessionId = sessionId, + timestamp = "2025-01-01T00:00:00Z", + name = name, + severity = "info", + id = id + ) // endregion } diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/CursorRepairTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/CursorRepairTest.kt new file mode 100644 index 00000000000000..3091e1c1906b14 --- /dev/null +++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/CursorRepairTest.kt @@ -0,0 +1,94 @@ +package expo.modules.observe + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class CursorRepairTest { + @Test + fun `cursor below max id is left alone`() = runTest { + var stored = 5L + + repairCursorIfStale("metric", { stored }, { stored = it }, { 10L }) + + assertEquals(5L, stored) + } + + @Test + fun `cursor equal to max id is left alone`() = runTest { + var stored = 10L + + repairCursorIfStale("metric", { stored }, { stored = it }, { 10L }) + + assertEquals(10L, stored) + } + + @Test + fun `cursor above max id is reset to -1`() = runTest { + var stored = 42L + + repairCursorIfStale("metric", { stored }, { stored = it }, { 10L }) + + assertEquals(-1L, stored) + } + + @Test + fun `non-default cursor with empty table is reset to -1`() = runTest { + var stored = 42L + + repairCursorIfStale("metric", { stored }, { stored = it }, { null }) + + assertEquals(-1L, stored) + } + + @Test + fun `default cursor with empty table is left alone`() = runTest { + var readMaxIdCalled = false + var writeCalled = false + var stored = -1L + + repairCursorIfStale( + "metric", + { stored }, + { + stored = it + writeCalled = true + }, + { + readMaxIdCalled = true + null + } + ) + + assertEquals(-1L, stored) + assertFalse(readMaxIdCalled) + assertFalse(writeCalled) + } + + @Test + fun `readMaxId throwing leaves cursor alone`() = runTest { + var writeCalled = false + var stored = 42L + + repairCursorIfStale( + "metric", + { stored }, + { + stored = it + writeCalled = true + }, + { throw TestError } + ) + + assertEquals(42L, stored) + assertFalse(writeCalled) + } + + private object TestError : Exception() +} diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt index 1e669197eddaf2..19eded0491f728 100644 --- a/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt +++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/DispatchUtilsTest.kt @@ -257,47 +257,6 @@ class DispatchUtilsClassifyResponseTest { } } -class DispatchUtilsShouldRemovePendingTest { - // On `Success`, the queue moves past the dispatched batch so the next round reads only - // newer rows. Unchanged from the pre-OTLP behavior; locked here for the table. - @Test - fun `Success removes pending IDs`() { - assertTrue(DispatchUtils.shouldRemovePending(DispatchResult.Success)) - } - - // Multi-record payloads are retried in smaller chunks before this check, so a 413 here - // represents a single oversized record that must not wedge the queue. - @Test - fun `PayloadTooLarge removes pending IDs`() { - assertTrue(DispatchUtils.shouldRemovePending(DispatchResult.PayloadTooLarge)) - } - - // `Retryable` is the "leave them alone" case — the next dispatch round picks the same - // rows up again. This is what keeps an in-flight outage from losing telemetry. - @Test - fun `Retryable keeps pending IDs`() { - assertTrue(!DispatchUtils.shouldRemovePending(DispatchResult.RetryableFailure())) - assertTrue(!DispatchUtils.shouldRemovePending(DispatchResult.RetryableFailure(retryAfterMs = 30_000L))) - } - - // `PartialSuccess` removes pending IDs like `Success` does: the bytes landed on the - // server (a subset was rejected server-side, but the batch as a whole was accepted), so - // re-sending the same rows would just trip the same rejection. - @Test - fun `PartialSuccess removes pending IDs`() { - val partial = OTPartialSuccess(rejectedDataPoints = 1, errorMessage = "x") - assertTrue(DispatchUtils.shouldRemovePending(DispatchResult.PartialSuccess(partial))) - } - - // The acceptance-criterion behavior: a non-retryable response (e.g. 400, 403) drops the - // offending batch. Without this, the next round would re-send the same rows and the - // server would refuse them again, wedging the queue indefinitely. - @Test - fun `NonRetryable removes pending IDs`() { - assertTrue(DispatchUtils.shouldRemovePending(DispatchResult.NonRetryableFailure("HTTP 400"))) - } -} - class DispatchUtilsParseRetryAfterTest { // Test-local bounds passed explicitly to every call so the suite is independent of the // production constants. A separate test verifies the default-argument path. diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/ObservePreferencesTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/ObservePreferencesTest.kt index 80e0e41aec8896..0954ae011f75ad 100644 --- a/packages/expo-observe/android/src/test/java/expo/modules/observe/ObservePreferencesTest.kt +++ b/packages/expo-observe/android/src/test/java/expo/modules/observe/ObservePreferencesTest.kt @@ -139,4 +139,38 @@ class ObservePreferencesTest { } // endregion + + // region Dispatch cursors + + @Test + fun `dispatch cursors default to -1`() { + assertEquals(-1L, ObservePreferences.getLastDispatchedMetricId(context)) + assertEquals(-1L, ObservePreferences.getLastDispatchedLogId(context)) + } + + @Test + fun `metric dispatch cursor round trips`() { + ObservePreferences.setLastDispatchedMetricId(context, 42) + + assertEquals(42L, ObservePreferences.getLastDispatchedMetricId(context)) + } + + @Test + fun `log dispatch cursor round trips`() { + ObservePreferences.setLastDispatchedLogId(context, 84) + + assertEquals(84L, ObservePreferences.getLastDispatchedLogId(context)) + } + + @Test + fun `dispatch cursor keys are independent`() { + ObservePreferences.setLastDispatchedMetricId(context, 42) + ObservePreferences.setLastDispatchedLogId(context, 84) + ObservePreferences.setLastDispatchedMetricId(context, 126) + + assertEquals(126L, ObservePreferences.getLastDispatchedMetricId(context)) + assertEquals(84L, ObservePreferences.getLastDispatchedLogId(context)) + } + + // endregion } diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingLogsManagerTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingLogsManagerTest.kt deleted file mode 100644 index 12b6718ac8998b..00000000000000 --- a/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingLogsManagerTest.kt +++ /dev/null @@ -1,156 +0,0 @@ -package expo.modules.observe.storage - -import android.content.Context -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [28]) -class PendingLogsManagerTest { - private lateinit var database: ObserveDatabase - private lateinit var manager: PendingLogsManager - - @Before - fun setUp() { - val context = ApplicationProvider.getApplicationContext() - database = Room - .inMemoryDatabaseBuilder(context, ObserveDatabase::class.java) - .allowMainThreadQueries() - .build() - manager = PendingLogsManager(context, database) - } - - @After - fun tearDown() { - database.close() - } - - @Test - fun `addPendingLogs inserts logs correctly`() = - runTest { - val logIds = listOf("log-1", "log-2", "log-3") - - manager.addPendingLogs(logIds) - - val result = manager.getPendingLogIds(Int.MAX_VALUE) - assertEquals(3, result.size) - assertTrue(result.containsAll(logIds)) - } - - @Test - fun `getPendingLogIds returns all inserted IDs from multiple add calls`() = - runTest { - manager.addPendingLogs(listOf("log-1", "log-2")) - manager.addPendingLogs(listOf("log-3")) - - val result = manager.getPendingLogIds(Int.MAX_VALUE) - - assertEquals(3, result.size) - assertTrue(result.containsAll(listOf("log-1", "log-2", "log-3"))) - } - - @Test - fun `getPendingLogIds returns the oldest IDs up to the limit`() = - runTest { - database.pendingLogDao().insertAll( - listOf( - PendingLog("log-3", "2025-01-03T00:00:00.000Z"), - PendingLog("log-1", "2025-01-01T00:00:00.000Z"), - PendingLog("log-4", "2025-01-04T00:00:00.000Z"), - PendingLog("log-2", "2025-01-02T00:00:00.000Z") - ) - ) - - val result = manager.getPendingLogIds(2) - - assertEquals(listOf("log-1", "log-2"), result) - } - - @Test - fun `hasPendingLogs reflects whether logs are pending`() = - runTest { - assertFalse(manager.hasPendingLogs()) - - manager.addPendingLogs(listOf("log-1")) - - assertTrue(manager.hasPendingLogs()) - } - - @Test - fun `removePendingLogs deletes specified IDs only`() = - runTest { - manager.addPendingLogs(listOf("log-1", "log-2", "log-3")) - - manager.removePendingLogs(listOf("log-1", "log-3")) - - assertEquals(listOf("log-2"), manager.getPendingLogIds(Int.MAX_VALUE)) - } - - @Test - fun `cleanupOldPendingLogs removes old entries`() = - runTest { - database.pendingLogDao().insertAll( - listOf(PendingLog(logId = "old-log", addedAt = "2020-01-01T00:00:00.000Z")) - ) - manager.addPendingLogs(listOf("recent-log")) - - manager.cleanupOldPendingLogs() - - assertEquals(listOf("recent-log"), manager.getPendingLogIds(Int.MAX_VALUE)) - } - - @Test - fun `addPendingLogs with duplicate IDs ignores duplicates`() = - runTest { - manager.addPendingLogs(listOf("log-1", "log-2")) - - manager.addPendingLogs(listOf("log-2", "log-3")) - - val result = manager.getPendingLogIds(Int.MAX_VALUE) - assertEquals(3, result.size) - assertTrue(result.containsAll(listOf("log-1", "log-2", "log-3"))) - } - - @Test - fun `removePendingLogs with empty list is a no-op`() = - runTest { - manager.addPendingLogs(listOf("log-1", "log-2")) - - manager.removePendingLogs(emptyList()) - - assertEquals(2, manager.getPendingLogIds(Int.MAX_VALUE).size) - } - - @Test - fun `removeAllPendingLogs deletes all pending logs`() = - runTest { - manager.addPendingLogs(listOf("log-1", "log-2")) - - manager.removeAllPendingLogs() - - assertFalse(manager.hasPendingLogs()) - } - - @Test - fun `removePendingLogs handles more than 900 items`() = - runTest { - val allIds = (1..1100).map { "log-$it" } - allIds.chunked(500).forEach { chunk -> - manager.addPendingLogs(chunk) - } - assertEquals(1100, manager.getPendingLogIds(Int.MAX_VALUE).size) - - manager.removePendingLogs(allIds) - - val remaining = manager.getPendingLogIds(Int.MAX_VALUE) - assertTrue("Expected empty but got ${remaining.size} items", remaining.isEmpty()) - } -} diff --git a/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt b/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt deleted file mode 100644 index 84b698b0ab72aa..00000000000000 --- a/packages/expo-observe/android/src/test/java/expo/modules/observe/storage/PendingMetricsManagerTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package expo.modules.observe.storage - -import android.content.Context -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [28]) -class PendingMetricsManagerTest { - private lateinit var database: ObserveDatabase - private lateinit var manager: PendingMetricsManager - - @Before - fun setUp() { - val context = ApplicationProvider.getApplicationContext() - database = Room - .inMemoryDatabaseBuilder(context, ObserveDatabase::class.java) - .allowMainThreadQueries() - .build() - manager = PendingMetricsManager(context, database) - } - - @After - fun tearDown() { - database.close() - } - - @Test - fun `addPendingMetrics inserts metrics correctly`() = - runTest { - // Arrange - val metricIds = listOf("metric-1", "metric-2", "metric-3") - - // Act - manager.addPendingMetrics(metricIds) - - // Assert - val result = manager.getPendingMetricIds(Int.MAX_VALUE) - assertEquals(3, result.size) - assertTrue(result.containsAll(metricIds)) - } - - @Test - fun `getPendingMetricIds returns all inserted IDs from multiple add calls`() = - runTest { - // Arrange - manager.addPendingMetrics(listOf("metric-1", "metric-2")) - manager.addPendingMetrics(listOf("metric-3")) - - // Act - val result = manager.getPendingMetricIds(Int.MAX_VALUE) - - // Assert - assertEquals(3, result.size) - assertTrue(result.containsAll(listOf("metric-1", "metric-2", "metric-3"))) - } - - @Test - fun `getPendingMetricIds returns the oldest IDs up to the limit`() = - runTest { - database.pendingMetricDao().insertAll( - listOf( - PendingMetric("metric-3", "2025-01-03T00:00:00.000Z"), - PendingMetric("metric-1", "2025-01-01T00:00:00.000Z"), - PendingMetric("metric-4", "2025-01-04T00:00:00.000Z"), - PendingMetric("metric-2", "2025-01-02T00:00:00.000Z") - ) - ) - - val result = manager.getPendingMetricIds(2) - - assertEquals(listOf("metric-1", "metric-2"), result) - } - - @Test - fun `removePendingMetrics deletes specified IDs only`() = - runTest { - // Arrange - manager.addPendingMetrics(listOf("metric-1", "metric-2", "metric-3")) - - // Act - manager.removePendingMetrics(listOf("metric-1", "metric-3")) - - // Assert - val remaining = manager.getPendingMetricIds(Int.MAX_VALUE) - assertEquals(1, remaining.size) - assertEquals("metric-2", remaining[0]) - } - - @Test - fun `cleanupOldPendingMetrics removes old entries`() = - runTest { - // Arrange - insert a metric with a very old timestamp directly via DAO - val oldMetric = PendingMetric(metricId = "old-metric", addedAt = "2020-01-01T00:00:00.000Z") - database.pendingMetricDao().insertAll(listOf(oldMetric)) - - // Insert a recent metric via the manager (uses current timestamp) - manager.addPendingMetrics(listOf("recent-metric")) - - // Verify both exist - assertEquals(2, manager.getPendingMetricIds(Int.MAX_VALUE).size) - - // Act - manager.cleanupOldPendingMetrics() - - // Assert - only recent metric survives - val remaining = manager.getPendingMetricIds(Int.MAX_VALUE) - assertEquals(1, remaining.size) - assertEquals("recent-metric", remaining[0]) - } - - @Test - fun `addPendingMetrics with duplicate IDs ignores duplicates`() = - runTest { - // Arrange - manager.addPendingMetrics(listOf("metric-1", "metric-2")) - - // Act - insert overlapping IDs - manager.addPendingMetrics(listOf("metric-2", "metric-3")) - - // Assert - no duplicates - val result = manager.getPendingMetricIds(Int.MAX_VALUE) - assertEquals(3, result.size) - assertTrue(result.containsAll(listOf("metric-1", "metric-2", "metric-3"))) - } - - @Test - fun `removePendingMetrics with empty list is a no-op`() = - runTest { - // Arrange - manager.addPendingMetrics(listOf("metric-1", "metric-2")) - - // Act - manager.removePendingMetrics(emptyList()) - - // Assert - nothing removed - assertEquals(2, manager.getPendingMetricIds(Int.MAX_VALUE).size) - } - - @Test - fun `removeAllPendingMetrics deletes all pending metrics`() = - runTest { - manager.addPendingMetrics(listOf("metric-1", "metric-2")) - - manager.removeAllPendingMetrics() - - assertFalse(manager.hasPendingMetrics()) - } - - @Test - fun `removePendingMetrics handles more than 900 items`() = - runTest { - // Arrange - insert 1100 metrics - val allIds = (1..1100).map { "metric-$it" } - allIds.chunked(500).forEach { chunk -> - manager.addPendingMetrics(chunk) - } - assertEquals(1100, manager.getPendingMetricIds(Int.MAX_VALUE).size) - - // Act - remove all 1100 at once - manager.removePendingMetrics(allIds) - - // Assert - all removed - val remaining = manager.getPendingMetricIds(Int.MAX_VALUE) - assertTrue("Expected empty but got ${remaining.size} items", remaining.isEmpty()) - } -} From 217230420f96ae1b183dc724d654a0d341a7825e Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:50 +0200 Subject: [PATCH 04/17] [observe] dispatch native crashes as logs (#49489) # Why We are collecting crash reports locally, but we never dispatch them. # How Similar to JS errors dispatch crashes as logs. 1. When crash is recorded add a new log for it 2. Trim stack trace to 50 frames, so that we don't make the log too big The logs use following attributes: * `event.name`: `"exception"` * `session.id`: session associated with the crash * `severity`: `"FATAL"` * `exception.type`: native exception or signal name * `exception.message`: termination or exception reason * `exception.stacktrace`: native call stack, when available * `expo.error.source`: nativeCrash * `expo.error.is_fatal`: true * `expo.crash.signal`: signal name, such as SIGSEGV * `expo.crash.signal_code`: numeric signal code * `expo.crash.termination_reason`: OS-provided termination reason * `expo.crash.exception_type`: iOS Mach exception name * `expo.crash.exception_type_code`: iOS Mach exception type code * `expo.crash.exception_code`: iOS processor-specific exception code * `expo.crash.objc_exception_type`: Objective-C exception type * `expo.crash.objc_exception_message`: Objective-C exception message android example: ```json { "attributes": { "exception.message": "java.lang.ArithmeticException: divide by zero", "exception.stacktrace": "expo.modules.crashtester.CrashTriggers.throwFor(CrashTester.kt:76)\nexpo.modules.crashtester.CrashTriggers.trigger$lambda$0(CrashTester.kt:65)\nexpo.modules.crashtester.CrashTriggers$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0)\nandroid.os.Handler.handleCallback(Handler.java:873)\nandroid.os.Handler.dispatchMessage(Handler.java:99)\nandroid.os.Looper.loop(Looper.java:193)\nandroid.app.ActivityThread.main(ActivityThread.java:6669)\njava.lang.reflect.Method.invoke(Native Method)\ncom.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)\ncom.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)", "exception.type": "java.lang.ArithmeticException", "expo.error.is_fatal": true, "expo.error.source": "nativeCrash" }, "body": null, "name": "exception", "severity": "fatal", "timestamp": "2026-08-28T12:17:47.406Z" } ``` # Test Plan 1. CI 2. Observe-tester # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-app-metrics/CHANGELOG.md | 1 + .../modules/appmetrics/AppMetricsModule.kt | 5 +- .../appmetrics/crashreporting/CrashReport.kt | 69 +++++++++ .../crashreporting/CrashReportAttribution.kt | 10 +- .../crashreporting/CrashReportProcessor.kt | 13 +- .../appmetrics/storage/SessionManager.kt | 28 +++- .../CrashReportAttributionTest.kt | 16 +++ .../CrashReportProcessorTest.kt | 15 +- .../crashreporting/CrashReportTest.kt | 82 +++++++++++ .../CrashReportingPipelineTest.kt | 6 +- .../ios/CrashReporting/CrashReport.swift | 113 +++++++++++++++ .../ios/Database/MetricsDatabase.swift | 15 ++ .../ios/MetricKitSubscriber.swift | 3 +- .../ios/Sessions/MainSession.swift | 17 --- .../ios/Tests/CrashReportTests.swift | 136 ++++++++++++++++-- .../ios/Tests/MetricsDatabaseTests.swift | 26 ++++ 16 files changed, 513 insertions(+), 42 deletions(-) diff --git a/packages/expo-app-metrics/CHANGELOG.md b/packages/expo-app-metrics/CHANGELOG.md index 29529d66d222f4..409d11fb8ed4ea 100644 --- a/packages/expo-app-metrics/CHANGELOG.md +++ b/packages/expo-app-metrics/CHANGELOG.md @@ -8,6 +8,7 @@ ### 🎉 New features +- [iOS] [Android] Dispatch attributed native crashes as fatal exception logs. ([#49489](https://github.com/expo/expo/pull/49489) by [@Ubax](https://github.com/Ubax)) - Add an optional `displayName` to `logEvent` ([#47289](https://github.com/expo/expo/pull/47289) by [@Ubax](https://github.com/Ubax)) - Capture React render-phase errors via `AppMetricsErrorBoundary`. ([#47341](https://github.com/expo/expo/pull/47341) by [@tsapeta](https://github.com/tsapeta)) - Describe the network a launch ran on: connection cost, request throughput, and a `slowest.*` group replacing `expo.network.requests.slowestDuration` and `slowestHost`. ([#48518](https://github.com/expo/expo/pull/48518) by [@tsapeta](https://github.com/tsapeta)) diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/AppMetricsModule.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/AppMetricsModule.kt index 81a1ccfb444122..8f37380a2b9329 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/AppMetricsModule.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/AppMetricsModule.kt @@ -168,13 +168,14 @@ class AppMetricsModule : Module(), UpdatesStateChangeListener { exitInfoProvider = ExitInfoProviderImpl(context), lastProcessedExitStore = PreferencesLastProcessedExitStore(context), appVersion = metadata?.appVersion - ) { sessionId, origin, report -> + ) { sessionId, origin, report, logDetails -> attributeAndStoreCrashReport( sessionManager = sessionManager, currentSessionId = mainSession.sessionId, sessionId = sessionId, origin = origin, - report = report + report = report, + logDetails = logDetails ) }.process() } diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReport.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReport.kt index 81215c120321d7..3a505e91a796fd 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReport.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReport.kt @@ -1,5 +1,9 @@ package expo.modules.appmetrics.crashreporting +import expo.modules.appmetrics.logevents.Severity +import expo.modules.appmetrics.logevents.truncateToMaxLength +import expo.modules.appmetrics.storage.LogRecord +import expo.modules.appmetrics.utils.JsonAny import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -28,6 +32,51 @@ data class CrashReport( */ val ingestedAt: String ) { + fun toLogRecord(sessionId: String, details: CrashLogDetails = CrashLogDetails()): LogRecord { + val attributes = buildMap { + put("exception.type", details.exceptionType ?: signal?.let(::signalName) ?: "NativeCrash") + put("exception.message", terminationReason ?: exceptionReason ?: signal?.let(::signalName) ?: "Native crash") + put("exception.stacktrace", renderStacktrace(details.stackFrames)) + put("expo.error.source", "nativeCrash") + put("expo.error.is_fatal", true) + signal?.let { + put("expo.crash.signal", signalName(it)) + put("expo.crash.signal_number", it) + } + terminationReason?.let { put("expo.crash.termination_reason", it) } + } + return LogRecord( + sessionId = sessionId, + timestamp = timestampBegin, + name = "native.exception", + severity = Severity.FATAL.rawValue, + attributes = JsonAny.encodeMapToJsonString(attributes) + ) + } + + private fun renderStacktrace(stackFrames: List?): String? { + val callStacks = callStackTree?.callStacks.orEmpty() + val attributedStacks = callStacks.filter { it.threadAttributed == true } + val selectedStacks = attributedStacks.ifEmpty { callStacks } + val frames = stackFrames ?: selectedStacks + .flatMap { it.callStackRootFrames.orEmpty() } + .map { it.symbol ?: "" } + if (frames.isEmpty()) { + return null + } + val rendered = buildList { + addAll(frames.take(MAX_LOG_STACK_FRAMES)) + if (frames.size > MAX_LOG_STACK_FRAMES) { + add("… +${frames.size - MAX_LOG_STACK_FRAMES} more frames") + } + }.joinToString("\n") + return truncateToMaxLength( + rendered, + MAX_LOG_STACKTRACE_LENGTH, + "Native crash stack trace exceeded the maximum length and was truncated." + ) + } + @Serializable data class CallStackTree( val callStacks: List? = null @@ -93,5 +142,25 @@ data class CrashReport( } private const val MAX_CAUSE_DEPTH = 5 + private const val MAX_LOG_STACK_FRAMES = 25 + private const val MAX_LOG_STACKTRACE_LENGTH = 65_536 + + private fun signalName(signal: Int): String = + when (signal) { + 4 -> "SIGILL" + 5 -> "SIGTRAP" + 6 -> "SIGABRT" + 7 -> "SIGBUS" + 8 -> "SIGFPE" + 9 -> "SIGKILL" + 11 -> "SIGSEGV" + 15 -> "SIGTERM" + else -> "SIG$signal" + } } } + +data class CrashLogDetails( + val exceptionType: String? = null, + val stackFrames: List? = null +) diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportAttribution.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportAttribution.kt index 50f1a9a9f8a0fe..9d3dcf6f0fe05f 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportAttribution.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportAttribution.kt @@ -13,7 +13,8 @@ suspend fun attributeAndStoreCrashReport( currentSessionId: String?, sessionId: String?, origin: CrashOrigin, - report: CrashReport + report: CrashReport, + logDetails: CrashLogDetails = CrashLogDetails() ) { runCatching { // A null target stores the report as an orphan. @@ -29,7 +30,12 @@ suspend fun attributeAndStoreCrashReport( // id-less JVM file (a crash before the main session existed) → orphan. else -> null } - sessionManager.setCrashReport(target, report.encodeToJsonString()) + val payload = report.encodeToJsonString() + if (target != null) { + sessionManager.storeCrashReportIfNew(target, payload, report.toLogRecord(target, logDetails)) + } else { + sessionManager.setCrashReport(null, payload) + } }.onFailure { Log.e(TAG, "Failed to persist a crash report", it) } diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportProcessor.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportProcessor.kt index 8043860ac11fd8..2e4edb5bae6817 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportProcessor.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/crashreporting/CrashReportProcessor.kt @@ -46,7 +46,12 @@ class CrashReportProcessor( private val exitInfoProvider: ExitInfoProvider, private val lastProcessedExitStore: LastProcessedExitStore, private val appVersion: String?, - private val storeReport: suspend (sessionId: String?, origin: CrashOrigin, report: CrashReport) -> Unit + private val storeReport: suspend ( + sessionId: String?, + origin: CrashOrigin, + report: CrashReport, + logDetails: CrashLogDetails + ) -> Unit ) { suspend fun process() { crashFileReader.deleteOrphanedTempFiles() @@ -95,7 +100,8 @@ class CrashReportProcessor( storeReport( file.sessionId, CrashOrigin.JVM_FILE, - file.toCrashReport(ingestedAt, resolvedAppVersion) + file.toCrashReport(ingestedAt, resolvedAppVersion), + CrashLogDetails(exceptionType = file.exceptionClass, stackFrames = file.stackFrames) ) crashFileReader.delete(file) } @@ -111,7 +117,8 @@ class CrashReportProcessor( storeReport( null, CrashOrigin.EXIT_RECORD, - record.toCrashReport(ingestedAt, resolvedAppVersion) + record.toCrashReport(ingestedAt, resolvedAppVersion), + CrashLogDetails() ) } } diff --git a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt index d1c0abaa63a044..9901ab4fa698cf 100644 --- a/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt +++ b/packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/storage/SessionManager.kt @@ -1,6 +1,7 @@ package expo.modules.appmetrics.storage import android.content.Context +import androidx.room.withTransaction import expo.modules.appmetrics.AppMetadata import expo.modules.appmetrics.AppMetricsPreferences import expo.modules.appmetrics.GlobalAttributes @@ -99,6 +100,25 @@ class SessionManager( ) } + /** + * Stores a crash report and its log for `sessionId`, but only when the session + * has no report yet. Reprocessing the same crash must not add a second log. + */ + suspend fun storeCrashReportIfNew(sessionId: String, payload: String, log: LogRecord) { + database.withTransaction { + if (database.crashReportDao().getBySessionId(sessionId) == null) { + database.crashReportDao().upsert( + CrashReportEntity( + sessionId = sessionId, + payload = payload, + createdAt = TimeUtils.getCurrentTimestampInISOFormat() + ) + ) + database.logDao().insertAll(logsWithSession(listOf(log), sessionId)) + } + } + } + suspend fun getCrashReport(sessionId: String): String? = database.crashReportDao().getBySessionId(sessionId)?.payload @@ -168,14 +188,16 @@ class SessionManager( logs: List, sessionId: String ) { - val logsWithSession = logs.map { log -> + database.logDao().insertAll(logsWithSession(logs, sessionId)) + } + + private fun logsWithSession(logs: List, sessionId: String): List = + logs.map { log -> log.copy( sessionId = sessionId, attributes = mergeGlobalAttributesIntoJsonString(log.attributes) ) } - database.logDao().insertAll(logsWithSession) - } suspend fun cleanupOldLogs() { val cutoffTimestamp = TimeUtils.getTimestampInISOFormatFromPast(MetricsConstants.SECONDS_TO_REMOVE_OLD_METRICS) diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportAttributionTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportAttributionTest.kt index 0f5d9db83b8738..843ac873cbeb75 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportAttributionTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportAttributionTest.kt @@ -78,6 +78,18 @@ class CrashReportAttributionTest { attribute("crashed-session", CrashOrigin.JVM_FILE) assertEquals("java.lang.IllegalStateException: boom", storedMessage("crashed-session")) + assertEquals(listOf("native.exception"), sessionManager.getLogsForSession("crashed-session").map { it.name }) + } + + @Test + fun `does not duplicate the crash log when a report is reprocessed`() = + runTest { + sessionManager.startSessionWithIdAt("crashed-session", "2023-11-14T22:00:00.000Z") + + attribute("crashed-session", CrashOrigin.JVM_FILE) + attribute("crashed-session", CrashOrigin.JVM_FILE) + + assertEquals(1, sessionManager.getLogsForSession("crashed-session").size) } @Test @@ -89,6 +101,7 @@ class CrashReportAttributionTest { assertNull(sessionManager.getCrashReport("never-persisted")) assertEquals(1, orphanCount()) + assertEquals(0, sessionManager.getLogsForSession("never-persisted").size) } @Test @@ -98,6 +111,7 @@ class CrashReportAttributionTest { attribute(null, CrashOrigin.JVM_FILE) assertEquals(1, orphanCount()) + assertEquals(0, sessionManager.getLogsForSession("current").size) } // endregion @@ -114,6 +128,7 @@ class CrashReportAttributionTest { assertEquals("java.lang.IllegalStateException: native", storedMessage("previous")) assertNull(sessionManager.getCrashReport("older")) + assertEquals(listOf("native.exception"), sessionManager.getLogsForSession("previous").map { it.name }) } @Test @@ -126,6 +141,7 @@ class CrashReportAttributionTest { // Never blame the live session — stored unattributed instead. assertNull(sessionManager.getCrashReport("current")) assertEquals(1, orphanCount()) + assertEquals(0, sessionManager.getLogsForSession("current").size) } @Test diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportProcessorTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportProcessorTest.kt index 9414a69316e669..8f9424879f94f9 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportProcessorTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportProcessorTest.kt @@ -44,13 +44,18 @@ class CrashReportProcessorTest { } } - data class StoreCall(val sessionId: String?, val origin: CrashOrigin, val report: CrashReport) + data class StoreCall( + val sessionId: String?, + val origin: CrashOrigin, + val report: CrashReport, + val logDetails: CrashLogDetails + ) private val storeCalls = mutableListOf() - private val storeReport: suspend (String?, CrashOrigin, CrashReport) -> Unit = - { sessionId, origin, report -> - storeCalls += StoreCall(sessionId, origin, report) + private val storeReport: suspend (String?, CrashOrigin, CrashReport, CrashLogDetails) -> Unit = + { sessionId, origin, report, logDetails -> + storeCalls += StoreCall(sessionId, origin, report, logDetails) } @Before @@ -110,6 +115,8 @@ class CrashReportProcessorTest { assertEquals(CrashOrigin.JVM_FILE, call.origin) assertEquals("java.lang.IllegalStateException: boom", call.report.exceptionReason) assertEquals("1.2.3", call.report.appVersion) + assertEquals("java.lang.IllegalStateException", call.logDetails.exceptionType) + assertTrue(call.logDetails.stackFrames.orEmpty().isNotEmpty()) } @Test diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportTest.kt index e5881a07ba908f..8437f960acd07f 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportTest.kt @@ -3,6 +3,7 @@ package expo.modules.appmetrics.crashreporting import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import expo.modules.appmetrics.utils.JsonAny import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -96,6 +97,87 @@ class CrashReportTest { assertTrue(frames.first().symbol!!.contains("CrashReportTest")) } + @Test + fun `builds a fatal exception log for a JVM crash`() { + val report = reportFromThrowable(IllegalStateException("boom")) + + val log = report.toLogRecord( + "session", + CrashLogDetails(exceptionType = "java.lang.IllegalStateException") + ) + val attributes = requireNotNull(JsonAny.decodeJsonStringToMap(requireNotNull(log.attributes))) + + assertEquals("session", log.sessionId) + assertEquals("native.exception", log.name) + assertEquals("fatal", log.severity) + assertEquals(crashTimestamp, log.timestamp) + assertEquals("java.lang.IllegalStateException", attributes["exception.type"]) + assertEquals("java.lang.IllegalStateException: boom", attributes["exception.message"]) + assertEquals("nativeCrash", attributes["expo.error.source"]) + assertEquals(true, attributes["expo.error.is_fatal"]) + } + + @Test + fun `builds a fatal exception log for an exit record`() { + val report = CrashReport( + signal = 11, + terminationReason = "Native crash", + appVersion = "1.2.3", + timestampBegin = crashTimestamp, + ingestedAt = ingestedAt + ) + + val attributes = requireNotNull( + JsonAny.decodeJsonStringToMap(requireNotNull(report.toLogRecord("session").attributes)) + ) + + assertEquals("SIGSEGV", attributes["exception.type"]) + assertEquals("Native crash", attributes["exception.message"]) + assertNull(attributes["exception.stacktrace"]) + assertEquals("SIGSEGV", attributes["expo.crash.signal"]) + assertEquals(11L, attributes["expo.crash.signal_number"]) + assertEquals("Native crash", attributes["expo.crash.termination_reason"]) + } + + @Test + fun `renders at most twenty-five attributed stack frames and reports the omitted count`() { + val report = CrashReport( + exceptionReason = "boom", + callStackTree = CrashReport.CallStackTree( + callStacks = listOf( + CrashReport.CallStackTree.CallStack( + threadAttributed = true, + callStackRootFrames = (0 until 28).map { CrashReport.CallStackTree.Frame("frame$it") } + ), + CrashReport.CallStackTree.CallStack( + threadAttributed = false, + callStackRootFrames = listOf(CrashReport.CallStackTree.Frame("unattributed")) + ) + ) + ), + appVersion = "1.2.3", + timestampBegin = crashTimestamp, + ingestedAt = ingestedAt + ) + + val attributes = requireNotNull( + JsonAny.decodeJsonStringToMap( + requireNotNull( + report.toLogRecord( + "session", + CrashLogDetails(exceptionType = "java.lang.IllegalStateException") + ).attributes + ) + ) + ) + val lines = requireNotNull(attributes["exception.stacktrace"] as? String).lines() + assertEquals(26, lines.size) + assertEquals("frame0", lines.first()) + assertEquals("frame24", lines[24]) + assertEquals("… +3 more frames", lines.last()) + assertFalse(lines.contains("unattributed")) + } + // MARK: JSON encoding — the payload is the cross-platform contract with types.ts @Test diff --git a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportingPipelineTest.kt b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportingPipelineTest.kt index c72c68ad267220..6ee7670768d501 100644 --- a/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportingPipelineTest.kt +++ b/packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/crashreporting/CrashReportingPipelineTest.kt @@ -101,13 +101,14 @@ class CrashReportingPipelineTest { } }, appVersion = "3.1.4" - ) { sessionId, origin, report -> + ) { sessionId, origin, report, logDetails -> attributeAndStoreCrashReport( sessionManager = sessionManager, currentSessionId = currentSessionId, sessionId = sessionId, origin = origin, - report = report + report = report, + logDetails = logDetails ) }.process() @@ -117,6 +118,7 @@ class CrashReportingPipelineTest { val crashed = sessions.single { it.id == crashedSessionId } val crashReport = requireNotNull(crashed.crashReport) + assertEquals(listOf("native.exception"), crashed.logs.map { it.name }) assertEquals("3.1.4", crashReport["appVersion"]) assertEquals("java.lang.IllegalStateException: boom", crashReport["exceptionReason"]) @Suppress("UNCHECKED_CAST") diff --git a/packages/expo-app-metrics/ios/CrashReporting/CrashReport.swift b/packages/expo-app-metrics/ios/CrashReporting/CrashReport.swift index 9a58f789ff2d9d..134ec89a6d25a4 100644 --- a/packages/expo-app-metrics/ios/CrashReporting/CrashReport.swift +++ b/packages/expo-app-metrics/ios/CrashReporting/CrashReport.swift @@ -6,6 +6,9 @@ /// payload time window. Unhandled JavaScript errors are recorded separately as `exception` log /// events (see `ErrorReport`). public struct CrashReport: Codable, Sendable { + private static let maxLogStackFrames = 25 + private static let maxLogStacktraceLength = 65_536 + /// Mach exception type (e.g. EXC_BAD_ACCESS, EXC_CRASH). public let exceptionType: Int? @@ -87,6 +90,116 @@ public struct CrashReport: Codable, Sendable { return candidates.max(by: { $0.startTimestamp < $1.startTimestamp }) } + func toLogRecord() -> LogRecord { + var attributes: [String: Any] = [ + "exception.type": exceptionReason?.exceptionName + ?? exceptionType.map { exceptionName(for: $0) } + ?? signal.map { signalName(for: $0) } + ?? "NativeCrash", + "exception.message": exceptionMessage, + "exception.stacktrace": renderedStacktrace as Any, + "expo.error.source": "nativeCrash", + "expo.error.is_fatal": true, + ] + if let exceptionType { + attributes["expo.crash.exception_type"] = exceptionName(for: exceptionType) + attributes["expo.crash.exception_type_code"] = exceptionType + } + if let exceptionCode { + attributes["expo.crash.exception_code"] = exceptionCode + } + if let signal { + attributes["expo.crash.signal"] = signalName(for: signal) + attributes["expo.crash.signal_number"] = signal + } + if let terminationReason { + attributes["expo.crash.termination_reason"] = terminationReason + } + if let exceptionReason { + attributes["expo.crash.objc_exception_type"] = exceptionReason.exceptionType + attributes["expo.crash.objc_exception_message"] = exceptionReason.composedMessage + } + return LogRecord( + name: "native.exception", + attributes: attributes, + severity: .fatal, + // MetricKit gives a time window; its end is the tightest crash-time bound and matches Android. + timestamp: timestampEnd.ISO8601Format() + ) + } + + private var exceptionMessage: String { + let parts = [terminationReason, exceptionReason?.composedMessage].compactMap { value -> String? in + guard let value, !value.isEmpty else { + return nil + } + return value + } + if !parts.isEmpty { + return parts.joined(separator: "\n") + } + if let signal { + return signalName(for: signal) + } + if let exceptionType { + return exceptionName(for: exceptionType) + } + return "Native crash" + } + + private var renderedStacktrace: String? { + guard let callStacks = callStackTree?.callStacks else { + return nil + } + let attributedStacks = callStacks.filter { $0.threadAttributed == true } + let selectedStacks = attributedStacks.isEmpty ? callStacks : attributedStacks + var frames: [CallStackTree.Frame] = [] + var totalFrames = 0 + var lines: [String] = [] + for callStack in selectedStacks { + collectFrames(callStack.callStackRootFrames ?? [], into: &frames, total: &totalFrames) + } + lines.append(contentsOf: frames.map(renderFrame)) + if totalFrames > frames.count { + lines.append("… +\(totalFrames - frames.count) more frames") + } + guard !lines.isEmpty else { + return nil + } + return truncateToMaxLength( + lines.joined(separator: "\n"), + maxLength: Self.maxLogStacktraceLength, + warningMessage: "[AppMetrics] Native crash stack trace exceeded the maximum length and was truncated." + ) + } + + private func collectFrames( + _ source: [CallStackTree.Frame], + into frames: inout [CallStackTree.Frame], + total: inout Int + ) { + for frame in source { + total += 1 + if frames.count < Self.maxLogStackFrames { + frames.append(frame) + } + collectFrames(frame.subFrames ?? [], into: &frames, total: &total) + } + } + + private func renderFrame(_ frame: CallStackTree.Frame) -> String { + if let symbol = frame.symbol { + return symbol + } + if let binaryName = frame.binaryName, let offset = frame.offsetIntoBinaryTextSegment { + return "\(binaryName) + \(offset)" + } + if let address = frame.address { + return "0x\(String(address, radix: 16))" + } + return frame.binaryName ?? "" + } + /// Mirrors the shape of `MXCallStackTree.JSONRepresentation()`. Every field is optional so that /// silently-renamed or removed Apple fields don't break decoding for the rest of the report. public struct CallStackTree: Codable, Sendable { diff --git a/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift b/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift index 642eab9cf2faf3..fd848193b59adb 100644 --- a/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift +++ b/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift @@ -542,6 +542,21 @@ final class MetricsDatabase: Sendable { try statement.run() } + /// Stores a crash report once and, when its session row exists, its dispatchable log atomically. + /// The crash report's primary key is the idempotence marker for MetricKit payload redelivery. + @AppMetricsActor + func storeCrashReportIfNew(sessionId: String, payload: String, log: LogRow) throws { + try database.transaction { + if try getCrashReport(sessionId: sessionId) != nil { + return + } + try setCrashReport(sessionId: sessionId, payload: payload) + if try getSession(id: sessionId) != nil { + try insert(log: log) + } + } + } + @AppMetricsActor func getCrashReport(sessionId: String) throws -> String? { let statement = try database.prepare("SELECT payload FROM crash_reports WHERE sessionId = ?1") diff --git a/packages/expo-app-metrics/ios/MetricKitSubscriber.swift b/packages/expo-app-metrics/ios/MetricKitSubscriber.swift index 18305d8a0f1946..8a7f09487b5ae1 100644 --- a/packages/expo-app-metrics/ios/MetricKitSubscriber.swift +++ b/packages/expo-app-metrics/ios/MetricKitSubscriber.swift @@ -57,7 +57,8 @@ private func persistCrashReport(_ crashReport: CrashReport, sessionId: String) { return } do { - try AppMetrics.database?.setCrashReport(sessionId: sessionId, payload: payload) + let log = LogRow.from(log: crashReport.toLogRecord(), sessionId: sessionId) + try AppMetrics.database?.storeCrashReportIfNew(sessionId: sessionId, payload: payload, log: log) } catch { logger.warn("[AppMetrics] Failed to persist crash report for session \(sessionId): \(error.localizedDescription)") } diff --git a/packages/expo-app-metrics/ios/Sessions/MainSession.swift b/packages/expo-app-metrics/ios/Sessions/MainSession.swift index fbfe8456cfb239..2835268f93f48e 100644 --- a/packages/expo-app-metrics/ios/Sessions/MainSession.swift +++ b/packages/expo-app-metrics/ios/Sessions/MainSession.swift @@ -33,21 +33,4 @@ public final class MainSession: Session, @unchecked Sendable { init(id: String, startDate: Date, endDate: Date?) { super.init(id: id, type: .main, startDate: startDate, endDate: endDate) } - - // MARK: - Crash reports - - /// Persists a crash report attributed to this session. Replaces any previously stored report for - /// this session id (only one crash per session is meaningful). - @AppMetricsActor - func storeCrashReport(_ crashReport: CrashReport) { - logger.warn("[AppMetrics] Received crash report:\n\(crashReport)") - guard let payload = encodeAsJSONString(crashReport) else { - return - } - do { - try AppMetrics.database?.setCrashReport(sessionId: self.id, payload: payload) - } catch { - logger.warn("[AppMetrics] Failed to persist crash report for session \(self.id): \(error.localizedDescription)") - } - } } diff --git a/packages/expo-app-metrics/ios/Tests/CrashReportTests.swift b/packages/expo-app-metrics/ios/Tests/CrashReportTests.swift index a5fcc2b462496b..5a709e85101273 100644 --- a/packages/expo-app-metrics/ios/Tests/CrashReportTests.swift +++ b/packages/expo-app-metrics/ios/Tests/CrashReportTests.swift @@ -163,6 +163,116 @@ struct CrashReportTests { #expect(report.findMatchingSession(in: []) == nil) } } + + @Suite("toLogRecord") + struct ToLogRecordTests { + @Test + func `builds a fatal exception log for a Mach exception`() throws { + let timestampEnd = Date(timeIntervalSince1970: 1_699_999_000) + let ingestedAt = Date(timeIntervalSince1970: 1_700_000_000) + let report = makeCrashReport( + timestampBegin: timestampEnd.addingTimeInterval(-3600), + timestampEnd: timestampEnd, + ingestedAt: ingestedAt, + exceptionType: 1, + exceptionCode: 2, + signal: 11, + terminationReason: "Namespace SIGNAL, Code 11" + ) + + let log = report.toLogRecord() + let attributes = try #require(log.attributes?.value as? [String: Any]) + + #expect(log.name == "native.exception") + #expect(log.severity == .fatal) + #expect(log.timestamp == timestampEnd.ISO8601Format()) + #expect(attributes["exception.type"] as? String == "EXC_BAD_ACCESS") + #expect(attributes["exception.message"] as? String == "Namespace SIGNAL, Code 11") + #expect(attributes["expo.error.source"] as? String == "nativeCrash") + #expect(attributes["expo.error.is_fatal"] as? Bool == true) + #expect(attributes["expo.crash.exception_type"] as? String == "EXC_BAD_ACCESS") + #expect(attributes["expo.crash.exception_type_code"] as? Int == 1) + #expect(attributes["expo.crash.exception_code"] as? Int == 2) + #expect(attributes["expo.crash.signal"] as? String == "SIGSEGV") + #expect(attributes["expo.crash.signal_number"] as? Int == 11) + #expect(attributes["expo.crash.termination_reason"] as? String == "Namespace SIGNAL, Code 11") + } + + @Test + func `uses Objective-C exception details`() throws { + let report = makeCrashReport( + timestampBegin: Date.now, + timestampEnd: Date.now, + terminationReason: "Application Specific Information", + exceptionReason: CrashReport.ExceptionReason( + composedMessage: "-[NSNull length]: unrecognized selector", + formatString: "%@: unrecognized selector", + arguments: ["-[NSNull length]"], + exceptionType: "NSInvalidArgumentException", + className: "NSException", + exceptionName: "NSInvalidArgumentException" + ) + ) + + let attributes = try #require(report.toLogRecord().attributes?.value as? [String: Any]) + #expect(attributes["exception.type"] as? String == "NSInvalidArgumentException") + #expect( + attributes["exception.message"] as? String + == "Application Specific Information\n-[NSNull length]: unrecognized selector" + ) + #expect(attributes["expo.crash.objc_exception_type"] as? String == "NSInvalidArgumentException") + #expect( + attributes["expo.crash.objc_exception_message"] as? String == "-[NSNull length]: unrecognized selector" + ) + } + + @Test + func `renders at most twenty-five attributed stack frames and reports the omitted count`() throws { + let attributedFrames = (0..<28).map { index in + CrashReport.CallStackTree.Frame( + binaryName: "TestApp", + binaryUUID: nil, + address: nil, + offsetIntoBinaryTextSegment: nil, + sampleCount: nil, + subFrames: nil, + symbol: "frame\(index)" + ) + } + let unattributedFrame = CrashReport.CallStackTree.Frame( + binaryName: "TestApp", + binaryUUID: nil, + address: nil, + offsetIntoBinaryTextSegment: nil, + sampleCount: nil, + subFrames: nil, + symbol: "unattributed" + ) + let report = makeCrashReport( + timestampBegin: Date.now, + timestampEnd: Date.now, + callStackTree: CrashReport.CallStackTree(callStacks: [ + CrashReport.CallStackTree.CallStack( + threadAttributed: true, + callStackRootFrames: attributedFrames + ), + CrashReport.CallStackTree.CallStack( + threadAttributed: false, + callStackRootFrames: [unattributedFrame] + ), + ]) + ) + + let attributes = try #require(report.toLogRecord().attributes?.value as? [String: Any]) + let stacktrace = try #require(attributes["exception.stacktrace"] as? String) + let lines = stacktrace.split(separator: "\n") + #expect(lines.count == 26) + #expect(lines.first == "frame0") + #expect(lines[24] == "frame24") + #expect(lines.last == "… +3 more frames") + #expect(!stacktrace.contains("unattributed")) + } + } } private func makeMainSessionRow(id: String, startDate: Date, endDate: Date?) -> SessionRow { @@ -175,18 +285,28 @@ private func makeMainSessionRow(id: String, startDate: Date, endDate: Date?) -> ) } -private func makeCrashReport(timestampBegin: Date, timestampEnd: Date) -> CrashReport { +private func makeCrashReport( + timestampBegin: Date, + timestampEnd: Date, + ingestedAt: Date = Date.now, + exceptionType: Int? = 1, + exceptionCode: Int? = 1, + signal: Int? = 11, + terminationReason: String? = nil, + exceptionReason: CrashReport.ExceptionReason? = nil, + callStackTree: CrashReport.CallStackTree? = nil +) -> CrashReport { return CrashReport( - exceptionType: 1, - exceptionCode: 1, - signal: 11, - terminationReason: nil, + exceptionType: exceptionType, + exceptionCode: exceptionCode, + signal: signal, + terminationReason: terminationReason, virtualMemoryRegionInfo: nil, - exceptionReason: nil, - callStackTree: nil, + exceptionReason: exceptionReason, + callStackTree: callStackTree, appVersion: "1.0.0", timestampBegin: timestampBegin, timestampEnd: timestampEnd, - ingestedAt: Date.now + ingestedAt: ingestedAt ) } diff --git a/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift b/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift index c4e4272d1dd395..9d702d78c229de 100644 --- a/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift +++ b/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift @@ -548,6 +548,32 @@ struct MetricsDatabaseTests { } } + @Test + func `stores a crash report and log only once`() throws { + try withTemporaryDatabase { database in + try database.insert(session: makeSessionRow(id: "s")) + let log = makeLogRow(sessionId: "s", severity: "fatal", name: "exception") + + try database.storeCrashReportIfNew(sessionId: "s", payload: "{\"v\":1}", log: log) + try database.storeCrashReportIfNew(sessionId: "s", payload: "{\"v\":2}", log: log) + + #expect(try database.getCrashReport(sessionId: "s") == "{\"v\":1}") + #expect(try database.getLogs(sessionId: "s").map(\.name) == ["exception"]) + } + } + + @Test + func `stores the crash report without a log when the session does not exist`() throws { + try withTemporaryDatabase { database in + let log = makeLogRow(sessionId: "missing", severity: "fatal", name: "exception") + + try database.storeCrashReportIfNew(sessionId: "missing", payload: "{}", log: log) + + #expect(try database.getCrashReport(sessionId: "missing") == "{}") + #expect(try database.getLogs(sessionId: "missing").isEmpty) + } + } + @Test func `getCrashReport returns nil when there is no entry`() throws { try withTemporaryDatabase { database in From 756a18bcc3f5a91dd20b8814f90be8397aaaf566 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Tue, 1 Sep 2026 16:52:08 +0530 Subject: [PATCH 05/17] [ui][router][docs] Add missing screenshots for `cornerRadius` and other follow-up changes (#49557) # Why Follow-up https://github.com/expo/expo/pull/49515 # How - Add missing screenshots for `cornerRadius` in the example - Generate Expo UI and Expo Router API reference data - Add missing changelog entry # Test Plan CleanShot 2026-08-31 at 21 54
18@2x # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- .../sdk/ui/jetpack-compose/dropdownmenu.mdx | 2 ++ .../sdk/ui/jetpack-compose/dropdownmenu.mdx | 2 ++ .../static/data/unversioned/expo-router.json | 2 +- .../expo-router/experimental-stack.json | 2 +- .../data/unversioned/expo-router/link.json | 2 +- .../unversioned/expo-router/native-tabs.json | 2 +- .../data/unversioned/expo-router/stack.json | 2 +- .../static/data/unversioned/expo-router/ui.json | 2 +- .../expo-ui/jetpack-compose/dropdownmenu.json | 2 +- .../expo-ui/jetpack-compose/dropdownmenu.json | 2 +- .../dropdownmenu-rounded-android-dark.webp | Bin 0 -> 8674 bytes .../dropdownmenu-rounded-android-light.webp | Bin 0 -> 8432 bytes packages/expo-router/CHANGELOG.md | 2 +- packages/expo-ui/CHANGELOG.md | 2 +- 14 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 docs/public/static/images/expo-ui/examples/dropdownmenu-rounded-android-dark.webp create mode 100644 docs/public/static/images/expo-ui/examples/dropdownmenu-rounded-android-light.webp diff --git a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx index ec2d547e35680b..772c020149ebff 100644 --- a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx @@ -217,6 +217,8 @@ Use `cornerRadius` to override the default Material3 menu shape with a custom co ```tsx diff --git a/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx b/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx index 5cc6cae0d2545b..56ccec4358758d 100644 --- a/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx +++ b/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx @@ -217,6 +217,8 @@ Use `cornerRadius` to override the default Material3 menu shape with a custom co ```tsx diff --git a/docs/public/static/data/unversioned/expo-router.json b/docs/public/static/data/unversioned/expo-router.json index b847d3382d2473..d4627d6c4d6f97 100644 --- a/docs/public/static/data/unversioned/expo-router.json +++ b/docs/public/static/data/unversioned/expo-router.json @@ -1 +1 @@ -{"schemaVersion":"2.0","name":"expo-router","variant":"project","kind":1,"children":[{"name":"ActionDispatchedEvent","variant":"declaration","kind":256,"children":[{"name":"actionType","variant":"declaration","kind":1024,"comment":{"summary":[{"kind":"text","text":"The action type from the dispatched NavigationAction (e.g. "},{"kind":"code","text":"`NAVIGATE`"},{"kind":"text","text":")."}]},"type":{"type":"intrinsic","name":"string"}},{"name":"payload","variant":"declaration","kind":1024,"type":{"type":"union","types":[{"type":"intrinsic","name":"object"},{"type":"intrinsic","name":"undefined"}]}},{"name":"state","variant":"declaration","kind":1024,"type":{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/global-state/types.ts","qualifiedName":"ReactNavigationState"},"name":"ReactNavigationState","package":"expo-router"}},{"name":"type","variant":"declaration","kind":1024,"type":{"type":"literal","value":"actionDispatched"}}]},{"name":"AnalyticsEvent","variant":"declaration","kind":2097152,"type":{"type":"union","types":[{"type":"reference","name":"PagePreloadedEvent","package":"expo-router"},{"type":"reference","name":"PageFocusedEvent","package":"expo-router"},{"type":"reference","name":"PageBlurredEvent","package":"expo-router"},{"type":"reference","name":"PageRemoved","package":"expo-router"},{"type":"reference","name":"ActionDispatchedEvent","package":"expo-router"}]}},{"name":"Badge","variant":"declaration","kind":64,"signatures":[{"name":"Badge","variant":"signature","kind":4096,"parameters":[{"name":"props","variant":"param","kind":32768,"type":{"type":"reference","name":"BadgeProps","package":"expo-router"}}],"type":{"type":"literal","value":null}}]},{"name":"BadgeProps","variant":"declaration","kind":2097152,"type":{"type":"union","types":[{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/native-tabs/common/elements.tsx","qualifiedName":"NativeTabsTriggerBadgeProps"},"name":"NativeTabsTriggerBadgeProps","package":"expo-router"},{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/layouts/stack-utils/toolbar/toolbar-primitives.tsx","qualifiedName":"StackToolbarBadgeProps"},"name":"StackToolbarBadgeProps","package":"expo-router"}]}},{"name":"DarkTheme","variant":"declaration","kind":32,"flags":{"isConst":true},"type":{"type":"reference","name":"Theme","package":"expo-router"},"defaultValue":"..."},{"name":"DefaultTheme","variant":"declaration","kind":32,"flags":{"isConst":true},"type":{"type":"reference","name":"Theme","package":"expo-router"},"defaultValue":"..."},{"name":"EffectCallback","variant":"declaration","kind":2097152,"comment":{"summary":[{"kind":"text","text":"Memoized callback containing the effect, should optionally return a cleanup function."}]},"type":{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"signatures":[{"name":"__type","variant":"signature","kind":4096,"type":{"type":"union","types":[{"type":"intrinsic","name":"undefined"},{"type":"intrinsic","name":"void"},{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"signatures":[{"name":"__type","variant":"signature","kind":4096,"type":{"type":"intrinsic","name":"void"}}]}}]}}]}}},{"name":"ErrorBoundary","variant":"declaration","kind":64,"signatures":[{"name":"ErrorBoundary","variant":"signature","kind":4096,"parameters":[{"name":"__namedParameters","variant":"param","kind":32768,"type":{"type":"reference","name":"ErrorBoundaryProps","package":"expo-router"}}],"type":{"type":"reference","target":{"packageName":"@types/react","packagePath":"jsx-runtime.d.ts","qualifiedName":"JSX.Element"},"name":"Element","package":"@types/react","qualifiedName":"JSX.Element"}}]},{"name":"ErrorBoundaryProps","variant":"declaration","kind":2097152,"comment":{"summary":[{"kind":"text","text":"Props passed to a page's "},{"kind":"code","text":"`ErrorBoundary`"},{"kind":"text","text":" export."}]},"children":[{"name":"error","variant":"declaration","kind":1024,"comment":{"summary":[{"kind":"text","text":"The error that was thrown."}]},"type":{"type":"reference","target":{"packageName":"typescript","packagePath":"lib/lib.es5.d.ts","qualifiedName":"Error"},"name":"Error","package":"typescript"}},{"name":"retry","variant":"declaration","kind":1024,"comment":{"summary":[{"kind":"text","text":"A function that will re-render the route component by clearing the "},{"kind":"code","text":"`error`"},{"kind":"text","text":" state."}]},"type":{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"signatures":[{"name":"__type","variant":"signature","kind":4096,"type":{"type":"reference","target":{"packageName":"typescript","packagePath":"lib/lib.es5.d.ts","qualifiedName":"Promise"},"typeArguments":[{"type":"intrinsic","name":"void"}],"name":"Promise","package":"typescript"}}]}}}]},{"name":"ExperimentalStack","variant":"declaration","kind":32,"flags":{"isConst":true},"comment":{"summary":[{"kind":"text","text":"Renders the new "},{"kind":"code","text":"`react-native-screens/experimental`"},{"kind":"text","text":" native stack.\n\nSibling to "},{"kind":"code","text":"`Stack`"},{"kind":"text","text":". Native-only — on web it falls back to the standard "},{"kind":"code","text":"`Stack`"},{"kind":"text","text":".\nOpt-in per navigator: replace "},{"kind":"code","text":"``"},{"kind":"text","text":" with "},{"kind":"code","text":"``"},{"kind":"text","text":" in the\nspecific layout you want to migrate."}],"modifierTags":["@experimental"]},"type":{"type":"intersection","types":[{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"signatures":[{"name":"__type","variant":"signature","kind":4096,"parameters":[{"name":"props","variant":"param","kind":32768,"type":{"type":"intersection","types":[{"type":"reference","target":{"packageName":"typescript","packagePath":"lib/lib.es5.d.ts","qualifiedName":"Omit"},"typeArguments":[{"type":"intersection","types":[{"type":"reference","target":{"packageName":"typescript","packagePath":"lib/lib.es5.d.ts","qualifiedName":"Omit"},"typeArguments":[{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/layouts/experimental-stack/types.ts","qualifiedName":"ExperimentalStackNavigatorProps"},"name":"ExperimentalStackNavigatorProps","package":"expo-router"},{"type":"union","types":[{"type":"literal","value":"children"},{"type":"literal","value":"id"},{"type":"literal","value":"initialRouteName"},{"type":"literal","value":"layout"},{"type":"literal","value":"screenListeners"},{"type":"literal","value":"screenOptions"},{"type":"literal","value":"screenLayout"},{"type":"literal","value":"UNSTABLE_router"},{"type":"literal","value":"UNSTABLE_routeNamesChangeBehavior"}]}],"name":"Omit","package":"typescript"},{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/react-navigation/routers/types.tsx","qualifiedName":"DefaultRouterOptions"},"typeArguments":[{"type":"intrinsic","name":"string"}],"name":"DefaultRouterOptions","package":"expo-router"},{"type":"unknown","name":"{ children: ReactNode; layout?: ((props: { state: StackNavigationState; navigation: NavigationHelpers; descriptors: Record<...>; children: ReactNode; }) => ReactElement<...>) | undefined; ... 4 more ...; UNSTABLE_routeNamesChangeBehavior?: \"firstMatch\" | ... 1 more ... | undefined; ..."}]},{"type":"literal","value":"children"}],"name":"Omit","package":"typescript"},{"type":"reference","target":{"packageName":"typescript","packagePath":"lib/lib.es5.d.ts","qualifiedName":"Partial"},"typeArguments":[{"type":"reference","target":{"packageName":"typescript","packagePath":"lib/lib.es5.d.ts","qualifiedName":"Pick"},"typeArguments":[{"type":"intersection","types":[{"type":"reference","target":{"packageName":"typescript","packagePath":"lib/lib.es5.d.ts","qualifiedName":"Omit"},"typeArguments":[{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/layouts/experimental-stack/types.ts","qualifiedName":"ExperimentalStackNavigatorProps"},"name":"ExperimentalStackNavigatorProps","package":"expo-router"},{"type":"union","types":[{"type":"literal","value":"children"},{"type":"literal","value":"id"},{"type":"literal","value":"initialRouteName"},{"type":"literal","value":"layout"},{"type":"literal","value":"screenListeners"},{"type":"literal","value":"screenOptions"},{"type":"literal","value":"screenLayout"},{"type":"literal","value":"UNSTABLE_router"},{"type":"literal","value":"UNSTABLE_routeNamesChangeBehavior"}]}],"name":"Omit","package":"typescript"},{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/react-navigation/routers/types.tsx","qualifiedName":"DefaultRouterOptions"},"typeArguments":[{"type":"intrinsic","name":"string"}],"name":"DefaultRouterOptions","package":"expo-router"},{"type":"unknown","name":"{ children: ReactNode; layout?: ((props: { state: StackNavigationState; navigation: NavigationHelpers; descriptors: Record<...>; children: ReactNode; }) => ReactElement<...>) | undefined; ... 4 more ...; UNSTABLE_routeNamesChangeBehavior?: \"firstMatch\" | ... 1 more ... | undefined; ..."}]},{"type":"literal","value":"children"}],"name":"Pick","package":"typescript"}],"name":"Partial","package":"typescript"},{"type":"reference","target":{"packageName":"@types/react","packagePath":"index.d.ts","qualifiedName":"React.RefAttributes"},"typeArguments":[{"type":"intrinsic","name":"unknown"}],"name":"RefAttributes","package":"@types/react","qualifiedName":"React.RefAttributes"}]}}],"type":{"type":"reference","target":{"packageName":"@types/react","packagePath":"jsx-runtime.d.ts","qualifiedName":"JSX.Element"},"name":"Element","package":"@types/react","qualifiedName":"JSX.Element"}}]}},{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"children":[{"name":"Protected","variant":"declaration","kind":1024,"type":{"type":"reference","target":{"packageName":"@types/react","packagePath":"index.d.ts","qualifiedName":"React.FunctionComponent"},"typeArguments":[{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/views/Protected.tsx","qualifiedName":"ProtectedProps"},"name":"ProtectedProps","package":"expo-router"}],"name":"FunctionComponent","package":"@types/react","qualifiedName":"React.FunctionComponent"}},{"name":"Screen","variant":"declaration","kind":1024,"type":{"type":"intersection","types":[{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"signatures":[{"name":"__type","variant":"signature","kind":4096,"parameters":[{"name":"__namedParameters","variant":"param","kind":32768,"type":{"type":"reference","target":{"packageName":"expo-router","packagePath":"src/layouts/stack-utils/StackScreen.tsx","qualifiedName":"StackScreenProps"},"name":"StackScreenProps","package":"expo-router"}}],"type":{"type":"reference","target":{"packageName":"@types/react","packagePath":"jsx-runtime.d.ts","qualifiedName":"JSX.Element"},"name":"Element","package":"@types/react","qualifiedName":"JSX.Element"}}]}},{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"children":[{"name":"BackButton","variant":"declaration","kind":1024,"type":{"type":"reflection","declaration":{"name":"__type","variant":"declaration","kind":65536,"signatures":[{"name":"__type","variant":"signature","kind":4096,"comment":{"summary":[{"kind":"text","text":"Component to configure the back button.\n\nCan be used inside Stack.Screen in a layout or directly inside a screen component."}],"blockTags":[{"tag":"@example","content":[{"kind":"code","text":"```tsx\nimport { Stack } from 'expo-router';\n\nexport default function Layout() {\n return (\n \n \n Back\n \n \n );\n}\n```"}]},{"tag":"@example","content":[{"kind":"code","text":"```tsx\nimport { Stack } from 'expo-router';\n\nexport default function Page() {\n return (\n <>\n