Analytics & Privacy
Segment and Amplitude with consent gates and ATT alignment on Expo SDK 57. Product analytics belongs in the observability program, but it follows privacy rules crash reporters do not.
Search across all documentation pages
Segment and Amplitude with consent gates and ATT alignment on Expo SDK 57. Product analytics belongs in the observability program, but it follows privacy rules crash reporters do not.
npm install @segment/analytics-react-native @segment/sovran-react-native
# or Amplitude directly:
npm install @amplitude/analytics-react-native
npx expo install expo-tracking-transparencyTooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
Quick-reference recipe card - copy-paste ready.
1. Consent state
// src/privacy/consent.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
export type ConsentState = {
analytics: boolean;
crashReporting: boolean; // often separate - may default on for stability
marketing: boolean;
decidedAt: string | null;
};
const KEY = "consent_v1";
const defaultState: ConsentState = {
analytics: false,
crashReporting: true,
marketing: false,
decidedAt: null,
};
export async function getConsent(): Promise<ConsentState> {
const raw = await AsyncStorage.getItem(KEY);
return raw ? JSON.parse(raw) : defaultState;
}
export async function setConsent(patch: Partial<ConsentState>) {
const next = { ...(await getConsent()), ...patch, decidedAt: new Date().toISOString() };
await AsyncStorage.setItem(KEY, JSON.stringify(next));
return next;
}2. ATT prompt (iOS)
// src/privacy/att.ts
import { Platform } from "react-native";
import * as TrackingTransparency from "expo-tracking-transparency";
export async function requestAttIfNeeded() {
if (Platform.OS !== "ios") return "unavailable";
const { status: existing } = await TrackingTransparency.getTrackingPermissionsAsync();
if (existing === "granted" || existing === "denied") return existing;
const { status } = await TrackingTransparency.requestTrackingPermissionsAsync();
return status;
}3. Gated analytics facade
// src/analytics/index.ts
import { getConsent } from "@/privacy/consent";
type Props = Record<string, string | number | boolean | null>;
let segmentClient: { track: (e: string, p?: Props) => void; identify: (id: string, t?: Props) => void; reset: () => void } | null = null;
export async function initAnalytics() {
const consent = await getConsent();
if (!consent.analytics) return;
const { createClient } = await import("@segment/analytics-react-native");
segmentClient = createClient({
writeKey: process.env.EXPO_PUBLIC_SEGMENT_WRITE_KEY!,
trackAppLifecycleEvents: true,
});
}
export async function track(event: string, properties?: Props) {
const consent = await getConsent();
if (!consent.analytics || !segmentClient) return;
segmentClient.track(event, sanitize(properties));
}
export async function identify(userId: string, traits?: Props) {
const consent = await getConsent();
if (!consent.analytics || !segmentClient) return;
segmentClient.identify(userId, sanitize(traits));
}
export async function resetAnalytics() {
segmentClient?.reset();
}
function sanitize(props?: Props): Props | undefined {
if (!props) return props;
const blocked = ["email", "phone", "name", "address"];
return Object.fromEntries(
Object.entries(props).filter(([k]) => !blocked.includes(k.toLowerCase()))
);
}4. Consent UI gate in root layout
// src/privacy/ConsentGate.tsx
import { useEffect, useState } from "react";
import { Button, Text, View } from "react-native";
import { getConsent, setConsent } from "./consent";
import { requestAttIfNeeded } from "./att";
import { initAnalytics } from "@/analytics";
export function ConsentGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
(async () => {
const consent = await getConsent();
setShowBanner(!consent.decidedAt);
if (consent.analytics) await initAnalytics();
setReady(true);
})();
}, []);
if (!ready) return null;
if (showBanner) {
return (
<View style={{ padding: 24, gap: 12 }}>
<Text>We use analytics to improve the app. You can change this in Settings.</Text>
<Button
title="Accept analytics"
onPress={async () => {
await requestAttIfNeeded();
await setConsent({ analytics: true });
await initAnalytics();
setShowBanner(false);
}}
/>
<Button
title="Decline"
onPress={async () => {
await setConsent({ analytics: false });
setShowBanner(false);
}}
/>
</View>
);
}
return <>{children}</>;
}When to reach for this:
Segment → Amplitude with environment-specific sources and logout reset.
// app.config.ts - declare NSUserTrackingUsageDescription for iOS
export default {
expo: {
ios: {
infoPlist: {
NSUserTrackingUsageDescription:
"This identifier helps us measure app performance and improve features.",
},
},
},
};// app/_layout.tsx
import { Stack } from "expo-router";
import { ConsentGate } from "@/privacy/ConsentGate";
export default function RootLayout() {
return (
<ConsentGate>
<Stack />
</ConsentGate>
);
}// src/features/auth/logout.ts
import { resetAnalytics } from "@/analytics";
import * as SecureStore from "expo-secure-store";
export async function logout() {
await SecureStore.deleteItemAsync("refresh_token");
await resetAnalytics();
// clear React state, invalidate queries...
}// Stable event schema - version properties carefully
await track("checkout_completed", {
order_id: order.id,
item_count: order.items.length,
currency: "USD",
value_cents: order.totalCents,
});| Approach | Pros | Cons |
|---|---|---|
| Segment router | One SDK, many destinations, server-side filtering | Extra cost, another vendor |
| Amplitude direct | Rich product analytics UI, experimentation | Each new tool needs another SDK |
| Both | Segment → Amplitude common in enterprise | Two configs to keep in sync |
// Amplitude direct (when Segment is not required)
import { init, track, identify, reset } from "@amplitude/analytics-react-native";
export async function initAmplitude() {
await init(process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY!, undefined, {
trackingOptions: { ipAddress: false },
});
}account_id - not for PIIrelease and otaUpdateId as super-properties for release correlation - OTA Updates BasicsRelated: Observability Basics - do not conflate
track()withcaptureException()
checkout_completed, not completeCheckoutschema_version property if neededuser_id from auth, never emailscreen_view from both router and screen useEffectcold_start_ms) belong here or in Performance Monitoring - pick one ownerdecidedAt is set.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026