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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/observe-tester/app/(tabs)/debug/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ScrollView, StyleSheet } from 'react-native';
import { Button } from '@/components/Button';
import { CrashReportsSection } from '@/components/CrashReportsSection';
import { Divider } from '@/components/Divider';
import { GlobalAttributesSection } from '@/components/GlobalAttributesSection';
import { JSAnimation } from '@/components/JSAnimation';
import { LogEventsSection } from '@/components/LogEventsSection';
import { useTheme } from '@/utils/theme';
Expand All @@ -27,9 +28,11 @@ export default function Debug() {
style={{ backgroundColor: theme.background.screen }}
contentContainerStyle={styles.container}>
<LogEventsSection />
{typeof AppMetrics.logEvent === 'function' ? <Divider /> : null}
<Divider />
<CrashReportsSection />
{typeof AppMetrics.triggerCrash === 'function' ? <Divider /> : null}
<GlobalAttributesSection />
<Divider />
<Button
title={showAnimation ? 'Hide JS Animation' : 'Show JS Animation'}
onPress={() => setShowAnimation(!showAnimation)}
Expand Down
82 changes: 82 additions & 0 deletions apps/observe-tester/components/GlobalAttributesSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import ExpoObserve, { type ObserveAttributes } from 'expo-observe';
import { StyleSheet, Text } from 'react-native';

import { Button } from '@/components/Button';
import { useTheme } from '@/utils/theme';

type GlobalAttributesPreset = {
title: string;
description: string;
attributes: ObserveAttributes;
};

const PRESETS: GlobalAttributesPreset[] = [
{
title: 'Pro tier · variant B',
description: 'subscription_tier, experiment_variant',
attributes: {
subscription_tier: 'pro',
experiment_variant: 'B',
},
},
{
title: 'Trial tier · variant A',
description: 'subscription_tier, experiment_variant, signup_flow',
attributes: {
subscription_tier: 'trial',
experiment_variant: 'A',
signup_flow: 'magic_link',
},
},
{
title: 'Collision case',
description: 'sets `screen` globally — per-event `screen` should still win',
attributes: {
screen: 'global_default',
build_channel: 'preview',
},
},
];

export function GlobalAttributesSection() {
const theme = useTheme();

return (
<>
<Text style={[styles.sectionTitle, { color: theme.text.default }]}>Global attributes</Text>
<Text style={[styles.sectionHint, { color: theme.text.secondary }]}>
Attributes set here are merged into every subsequent metric's params and log record's
attributes. Per-record keys take precedence on collision. Pick a preset, then fire a log
event from the section below (or wait for the next metric flush) and inspect the dispatched
record.
</Text>
{PRESETS.map(({ title, description, attributes }) => (
<Button
key={title}
title={title}
description={description}
onPress={() => ExpoObserve.setGlobalAttributes(attributes)}
theme="secondary"
/>
))}
<Button
title="Clear global attributes"
description="Resets the store to empty"
onPress={() => ExpoObserve.setGlobalAttributes(null)}
theme="secondary"
/>
</>
);
}

const styles = StyleSheet.create({
sectionTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
sectionHint: {
fontSize: 13,
marginBottom: 16,
},
});
4 changes: 0 additions & 4 deletions apps/observe-tester/components/LogEventsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,6 @@ export function LogEventsSection() {
const theme = useTheme();
const paletteScheme = usePaletteScheme();

if (typeof AppMetrics.logEvent !== 'function') {
return null;
}

return (
<>
<Text style={[styles.sectionTitle, { color: theme.text.default }]}>Log events</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ class AppMetricsModule : Module(), UpdatesStateChangeListener {
// already persisted eagerly in `OnCreate`, so this is purely about
// ordering startup-metric writes ahead of caller-driven log events.
saveStartupMetricsIfNotSaved()
// Globals merge happens inside `sessionManager.addLogs` so every
// persistence path picks them up.
sessionManager.addLogs(
listOf(
LogRecord(
Expand All @@ -107,6 +109,10 @@ class AppMetricsModule : Module(), UpdatesStateChangeListener {
}
}

Function("setGlobalAttributes") { attributes: Map<String, Any?>? ->
GlobalAttributes.set(attributes)
}

OnCreate {
sessionManager = SessionManager(context)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package expo.modules.appmetrics

import expo.modules.appmetrics.logevents.sanitizeLogEventAttributes
import java.util.concurrent.atomic.AtomicReference

/**
* Holds caller-provided global attributes that are merged into every subsequent
* metric's `params` and log record's `attributes`. Values live for the
* lifetime of the SDK instance and are cleared on app restart — persistent
* storage is intentionally out of scope.
*
* `setGlobalAttributes` is invoked from the JS thread while `mergeWith` runs
* on the coroutine that persists the record. An `AtomicReference` to an
* immutable map keeps reads lock-free and prevents a concurrent set from
* mutating a snapshot mid-merge.
*/
object GlobalAttributes {
private val current = AtomicReference<Map<String, Any?>>(emptyMap())

/**
* Replaces the current set of global attributes. The input is sanitized using
* the same rules as per-event attributes (`expo.*` reserved, empty keys
* rejected, per-record cap).
*
* Passing `null` or an empty map clears the store.
*/
fun set(attributes: Map<String, Any?>?) {
val sanitized = sanitizeLogEventAttributes(attributes)
current.set(sanitized.attributes ?: emptyMap())
}

/**
* Returns the current global attributes merged with the given per-event
* attributes. Per-event keys win on collision so callers can override a
* global value for a single record without mutating the store.
*/
fun mergeWith(eventAttributes: Map<String, Any?>?): Map<String, Any?>? {
val snapshot = current.get()
if (snapshot.isEmpty()) {
return eventAttributes
}
if (eventAttributes.isNullOrEmpty()) {
return snapshot
}
return snapshot + eventAttributes
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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.utils.JsonAny
import expo.modules.appmetrics.utils.TimeUtils
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
Expand Down Expand Up @@ -94,7 +96,12 @@ class SessionManager(
metrics: List<Metric>,
sessionId: String
) {
val metricsWithSession = metrics.map { it.copy(sessionId = sessionId) }
val metricsWithSession = metrics.map { metric ->
metric.copy(
sessionId = sessionId,
params = mergeGlobalAttributesIntoJsonString(metric.params)
)
}
database.metricDao().insertAll(metricsWithSession)
val metricIds = metricsWithSession.map { it.metricId }
metricsInsertListeners.forEach { listener ->
Expand Down Expand Up @@ -131,7 +138,12 @@ class SessionManager(
logs: List<LogRecord>,
sessionId: String
) {
val logsWithSession = logs.map { it.copy(sessionId = sessionId) }
val logsWithSession = logs.map { log ->
log.copy(
sessionId = sessionId,
attributes = mergeGlobalAttributesIntoJsonString(log.attributes)
)
}
database.logDao().insertAll(logsWithSession)
val logIds = logsWithSession.map { it.logId }
logsInsertListeners.forEach { listener ->
Expand Down Expand Up @@ -199,4 +211,20 @@ class SessionManager(
)
}
}

/**
* Decodes a JSON-encoded `params` / `attributes` column, folds the current
* [GlobalAttributes] snapshot into it, and re-encodes. Returns the original
* string when there's nothing to merge in — empty globals, or a non-null
* input that couldn't be parsed as a JSON object (we preserve whatever the
* caller wrote rather than silently replacing it).
*/
private fun mergeGlobalAttributesIntoJsonString(json: String?): String? {
val existing = json?.let { JsonAny.decodeJsonStringToMap(it) }
if (json != null && existing == null) {
return json
}
val merged = GlobalAttributes.mergeWith(existing) ?: return json
return JsonAny.encodeMapToJsonString(merged)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,16 @@ object JsonAny {
JsonObject.serializer(),
JsonObject(map.mapValues { (_, v) -> toElement(v) })
)

/**
* Inverse of [encodeMapToJsonString]. Returns `null` if the JSON does not
* parse or does not decode to an object — callers can treat that as an
* absent map. The inner values are decoded by [fromElement] so the result
* round-trips with [toElement].
*/
fun decodeJsonStringToMap(json: String): Map<String, Any?>? {
val element = runCatching { Json.parseToJsonElement(json) }.getOrNull() ?: return null
val obj = element as? JsonObject ?: return null
return obj.mapValues { (_, v) -> fromElement(v) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package expo.modules.appmetrics

import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
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

@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28])
class GlobalAttributesTest {
@Before
fun resetBefore() {
GlobalAttributes.set(null)
}

@After
fun resetAfter() {
GlobalAttributes.set(null)
}

@Test
fun `merged returns event attributes when store is empty`() {
val merged = GlobalAttributes.mergeWith(mapOf("userId" to "u_42"))
assertNotNull(merged)
assertEquals(1, merged!!.size)
assertEquals("u_42", merged["userId"])
}

@Test
fun `merged returns null when both store and event attributes are empty`() {
assertNull(GlobalAttributes.mergeWith(null))
}

@Test
fun `set populates the store and merged returns globals`() {
GlobalAttributes.set(mapOf("tier" to "pro", "variant" to "B"))
val merged = GlobalAttributes.mergeWith(null)
assertNotNull(merged)
assertEquals(2, merged!!.size)
assertEquals("pro", merged["tier"])
assertEquals("B", merged["variant"])
}

@Test
fun `merged combines globals and per-event attributes`() {
GlobalAttributes.set(mapOf("tier" to "pro"))
val merged = GlobalAttributes.mergeWith(mapOf("screen" to "home"))
assertNotNull(merged)
assertEquals(2, merged!!.size)
assertEquals("pro", merged["tier"])
assertEquals("home", merged["screen"])
}

@Test
fun `per-event attributes win on key collision`() {
GlobalAttributes.set(mapOf("tier" to "pro"))
val merged = GlobalAttributes.mergeWith(mapOf("tier" to "trial"))
assertNotNull(merged)
assertEquals(1, merged!!.size)
assertEquals("trial", merged["tier"])
}

@Test
fun `set replaces the previous store (not merges)`() {
GlobalAttributes.set(mapOf("tier" to "pro", "variant" to "B"))
GlobalAttributes.set(mapOf("tier" to "trial"))
val merged = GlobalAttributes.mergeWith(null)
assertNotNull(merged)
assertEquals(1, merged!!.size)
assertEquals("trial", merged["tier"])
assertNull(merged["variant"])
}

@Test
fun `set with empty map clears the store`() {
GlobalAttributes.set(mapOf("tier" to "pro"))
GlobalAttributes.set(emptyMap())
assertNull(GlobalAttributes.mergeWith(null))
}

@Test
fun `set with null clears the store`() {
GlobalAttributes.set(mapOf("tier" to "pro"))
GlobalAttributes.set(null)
assertNull(GlobalAttributes.mergeWith(null))
}

@Test
fun `set sanitizes reserved keys before storing`() {
GlobalAttributes.set(
mapOf(
"expo.app.name" to "spoofed",
"session.id" to "spoofed",
"tier" to "pro"
)
)
val merged = GlobalAttributes.mergeWith(null)
assertNotNull(merged)
assertEquals(1, merged!!.size)
assertEquals("pro", merged["tier"])
}

}
Loading
Loading