From 99b661b0ff57c43f64ae53fb5cbf190c3fba9f9a Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:28:01 -0300 Subject: [PATCH 1/2] feat: add provider-agnostic analytics layer --- AGENTS.md | 3 +- .../analytics-screen-tracker.test.tsx | 81 +++++++++ .../components/providers/analytics.test.tsx | 157 ++++++++++++++++++ .../lib/analytics/console-provider.test.ts | 46 +++++ agent_docs/architecture.md | 2 + src/app/_layout.tsx | 17 +- src/components/analytics-screen-tracker.tsx | 19 +++ src/components/providers/analytics.tsx | 80 +++++++++ src/lib/analytics/console-provider.ts | 24 +++ src/lib/analytics/index.ts | 2 + src/lib/analytics/types.ts | 45 +++++ src/lib/index.tsx | 1 + 12 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 __tests__/components/analytics-screen-tracker.test.tsx create mode 100644 __tests__/components/providers/analytics.test.tsx create mode 100644 __tests__/lib/analytics/console-provider.test.ts create mode 100644 src/components/analytics-screen-tracker.tsx create mode 100644 src/components/providers/analytics.tsx create mode 100644 src/lib/analytics/console-provider.ts create mode 100644 src/lib/analytics/index.ts create mode 100644 src/lib/analytics/types.ts diff --git a/AGENTS.md b/AGENTS.md index bd2be7086..c6f6d252f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,8 @@ GestureHandlerRootView └── ThemeProvider └── APIProvider └── AuthProvider - └── BottomSheetModalProvider + └── AnalyticsProvider + └── BottomSheetModalProvider ``` Never add a new provider outside `APIProvider` if it requires API access, or outside `AuthProvider` if it requires auth state. diff --git a/__tests__/components/analytics-screen-tracker.test.tsx b/__tests__/components/analytics-screen-tracker.test.tsx new file mode 100644 index 000000000..60af823a4 --- /dev/null +++ b/__tests__/components/analytics-screen-tracker.test.tsx @@ -0,0 +1,81 @@ +import { usePathname } from 'expo-router'; + +import { AnalyticsScreenTracker } from '@/components/analytics-screen-tracker'; +import { AnalyticsProvider } from '@/components/providers/analytics'; +import type { AnalyticsProviderAdapter } from '@/lib/analytics'; +import { render } from '@/lib/test-utils'; + +jest.mock('expo-router', () => ({ + usePathname: jest.fn(), +})); + +const mockUsePathname = usePathname as jest.MockedFunction; + +function createMockProvider(): AnalyticsProviderAdapter { + return { + init: jest.fn(), + trackEvent: jest.fn(), + trackScreen: jest.fn(), + }; +} + +describe('AnalyticsScreenTracker', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('tracks the current screen on mount', () => { + const provider = createMockProvider(); + mockUsePathname.mockReturnValue('/sign-in'); + + render( + + + , + ); + + expect(provider.trackScreen).toHaveBeenCalledTimes(1); + expect(provider.trackScreen).toHaveBeenCalledWith('/sign-in', undefined); + }); + + it('tracks again when the route changes and not on unrelated re-renders', () => { + const provider = createMockProvider(); + const providers = [provider]; + mockUsePathname.mockReturnValue('/feed'); + + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + expect(provider.trackScreen).toHaveBeenCalledTimes(1); + + mockUsePathname.mockReturnValue('/settings'); + rerender( + + + , + ); + + expect(provider.trackScreen).toHaveBeenCalledTimes(2); + expect(provider.trackScreen).toHaveBeenLastCalledWith('/settings', undefined); + }); + + it('renders nothing', () => { + mockUsePathname.mockReturnValue('/'); + + const { toJSON } = render( + + + , + ); + + expect(toJSON()).toBeNull(); + }); +}); diff --git a/__tests__/components/providers/analytics.test.tsx b/__tests__/components/providers/analytics.test.tsx new file mode 100644 index 000000000..a37275584 --- /dev/null +++ b/__tests__/components/providers/analytics.test.tsx @@ -0,0 +1,157 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; + +import { AnalyticsProvider, useAnalytics } from '@/components/providers/analytics'; +import type { AnalyticsProviderAdapter } from '@/lib/analytics'; +import { fireEvent, render, screen } from '@/lib/test-utils'; +import { Text, TouchableOpacity, View } from '@/components/ui'; + +function createMockProvider(overrides: Partial = {}) { + return { + init: jest.fn(), + trackEvent: jest.fn(), + trackScreen: jest.fn(), + identify: jest.fn(), + reset: jest.fn(), + ...overrides, + } satisfies AnalyticsProviderAdapter; +} + +function renderAnalytics(providers: ReadonlyArray) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook(() => useAnalytics(), { wrapper }); +} + +describe('AnalyticsProvider', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('initialises every provider once on mount', () => { + const first = createMockProvider(); + const second = createMockProvider(); + + const { rerender } = renderAnalytics([first, second]); + rerender({}); + + expect(first.init).toHaveBeenCalledTimes(1); + expect(second.init).toHaveBeenCalledTimes(1); + }); + + it('fans out trackEvent to every provider', () => { + const first = createMockProvider(); + const second = createMockProvider(); + const { result } = renderAnalytics([first, second]); + + result.current.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' }); + + expect(first.trackEvent).toHaveBeenCalledWith('BUTTON_PRESS', { + accessibilityId: 'login-button', + }); + expect(second.trackEvent).toHaveBeenCalledWith('BUTTON_PRESS', { + accessibilityId: 'login-button', + }); + }); + + it('fans out trackScreen to every provider', () => { + const first = createMockProvider(); + const second = createMockProvider(); + const { result } = renderAnalytics([first, second]); + + result.current.trackScreen('/feed', { tab: 'home' }); + + expect(first.trackScreen).toHaveBeenCalledWith('/feed', { tab: 'home' }); + expect(second.trackScreen).toHaveBeenCalledWith('/feed', { tab: 'home' }); + }); + + it('fans out identify and reset, skipping providers that do not implement them', () => { + const full = createMockProvider(); + const minimal = createMockProvider({ identify: undefined, reset: undefined }); + const { result } = renderAnalytics([full, minimal]); + + result.current.identify('user-1', { plan: 'free' }); + result.current.reset(); + + expect(full.identify).toHaveBeenCalledWith('user-1', { plan: 'free' }); + expect(full.reset).toHaveBeenCalledTimes(1); + }); + + it('isolates provider failures so the other providers still receive the call', async () => { + const failing = createMockProvider({ + trackEvent: jest.fn(() => { + throw new Error('vendor is down'); + }), + }); + const rejecting = createMockProvider({ + trackScreen: jest.fn().mockRejectedValue(new Error('network')), + }); + const healthy = createMockProvider(); + const { result } = renderAnalytics([failing, rejecting, healthy]); + + expect(() => { + result.current.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' }); + result.current.trackScreen('/feed'); + }).not.toThrow(); + + expect(healthy.trackEvent).toHaveBeenCalledTimes(1); + expect(healthy.trackScreen).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(console.error).toHaveBeenCalledTimes(2); + }); + }); + + it('rejects events that are not in the catalogue at compile time', () => { + const provider = createMockProvider(); + const { result } = renderAnalytics([provider]); + + // @ts-expect-error unknown event names are not allowed + result.current.trackEvent('NOT_IN_CATALOGUE', {}); + // @ts-expect-error params must match the catalogue entry + result.current.trackEvent('BUTTON_PRESS', { wrong: true }); + + expect(provider.trackEvent).toHaveBeenCalledTimes(2); + }); + + it('throws when useAnalytics is used outside of AnalyticsProvider', () => { + expect(() => renderHook(() => useAnalytics())).toThrow( + 'useAnalytics must be used within an AnalyticsProvider', + ); + }); +}); + +describe('useAnalytics in a component', () => { + it('tracks an event when the user presses a button', () => { + const provider = createMockProvider(); + + const TestComponent = () => { + const analytics = useAnalytics(); + return ( + + analytics.trackEvent('BUTTON_PRESS', { accessibilityId: 'track-button' })} + > + Track + + + ); + }; + + render( + + + , + ); + + fireEvent.press(screen.getByTestId('track-button')); + + expect(provider.trackEvent).toHaveBeenCalledWith('BUTTON_PRESS', { + accessibilityId: 'track-button', + }); + }); +}); diff --git a/__tests__/lib/analytics/console-provider.test.ts b/__tests__/lib/analytics/console-provider.test.ts new file mode 100644 index 000000000..7bdaed808 --- /dev/null +++ b/__tests__/lib/analytics/console-provider.test.ts @@ -0,0 +1,46 @@ +import { consoleAnalyticsProvider } from '@/lib/analytics'; + +const devGlobal = globalThis as unknown as { __DEV__: boolean }; + +describe('consoleAnalyticsProvider', () => { + let infoSpy: jest.SpyInstance; + const originalDev = devGlobal.__DEV__; + + beforeEach(() => { + infoSpy = jest.spyOn(console, 'info').mockImplementation(() => undefined); + }); + + afterEach(() => { + devGlobal.__DEV__ = originalDev; + infoSpy.mockRestore(); + }); + + it('logs every call in development builds', () => { + devGlobal.__DEV__ = true; + + consoleAnalyticsProvider.init(); + consoleAnalyticsProvider.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' }); + consoleAnalyticsProvider.trackScreen('/feed'); + consoleAnalyticsProvider.identify?.('user-1', { plan: 'free' }); + consoleAnalyticsProvider.reset?.(); + + expect(infoSpy).toHaveBeenCalledTimes(5); + expect(infoSpy).toHaveBeenNthCalledWith(1, '[analytics]', 'init'); + expect(infoSpy).toHaveBeenNthCalledWith(2, '[analytics]', 'event: BUTTON_PRESS', { + accessibilityId: 'login-button', + }); + expect(infoSpy).toHaveBeenNthCalledWith(3, '[analytics]', 'screen: /feed'); + expect(infoSpy).toHaveBeenNthCalledWith(4, '[analytics]', 'identify: user-1', { plan: 'free' }); + expect(infoSpy).toHaveBeenNthCalledWith(5, '[analytics]', 'reset'); + }); + + it('is a no-op outside development builds', () => { + devGlobal.__DEV__ = false; + + consoleAnalyticsProvider.init(); + consoleAnalyticsProvider.trackEvent('BUTTON_PRESS', { accessibilityId: 'login-button' }); + consoleAnalyticsProvider.trackScreen('/feed'); + + expect(infoSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/agent_docs/architecture.md b/agent_docs/architecture.md index c8f910d72..56c6a7c35 100644 --- a/agent_docs/architecture.md +++ b/agent_docs/architecture.md @@ -62,6 +62,8 @@ Route guards are centralised in `src/app/_layout.tsx`. The `GuardedStack` compon | Translation strings | `src/translations/en.json` | | Environment config | `src/lib/env.js` (access via `Env` from `@/lib/env`) | | General persistent storage | `src/lib/storage.tsx` (MMKV) | +| Analytics — event catalogue & provider contract | `src/lib/analytics/types.ts` | +| Analytics — context, `useAnalytics()`, screen tracking | `src/components/providers/analytics.tsx`, `src/components/analytics-screen-tracker.tsx` | | Global TypeScript types | `src/types/index.ts` | --- diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index a105f26b8..3b3d4bf1b 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -12,8 +12,10 @@ import { KeyboardProvider } from 'react-native-keyboard-controller'; import { APIProvider } from '@/api'; import interceptors from '@/api/common/interceptors'; +import { AnalyticsScreenTracker } from '@/components/analytics-screen-tracker'; +import { AnalyticsProvider } from '@/components/providers/analytics'; import { AuthProvider, useAuth } from '@/components/providers/auth'; -import { hydrateAuth, loadSelectedTheme, useIsFirstTime } from '@/lib'; +import { consoleAnalyticsProvider, hydrateAuth, loadSelectedTheme, useIsFirstTime } from '@/lib'; import { useThemeConfig } from '@/lib/use-theme-config'; // Import global CSS file import '../../global.css'; @@ -27,6 +29,8 @@ export const unstable_settings = { hydrateAuth(); loadSelectedTheme(); interceptors(); +// Analytics providers the app reports to. Add real vendor adapters here. +const analyticsProviders = [consoleAnalyticsProvider]; // Prevent the splash screen from auto-hiding before asset loading is complete. void SplashScreen.preventAutoHideAsync(); // Set the animation options. This is optional. @@ -103,10 +107,13 @@ function Providers({ children }: Readonly<{ children: React.ReactNode }>) { - - {children} - - + + + + {children} + + + diff --git a/src/components/analytics-screen-tracker.tsx b/src/components/analytics-screen-tracker.tsx new file mode 100644 index 000000000..89c1676d6 --- /dev/null +++ b/src/components/analytics-screen-tracker.tsx @@ -0,0 +1,19 @@ +import { usePathname } from 'expo-router'; +import { useEffect } from 'react'; + +import { useAnalytics } from '@/components/providers/analytics'; + +/** + * Reports a screen view to every analytics provider whenever the route + * changes. Mount it once, inside `AnalyticsProvider`. + */ +export function AnalyticsScreenTracker() { + const pathname = usePathname(); + const { trackScreen } = useAnalytics(); + + useEffect(() => { + trackScreen(pathname); + }, [pathname, trackScreen]); + + return null; +} diff --git a/src/components/providers/analytics.tsx b/src/components/providers/analytics.tsx new file mode 100644 index 000000000..cf95d1ac4 --- /dev/null +++ b/src/components/providers/analytics.tsx @@ -0,0 +1,80 @@ +import type { + AnalyticsEvent, + AnalyticsEventArgs, + AnalyticsParams, + AnalyticsProviderAdapter, +} from '@/lib/analytics'; +import * as React from 'react'; +import { createContext, use, useEffect, useMemo } from 'react'; + +type AnalyticsContextProps = { + providers: ReadonlyArray; + trackEvent: (name: E, ...args: AnalyticsEventArgs) => void; + trackScreen: (screenName: string, params?: AnalyticsParams) => void; + identify: (userId: string, traits?: AnalyticsParams) => void; + reset: () => void; +}; + +const AnalyticsContext = createContext(undefined); + +function reportProviderError(error: unknown) { + console.error('[analytics] provider call failed', error); +} + +/** + * Runs `call` against every provider, isolating failures so a broken vendor + * SDK never breaks the app or the other providers. + */ +function fanOut( + providers: ReadonlyArray, + call: (provider: AnalyticsProviderAdapter) => Promise, +) { + providers.forEach((provider) => { + void call(provider).catch(reportProviderError); + }); +} + +type AnalyticsProviderProps = { + /** Keep this array stable (module-level constant) so providers init only once. */ + providers: ReadonlyArray; + children: React.ReactNode; +}; + +export const AnalyticsProvider: React.FC = ({ + providers, + children, +}) => { + useEffect(() => { + fanOut(providers, async provider => provider.init()); + }, [providers]); + + const values = useMemo( + () => ({ + providers, + trackEvent: (name, ...args) => { + const [params] = args; + fanOut(providers, async provider => provider.trackEvent(name, params)); + }, + trackScreen: (screenName, params) => { + fanOut(providers, async provider => provider.trackScreen(screenName, params)); + }, + identify: (userId, traits) => { + fanOut(providers, async provider => provider.identify?.(userId, traits)); + }, + reset: () => { + fanOut(providers, async provider => provider.reset?.()); + }, + }), + [providers], + ); + + return {children}; +}; + +export function useAnalytics(): AnalyticsContextProps { + const context = use(AnalyticsContext); + if (context === undefined) { + throw new Error('useAnalytics must be used within an AnalyticsProvider'); + } + return context; +} diff --git a/src/lib/analytics/console-provider.ts b/src/lib/analytics/console-provider.ts new file mode 100644 index 000000000..32a5965ca --- /dev/null +++ b/src/lib/analytics/console-provider.ts @@ -0,0 +1,24 @@ +import type { AnalyticsParams, AnalyticsProviderAdapter } from './types'; + +const PREFIX = '[analytics]'; + +function log(message: string, params?: AnalyticsParams) { + if (!__DEV__) { + return; + } + // eslint-disable-next-line no-console + console.info(PREFIX, message, ...(params === undefined ? [] : [params])); +} + +/** + * Default provider: prints every call to the console in development builds + * and does nothing in production. Replace or complement it with a real + * vendor adapter in `src/app/_layout.tsx`. + */ +export const consoleAnalyticsProvider: AnalyticsProviderAdapter = { + init: () => log('init'), + trackEvent: (name, params) => log(`event: ${name}`, params), + trackScreen: (screenName, params) => log(`screen: ${screenName}`, params), + identify: (userId, traits) => log(`identify: ${userId}`, traits), + reset: () => log('reset'), +}; diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts new file mode 100644 index 000000000..8f6ac45d2 --- /dev/null +++ b/src/lib/analytics/index.ts @@ -0,0 +1,2 @@ +export * from './console-provider'; +export * from './types'; diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts new file mode 100644 index 000000000..e9cf67577 --- /dev/null +++ b/src/lib/analytics/types.ts @@ -0,0 +1,45 @@ +/** + * Values accepted as analytics parameters. Keeping payloads flat and primitive + * makes them compatible with every major vendor (Firebase, Segment, Amplitude...). + */ +export type AnalyticsParamValue = string | number | boolean | null | undefined; +export type AnalyticsParams = Record; + +type EventCatalogue> = T; + +/** + * Catalogue of every custom event the app can track, mapped to its params. + * Add new events here: `trackEvent` is typed against this map, so the event + * name and its params are checked at compile time. + * Use `undefined` for events that carry no params. + */ +export type AnalyticsEventParams = EventCatalogue<{ + BUTTON_PRESS: { accessibilityId: string }; +}>; + +export type AnalyticsEvent = keyof AnalyticsEventParams; + +/** + * Params tuple for `trackEvent`: optional when the event declares `undefined` + * params, required otherwise. + */ +export type AnalyticsEventArgs + = undefined extends AnalyticsEventParams[E] + ? [params?: AnalyticsEventParams[E]] + : [params: AnalyticsEventParams[E]]; + +/** + * Contract every analytics vendor must implement. App code never talks to a + * vendor SDK directly; it goes through `useAnalytics`, which fans out to every + * registered adapter. + */ +export type AnalyticsProviderAdapter = { + /** Called once when the app mounts. Initialise the vendor SDK here. */ + init: () => void | Promise; + trackEvent: (name: AnalyticsEvent, params?: AnalyticsParams) => void | Promise; + trackScreen: (screenName: string, params?: AnalyticsParams) => void | Promise; + /** Associate subsequent events with a user. */ + identify?: (userId: string, traits?: AnalyticsParams) => void | Promise; + /** Clear the current user, e.g. on sign out. */ + reset?: () => void | Promise; +}; diff --git a/src/lib/index.tsx b/src/lib/index.tsx index 088206f63..c0de643e1 100644 --- a/src/lib/index.tsx +++ b/src/lib/index.tsx @@ -1,3 +1,4 @@ +export * from './analytics'; export * from './auth'; export * from './hooks'; export * from './i18n'; From 9cb7069d7a6e88edd2d30388b8f3c2df708578b1 Mon Sep 17 00:00:00 2001 From: devs-rootstrap <112487182+devs-rootstrap@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:28:14 -0300 Subject: [PATCH 2/2] docs: add analytics guide --- docs/astro.config.mjs | 5 + docs/src/content/docs/guides/analytics.mdx | 170 +++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 docs/src/content/docs/guides/analytics.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9f5eb3040..bcde9bde0 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -142,6 +142,11 @@ export default defineConfig({ label: 'Storage', link: '/guides/storage/', }, + { + label: 'Analytics', + link: '/guides/analytics/', + badge: 'new', + }, { label: 'Upgrade Dependencies', link: '/guides/upgrading-deps/', diff --git a/docs/src/content/docs/guides/analytics.mdx b/docs/src/content/docs/guides/analytics.mdx new file mode 100644 index 000000000..38aff267c --- /dev/null +++ b/docs/src/content/docs/guides/analytics.mdx @@ -0,0 +1,170 @@ +--- +title: Analytics +description: Provider-agnostic analytics layer with typed events and automatic screen tracking. +head: + - tag: title + content: Analytics | React Native / Expo Starter +--- + +import CodeBlock from '../../../components/code.astro'; + +Analytics should be part of a project from day one, but the vendor is often chosen (or changed) later. The starter ships a small analytics layer that keeps vendor SDKs out of your screens and components: app code only ever calls `useAnalytics()`, and the layer fans every call out to the providers you register. + +Out of the box the layer is wired with a console provider, so it works with no vendor SDK and no native configuration. Swapping or adding a vendor is a change in a single file. + +## How it works + +``` +Screens / components + │ useAnalytics().trackEvent(...) / trackScreen(...) + ▼ +AnalyticsProvider (React context) ──► provider A (console) + ▲ ──► provider B (Firebase, Segment, ...) + │ +AnalyticsScreenTracker (listens to expo-router route changes) +``` + +| Piece | Location | +| -------------------------------------------- | ----------------------------------------------- | +| Event catalogue and provider adapter contract | `src/lib/analytics/types.ts` | +| Built-in console provider | `src/lib/analytics/console-provider.ts` | +| React context, `useAnalytics()` hook | `src/components/providers/analytics.tsx` | +| Automatic screen tracking | `src/components/analytics-screen-tracker.tsx` | +| Wiring (which providers the app uses) | `src/app/_layout.tsx` | + +## Tracking events + +Use the `useAnalytics()` hook anywhere below the provider tree. `trackEvent` is typed against the event catalogue, so an unknown event name or a wrong params shape fails to compile. + +```tsx +import { useAnalytics } from '@/components/providers/analytics'; + +export function MyComponent() { + const analytics = useAnalytics(); + + const onPressButton = () => { + analytics.trackEvent('BUTTON_PRESS', { accessibilityId: 'submit-button' }); + }; + + return