npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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_completed event; optional completion guard stops repeat funnel events on the same device after complete()
  • Ready-made helpers — buttons, subscriptions, screen views

Requirements

  • React 18+
  • React Native 0.74+
  • Expo: expo-constants and expo-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-storage

You need two values from your analytics project (dashboard or admin):

  1. Write key — authenticates the app when sending events
  2. 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.com

Use 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_id
  • step_name
  • step_order
  • status
  • 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 in step())
  • 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() and funnel.complete() calls for that funnelId are 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; use complete() 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 new funnelId per 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 localhost on a phone unless you use your machine’s LAN IP)
  • [ ] User id is set after sign-in (useAnalyticsIdentity or identify)
  • [ ] 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 new funnelId)

Troubleshooting

Events do not appear in the dashboard

  1. Check env vars for the write key and server URL
  2. Restart the bundler after env changes (npx expo start -c)
  3. Confirm the analytics API is running and reachable from the device
  4. 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.

  1. Use analytics.funnel.reset("my_funnel") to clear one funnel, or
  2. Run await analytics.resetLocalState() to reset all local analytics data, or
  3. 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 into funnel_step events
  • README: multiple funnels per app, subscription + funnel pairing, step property helpers

1.1.6

  • README: optional isConfigured guards, funnel constants pattern, button_name on 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_completed once-per-funnel per device after complete()

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 pack

Publish:

npm run sync-schema && npm run build && npm publish --access public

Maintainer notes: PUBLISH.md


License

MIT