@hahnmedia/analytics-sdk
v1.1.7
Published
Analytics SDK for iOS, Android, and Expo apps — events, lifecycle, and user identity for your analytics dashboard
Downloads
1,847
Maintainers
Readme
@hahnmedia/analytics-sdk
Analytics for iOS, Android, and Expo apps.
Connect any mobile app to your analytics backend and dashboard. Send events from the app; view them in the project dashboard you already use for analytics. This SDK handles batching, offline storage, app lifecycle events, linking activity to signed-in users, and funnel completion without duplicate events after a flow is finished.
Features
- Custom events — track anything with a name and optional properties
- Works offline — events are saved on the device and sent when the network is back
- App lifecycle — first install, app opened, app backgrounded (on by default with the Expo helper)
- Logged-in users — attach your auth user id after sign-in
- Funnels — per-step tracking with optional step properties, plus a single
funnel_completedevent; optional completion guard stops repeat funnel events on the same device aftercomplete() - Ready-made helpers — buttons, subscriptions, screen views
Requirements
- React 18+
- React Native 0.74+
- Expo:
expo-constantsandexpo-device(usually already in the project) @react-native-async-storage/async-storage(offline queue, stable device id, funnel completion guard)
Install
npm install @hahnmedia/analytics-sdk @react-native-async-storage/async-storageYou need two values from your analytics project (dashboard or admin):
- Write key — authenticates the app when sending events
- Server URL — base URL of your analytics API (no path suffix)
Example .env for Expo:
EXPO_PUBLIC_ANALYTICS_KEY=pk_live_your_key_here
EXPO_PUBLIC_ANALYTICS_URL=https://analytics.yourdomain.comUse the base URL only — do not add /api/v1/ingest. The SDK adds that path for you.
Quick start
Three steps: create the client, wrap your app, connect auth.
1. Create the client
lib/analytics.ts:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createExpoAnalytics } from "@hahnmedia/analytics-sdk";
export const analytics = createExpoAnalytics(
{
writeKey: process.env.EXPO_PUBLIC_ANALYTICS_KEY!,
baseUrl: process.env.EXPO_PUBLIC_ANALYTICS_URL!,
},
AsyncStorage,
);You do not need to call init() yourself. The first track or identify starts the SDK automatically.
2. Wrap your root layout
app/_layout.tsx:
import { AnalyticsProvider } from "@hahnmedia/analytics-sdk";
import { analytics } from "@/lib/analytics";
export default function RootLayout() {
return (
<AnalyticsProvider client={analytics} appSlug="my_app">
{/* your existing app tree */}
</AnalyticsProvider>
);
}appSlug is optional. If you set it, the SDK sends one sdk_initialized event on startup (handy to confirm the integration).
3. Attach your auth user id
When someone signs in, tell the SDK who they are. Works with Supabase, Clerk, Firebase, or any auth that exposes a stable user id.
Inside your auth provider:
import { useAnalyticsIdentity } from "@hahnmedia/analytics-sdk";
import { analytics } from "@/lib/analytics";
export function AuthProvider({ children }) {
const userId = session?.user?.id; // your auth source
useAnalyticsIdentity(analytics, userId);
return <>{children}</>;
}On sign-out, the hook clears the user id. Events before and after login share the same device id, so you see one person in the dashboard—not two entries for the same install.
4. Track something
import { analytics } from "@/lib/analytics";
analytics.track("recipe_viewed", { recipe_id: "abc123" });That is enough for a working integration in any app.
Optional: skip tracking when not configured
Use empty env vars in local dev without a backend. The client exposes isConfigured so you can guard calls:
export const analytics = createExpoAnalytics(
{
writeKey: process.env.EXPO_PUBLIC_ANALYTICS_KEY ?? "",
baseUrl: process.env.EXPO_PUBLIC_ANALYTICS_URL ?? "",
},
AsyncStorage,
);
export const isAnalyticsConfigured = analytics.isConfigured;if (!isAnalyticsConfigured) return;
analytics.track("recipe_viewed", { recipe_id: "abc123" });track, identify, funnel, and subscription methods no-op when the write key or URL is missing. AnalyticsProvider and useAnalyticsIdentity also skip work when not configured.
Automatic events (Expo helper)
With createExpoAnalytics, the SDK can send:
| Event | When |
|--------|------|
| app_installed | First launch on this device |
| app_opened | App becomes active |
| app_backgrounded | App goes to background (includes session length) |
Device context (app version, model, screen size, etc.) is included when available.
Built-in events
| Event | How it is sent |
|--------|----------------|
| app_installed | Lifecycle (first launch) |
| app_opened | Lifecycle |
| app_backgrounded | Lifecycle (session_duration_ms in properties) |
| screen_viewed | useAnalyticsScreen(client, name) |
| button_clicked | trackClick(name, props) |
| funnel_step | funnel.step(funnelId, stepName, order, status) |
| funnel_completed | funnel.complete(funnelId, props?) |
| subscription_success | subscription.success(plan) |
| subscription_failed | subscription.failed(reason) |
| subscription_restored | subscription.restored() |
| sdk_initialized | AnalyticsProvider when appSlug is set |
Custom events use analytics.track("your_event", { ... }).
Common tasks
Buttons
analytics.trackClick("upgrade_button", {
screen_name: "Paywall",
plan: "yearly",
});Recorded as button_clicked with button_name set to the first argument, plus any extra properties:
| Property | Source |
|----------|--------|
| button_name | First argument to trackClick |
| (custom) | Optional second argument |
Screens
import { useAnalyticsScreen } from "@hahnmedia/analytics-sdk";
export function HomeScreen() {
useAnalyticsScreen(analytics, "Home");
// ...
}Funnel tracking
Track multi-step flows with steps (per screen or action) and completion (whole funnel finished).
Steps
analytics.funnel.step(
"onboarding",
"welcome_viewed",
1,
"started",
);
analytics.funnel.step(
"onboarding",
"profile_completed",
2,
"completed",
);| Argument | Description |
|----------|-------------|
| funnelId | Stable id for the flow (e.g. "onboarding", "recipe_flow") |
| stepName | Human-readable step label |
| stepOrder | Numeric order (1, 2, 3…) |
| status | "started", "completed", or "dropped" |
| properties | (optional) Extra fields merged into the event (e.g. selections, plan id) |
Sends funnel_step with properties:
funnel_idstep_namestep_orderstatus- plus any optional properties you pass (e.g.
selected_goal_ids,trial_experience_id)
analytics.funnel.step("onboarding", "goals", 3, "completed", {
selected_goal_ids: "eat-healthier,plan-meals",
selected_goal_count: 2,
});Use the same funnelId and consistent stepOrder values across the flow.
Whole-funnel completion
When the user finishes the entire funnel (not just one step), call:
analytics.funnel.complete("onboarding");Optional custom properties (business context only — do not send duration from the client):
analytics.funnel.complete("onboarding", {
plan: "pro_yearly",
experiment: "pricing_v2",
});Sends funnel_completed with at least:
funnel_id(required, same string as instep())- plus any optional properties you pass
The dashboard uses funnel_completed for funnel completion counts and rates. You do not need to infer completion from every step in SQL. Time-to-complete is computed on the server from the first funnel_step to funnel_completed in the same session.
Full example (onboarding)
analytics.funnel.step("onboarding", "welcome_viewed", 1, "started");
analytics.funnel.step("onboarding", "welcome_viewed", 1, "completed");
analytics.funnel.step("onboarding", "profile_completed", 2, "completed");
analytics.funnel.step("onboarding", "paywall_converted", 3, "completed");
analytics.funnel.complete("onboarding");Example (one-time product funnel)
const FUNNEL = "recipe_flow";
analytics.funnel.step(FUNNEL, "create_recipe", 1, "completed");
analytics.funnel.step(FUNNEL, "create_meal_plan", 2, "completed");
analytics.funnel.step(FUNNEL, "complete_grocery_list", 3, "completed");
analytics.funnel.complete(FUNNEL);
// User returns to "create recipe" later — step/complete calls are no-ops
// if the completion guard is active (see below).Completion guard (once per funnel, per device)
When you use createExpoAnalytics (or createAnalytics with storage), the SDK remembers which funnels this install has already finished. After complete():
- Further
funnel.step()andfunnel.complete()calls for thatfunnelIdare ignored (nothing queued or sent). - State is stored in AsyncStorage under
@hahnmedia/analytics/funnel_completed.
This avoids duplicate funnel events when users revisit screens after finishing a one-time flow.
if (analytics.funnel.isComplete("recipe_flow")) {
// skip onboarding UI, etc.
}
// Development only — allow tracking the funnel again on this device
analytics.funnel.reset("recipe_flow");resetLocalState() clears completion flags along with the queue and device id. Logout (resetUser()) does not clear completions.
Without storage, the guard is disabled (all funnel calls are tracked).
The guard is per device / install, not synced across phones. Clearing app data or calling resetLocalState() allows the funnel to be tracked again locally.
Organize steps in your app (recommended)
Keep funnel ids and step names in one constants file, then add thin helpers so screens stay readable:
constants/analytics.ts:
export const ONBOARDING_FUNNEL_ID = "onboarding";
export const TRIAL_FUNNEL_ID = "trial";
export const SUBSCRIPTION_FUNNEL_ID = "subscription";
export const ONBOARDING_FUNNEL_STEP = {
welcome: { name: "welcome_viewed", order: 1 },
goals: { name: "goals", order: 2 },
signIn: { name: "sign_in", order: 3 },
} as const;
export const TRIAL_FUNNEL_STEP = {
freeTrial: { name: "free_trial", order: 1 },
planSelected: { name: "plan_selected", order: 2 },
} as const;lib/onboarding-analytics.ts:
import { useEffect } from "react";
import { ONBOARDING_FUNNEL_ID, ONBOARDING_FUNNEL_STEP } from "@/constants/analytics";
import { analytics, isAnalyticsConfigured } from "@/lib/analytics";
type StepKey = keyof typeof ONBOARDING_FUNNEL_STEP;
export function trackOnboardingStep(
stepName: string,
stepOrder: number,
status: "started" | "completed" | "dropped" = "completed",
properties?: Record<string, unknown>,
) {
if (!isAnalyticsConfigured) return;
analytics.funnel.step(ONBOARDING_FUNNEL_ID, stepName, stepOrder, status, properties);
}
export function trackOnboardingStepByKey(
step: StepKey,
status: "started" | "completed" | "dropped" = "completed",
properties?: Record<string, unknown>,
) {
const { name, order } = ONBOARDING_FUNNEL_STEP[step];
trackOnboardingStep(name, order, status, properties);
}
export function buildGoalsStepProperties(selectedGoalIds: string[]) {
return {
selected_goal_ids: selectedGoalIds.join(","),
selected_goal_count: selectedGoalIds.length,
};
}
export function completeOnboardingFunnel(properties?: Record<string, unknown>) {
if (!isAnalyticsConfigured) return;
analytics.funnel.complete(ONBOARDING_FUNNEL_ID, properties);
}
export function useOnboardingFunnelStep(
step: StepKey,
status: "started" | "completed" | "dropped" = "completed",
properties?: Record<string, unknown>,
) {
useEffect(() => {
trackOnboardingStepByKey(step, status, properties);
}, [step, status, properties]);
}In a screen:
useOnboardingFunnelStep("goals", "completed", buildGoalsStepProperties(selectedIds));At the end of the flow: completeOnboardingFunnel().
Multiple funnels in one app
Use a separate funnelId per product flow (onboarding, trial, subscription, etc.). Each id has its own completion guard.
| Funnel id | Typical use |
|-----------|-------------|
| onboarding | First-run education and sign-in |
| trial | Free-trial / plan selection screens |
| subscription | Paywall checkout (often repeatable — call funnel.reset when a new purchase starts) |
Pair funnel steps with subscription.success / subscription.failed when checkout finishes:
analytics.funnel.reset("subscription");
analytics.funnel.step("subscription", "purchase_started", 1, "started");
// On success:
analytics.funnel.step("subscription", "checkout_success", 2, "completed");
analytics.subscription.success({ product_id: "pro_yearly" });
analytics.funnel.complete("subscription", { source: "in_app_paywall", outcome: "paid" });Guidelines
- Call
complete()once when your product definition of “funnel finished” is met (may include rules steps alone cannot express). - Keep calling
step()for per-step conversion charts; usecomplete()for overall completion KPIs. - If the user drops off, use
status: "dropped"on the relevant step — you do not need a completion event. - For repeatable funnels (user can finish many times), call
funnel.reset(funnelId)when starting a new attempt, or use a newfunnelIdper product version.
Subscriptions
analytics.subscription.success({
product_id: "pro_monthly",
price: 9.99,
currency: "USD",
});
analytics.subscription.failed("payment_cancelled");
analytics.subscription.restored();Send events immediately
Events usually go out in batches. To send the queue right away:
await analytics.flush();Reset on-device data (development)
Clears the device id, offline queue, funnel completion flags, and first-install flag on the device. Use when testing so old runs do not pollute new ones. Does not delete data on your server.
await analytics.resetLocalState();API overview
| Method | Purpose |
|--------|---------|
| analytics.track(name, properties?) | Custom event |
| analytics.identify(userId) | Logged-in user id |
| analytics.resetUser() | Clear user id on logout |
| analytics.trackClick(name, properties?) | Button click |
| analytics.funnel.step(funnelId, stepName, stepOrder, status, properties?) | Funnel step → funnel_step |
| analytics.funnel.complete(funnelId, properties?) | Funnel finished → funnel_completed |
| analytics.funnel.isComplete(funnelId) | true if this device already completed the funnel |
| analytics.funnel.reset(funnelId) | Clear local completion (dev / repeatable funnels) |
| analytics.subscription.success / .failed / .restored | Subscription events |
| analytics.flush() | Send queue now |
| analytics.ready() | Wait until startup finished (optional) |
| analytics.isConfigured | true when key and URL are set |
| analytics.resetLocalState() | Clear local analytics storage (dev) |
React helpers:
| Export | Purpose |
|--------|---------|
| AnalyticsProvider | Wrap the app root |
| useAnalyticsIdentity(client, userId) | Keep user id in sync with auth (no-op if client missing or not configured) |
| useAnalyticsScreen(client, screenName) | screen_viewed with screen_name (skips duplicate name on re-render) |
Other exports:
| Export | Purpose |
|--------|---------|
| createExpoAnalytics | Expo client with lifecycle + device context |
| createAnalytics | Manual client setup |
| createExpoContextProvider | Device context for createAnalytics |
| STORAGE_KEYS | AsyncStorage key constants |
| AnalyticsEventSchema, IngestPayloadSchema | Zod schemas (advanced / server parity) |
Advanced: manual client setup
If you are not using createExpoAnalytics, configure the client directly:
import { createAnalytics, createExpoContextProvider } from "@hahnmedia/analytics-sdk";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AppState } from "react-native";
export const analytics = createAnalytics(
{
writeKey: "pk_live_...",
endpoint: "https://analytics.yourdomain.com/api/v1/ingest",
flushAt: 20,
flushInterval: 10_000,
autoTrackLifecycle: true,
platform: "ios",
},
{
storage: AsyncStorage,
appState: AppState,
contextProvider: createExpoContextProvider(),
},
);Lifecycle events need both storage and appState. Pass storage for the funnel completion guard as well.
Local storage keys
The SDK reads and writes these AsyncStorage keys (also exported as STORAGE_KEYS):
| Key | Purpose |
|-----|---------|
| @hahnmedia/analytics/queue | Offline event batch |
| @hahnmedia/analytics/anonymous_id | Stable device id |
| @hahnmedia/analytics/installed | First-install flag for app_installed |
| @hahnmedia/analytics/funnel_completed | JSON map of completed funnel_id → timestamp |
import { STORAGE_KEYS } from "@hahnmedia/analytics-sdk";Options
| Option | Default | Description |
|--------|---------|-------------|
| flushAt | 20 | Send after this many queued events |
| flushInterval | 10000 | Also send every N ms |
| autoTrackLifecycle | true | Install / open / background events |
| platform | "ios" | "ios", "android", or "web" |
Before you ship
- [ ] Write key and server URL in environment variables (not in source code)
- [ ] HTTPS for the server URL in production
- [ ] Real devices can reach the server (avoid
localhoston a phone unless you use your machine’s LAN IP) - [ ] User id is set after sign-in (
useAnalyticsIdentityoridentify) - [ ] Event names stay stable across app versions
- [ ] Each one-time funnel calls
funnel.complete(funnelId)at the real end of the flow - [ ] Repeatable funnels call
funnel.reset(funnelId)when starting a new attempt (or use a newfunnelId)
Troubleshooting
Events do not appear in the dashboard
- Check env vars for the write key and server URL
- Restart the bundler after env changes (
npx expo start -c) - Confirm the analytics API is running and reachable from the device
- Try
await analytics.flush()and check API logs
No app_opened or app_installed
Use createExpoAnalytics with AsyncStorage, or pass storage and appState to createAnalytics.
Same person listed twice
Often leftover test data, or the database was cleared but not on-device storage. Run await analytics.resetLocalState(), clear server events, then test again. With AsyncStorage, the device id stays stable per install (SDK 1.0.2+).
Duplicate app_opened in dev
Common when the root component remounts or you reload often. Usually not an issue in release builds.
Funnel steps not showing after testing
If you already called funnel.complete("my_funnel") on this device, the SDK skips further funnel events for that id.
- Use
analytics.funnel.reset("my_funnel")to clear one funnel, or - Run
await analytics.resetLocalState()to reset all local analytics data, or - Check
analytics.funnel.isComplete("my_funnel")before debugging ingest.
Funnel completion rate is zero in the dashboard
The dashboard counts funnel_completed events. Ensure the app calls analytics.funnel.complete(funnelId) at the end of the flow, not only funnel.step(..., "completed") on the last step.
Changelog
1.1.7
funnel.step()accepts optional step properties (5th argument), merged intofunnel_stepevents- README: multiple funnels per app, subscription + funnel pairing, step property helpers
1.1.6
- README: optional
isConfiguredguards, funnel constants pattern,button_nameon clicks - Docs aligned with funnel completion guard and app-level helper pattern
1.1.5
- Funnel completion guard (
funnel.isComplete,funnel.reset, AsyncStorage persistence) funnel_completedonce-per-funnel per device aftercomplete()
1.1.4
- React exports:
AnalyticsProvider,useAnalyticsIdentity,useAnalyticsScreen - Expo helper and identity improvements
Earlier versions: lifecycle events, offline queue, subscription helpers.
Developing this package
From the tracking directory:
npm run build
npm run sync-schema
npm packPublish:
npm run sync-schema && npm run build && npm publish --access publicMaintainer notes: PUBLISH.md
License
MIT
