diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.appbuild.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.appbuild.md new file mode 100644 index 000000000..e9dc7a44f --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.appbuild.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [AppLifecycleConfiguration](./react-native-tracker.applifecycleconfiguration.md) > [appBuild](./react-native-tracker.applifecycleconfiguration.appbuild.md) + +## AppLifecycleConfiguration.appBuild property + +Build name of the application e.g s9f2k2d or 1.1.0 beta + +Entity schema: `iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0` + +Signature: + +```typescript +appBuild?: string; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.appversion.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.appversion.md new file mode 100644 index 000000000..8b5f4fdfe --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.appversion.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [AppLifecycleConfiguration](./react-native-tracker.applifecycleconfiguration.md) > [appVersion](./react-native-tracker.applifecycleconfiguration.appversion.md) + +## AppLifecycleConfiguration.appVersion property + +Version number of the application e.g 1.1.0 (semver or git commit hash). + +Entity schema if `appBuild` property is set: `iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0` Entity schema if `appBuild` property is not set: `iglu:com.snowplowanalytics.snowplow/application/jsonschema/1-0-0` + +Signature: + +```typescript +appVersion?: string; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.installautotracking.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.installautotracking.md new file mode 100644 index 000000000..707da58ae --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.installautotracking.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [AppLifecycleConfiguration](./react-native-tracker.applifecycleconfiguration.md) > [installAutotracking](./react-native-tracker.applifecycleconfiguration.installautotracking.md) + +## AppLifecycleConfiguration.installAutotracking property + +Whether to automatically track app install event on first run. + +Schema: `iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0` + +Signature: + +```typescript +installAutotracking?: boolean; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.lifecycleautotracking.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.lifecycleautotracking.md new file mode 100644 index 000000000..cb90cb23f --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.lifecycleautotracking.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [AppLifecycleConfiguration](./react-native-tracker.applifecycleconfiguration.md) > [lifecycleAutotracking](./react-native-tracker.applifecycleconfiguration.lifecycleautotracking.md) + +## AppLifecycleConfiguration.lifecycleAutotracking property + +Whether to automatically track app lifecycle events (app foreground and background events). Also adds a lifecycle context entity to all events. + +Foreground event schema: `iglu:com.snowplowanalytics.snowplow/application_foreground/jsonschema/1-0-0` Background event schema: `iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0` Context entity schema: `iglu:com.snowplowanalytics.mobile/application_lifecycle/jsonschema/1-0-0` + +Signature: + +```typescript +lifecycleAutotracking?: boolean; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.md new file mode 100644 index 000000000..0c1f62a04 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.applifecycleconfiguration.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [AppLifecycleConfiguration](./react-native-tracker.applifecycleconfiguration.md) + +## AppLifecycleConfiguration interface + +Configuration for app lifecycle tracking + +Signature: + +```typescript +export interface AppLifecycleConfiguration +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [appBuild?](./react-native-tracker.applifecycleconfiguration.appbuild.md) | string | (Optional) Build name of the application e.g s9f2k2d or 1.1.0 betaEntity schema: iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0 | +| [appVersion?](./react-native-tracker.applifecycleconfiguration.appversion.md) | string | (Optional) Version number of the application e.g 1.1.0 (semver or git commit hash).Entity schema if appBuild property is set: iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0 Entity schema if appBuild property is not set: iglu:com.snowplowanalytics.snowplow/application/jsonschema/1-0-0 | +| [installAutotracking?](./react-native-tracker.applifecycleconfiguration.installautotracking.md) | boolean | (Optional) Whether to automatically track app install event on first run.Schema: iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0 | +| [lifecycleAutotracking?](./react-native-tracker.applifecycleconfiguration.lifecycleautotracking.md) | boolean | (Optional) Whether to automatically track app lifecycle events (app foreground and background events). Also adds a lifecycle context entity to all events.Foreground event schema: iglu:com.snowplowanalytics.snowplow/application_foreground/jsonschema/1-0-0 Background event schema: iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0 Context entity schema: iglu:com.snowplowanalytics.mobile/application_lifecycle/jsonschema/1-0-0 | + diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md index 4f68312df..a4a06599b 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md @@ -24,6 +24,7 @@ | Interface | Description | | --- | --- | +| [AppLifecycleConfiguration](./react-native-tracker.applifecycleconfiguration.md) | Configuration for app lifecycle tracking | | [CoreConfiguration](./react-native-tracker.coreconfiguration.md) | The configuration object for the tracker core library | | [CorePlugin](./react-native-tracker.coreplugin.md) | Interface which defines Core Plugins | | [CorePluginConfiguration](./react-native-tracker.corepluginconfiguration.md) | The configuration of the plugin to add | diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md index a4b953deb..270a0f7f7 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md @@ -9,14 +9,14 @@ Creates a new tracker instance with the given configuration Signature: ```typescript -export declare function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration & ScreenTrackingConfiguration & PlatformContextConfiguration & DeepLinkConfiguration): Promise; +export declare function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration & ScreenTrackingConfiguration & PlatformContextConfiguration & DeepLinkConfiguration & AppLifecycleConfiguration): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| configuration | [TrackerConfiguration](./react-native-tracker.trackerconfiguration.md) & EmitterConfiguration & [SessionConfiguration](./react-native-tracker.sessionconfiguration.md) & [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md) & [EventStoreConfiguration](./react-native-tracker.eventstoreconfiguration.md) & ScreenTrackingConfiguration & [PlatformContextConfiguration](./react-native-tracker.platformcontextconfiguration.md) & [DeepLinkConfiguration](./react-native-tracker.deeplinkconfiguration.md) | Configuration for the tracker | +| configuration | [TrackerConfiguration](./react-native-tracker.trackerconfiguration.md) & EmitterConfiguration & [SessionConfiguration](./react-native-tracker.sessionconfiguration.md) & [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md) & [EventStoreConfiguration](./react-native-tracker.eventstoreconfiguration.md) & ScreenTrackingConfiguration & [PlatformContextConfiguration](./react-native-tracker.platformcontextconfiguration.md) & [DeepLinkConfiguration](./react-native-tracker.deeplinkconfiguration.md) & [AppLifecycleConfiguration](./react-native-tracker.applifecycleconfiguration.md) | Configuration for the tracker | Returns: diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md index 5785f9ad5..720dc9ee8 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md @@ -42,6 +42,9 @@ export declare type ReactNativeTracker = { readonly getSessionId: () => Promise; readonly getSessionIndex: () => Promise; readonly getSessionState: () => Promise; + readonly getIsInBackground: () => boolean | undefined; + readonly getBackgroundIndex: () => number | undefined; + readonly getForegroundIndex: () => number | undefined; readonly enablePlatformContext: () => Promise; readonly disablePlatformContext: () => void; readonly refreshPlatformContext: () => Promise; diff --git a/api-docs/docs/react-native-tracker/react-native-tracker.api.md b/api-docs/docs/react-native-tracker/react-native-tracker.api.md index f2f785ae1..452ad94c4 100644 --- a/api-docs/docs/react-native-tracker/react-native-tracker.api.md +++ b/api-docs/docs/react-native-tracker/react-native-tracker.api.md @@ -8,6 +8,14 @@ import { BrowserPlugin } from '@snowplow/browser-tracker-core'; import { BrowserPluginConfiguration } from '@snowplow/browser-tracker-core'; import { ScreenTrackingConfiguration } from '@snowplow/browser-plugin-screen-tracking'; +// @public +export interface AppLifecycleConfiguration { + appBuild?: string; + appVersion?: string; + installAutotracking?: boolean; + lifecycleAutotracking?: boolean; +} + // @public export type ConditionalContextProvider = FilterProvider | RuleSetProvider; @@ -260,7 +268,7 @@ export type MessageNotificationProps = { }; // @public -export function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration & ScreenTrackingConfiguration & PlatformContextConfiguration & DeepLinkConfiguration): Promise; +export function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration & ScreenTrackingConfiguration & PlatformContextConfiguration & DeepLinkConfiguration & AppLifecycleConfiguration): Promise; // @public export interface PageViewEvent { @@ -377,6 +385,9 @@ export type ReactNativeTracker = { readonly getSessionId: () => Promise; readonly getSessionIndex: () => Promise; readonly getSessionState: () => Promise; + readonly getIsInBackground: () => boolean | undefined; + readonly getBackgroundIndex: () => number | undefined; + readonly getForegroundIndex: () => number | undefined; readonly enablePlatformContext: () => Promise; readonly disablePlatformContext: () => void; readonly refreshPlatformContext: () => Promise; diff --git a/common/changes/@snowplow/react-native-tracker/issue-react-native-lifecycle-tracking_2024-12-02-08-40.json b/common/changes/@snowplow/react-native-tracker/issue-react-native-lifecycle-tracking_2024-12-02-08-40.json new file mode 100644 index 000000000..6ff5b3a87 --- /dev/null +++ b/common/changes/@snowplow/react-native-tracker/issue-react-native-lifecycle-tracking_2024-12-02-08-40.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/react-native-tracker", + "comment": "Add app install, foreground and background event and application entity tracking (#1396)", + "type": "none" + } + ], + "packageName": "@snowplow/react-native-tracker" +} \ No newline at end of file diff --git a/trackers/react-native-tracker/src/constants.ts b/trackers/react-native-tracker/src/constants.ts index a681fa37c..5c1fbb0d7 100644 --- a/trackers/react-native-tracker/src/constants.ts +++ b/trackers/react-native-tracker/src/constants.ts @@ -2,10 +2,14 @@ export const FOREGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/appl export const BACKGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0'; export const DEEP_LINK_RECEIVED_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/deep_link_received/jsonschema/1-0-0'; export const SCREEN_VIEW_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/screen_view/jsonschema/1-0-0'; +export const APPLICATION_INSTALL_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0'; export const CLIENT_SESSION_ENTITY_SCHEMA ='iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2' export const MOBILE_CONTEXT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/mobile_context/jsonschema/1-0-3'; export const DEEP_LINK_ENTITY_SCHEMA = 'iglu:com.snowplowanalytics.mobile/deep_link/jsonschema/1-0-0'; +export const LIFECYCLE_CONTEXT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/application_lifecycle/jsonschema/1-0-0'; +export const MOBILE_APPLICATION_CONTEXT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0'; +export const APPLICATION_CONTEXT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application/jsonschema/1-0-0'; export const PAGE_URL_PROPERTY = 'url'; export const PAGE_REFERRER_PROPERTY = 'refr'; diff --git a/trackers/react-native-tracker/src/plugins/app_context/index.ts b/trackers/react-native-tracker/src/plugins/app_context/index.ts new file mode 100644 index 000000000..417020df2 --- /dev/null +++ b/trackers/react-native-tracker/src/plugins/app_context/index.ts @@ -0,0 +1,44 @@ +import { CorePluginConfiguration, SelfDescribingJson } from '@snowplow/tracker-core'; +import { AppLifecycleConfiguration } from '../../types'; +import { APPLICATION_CONTEXT_SCHEMA, MOBILE_APPLICATION_CONTEXT_SCHEMA } from '../../constants'; + +/** + * Tracks the application context entity with information about the app version. + * If appBuild is provided, a mobile application context is tracked, otherwise the Web equivalent is tracked. + * + * Entity schema if `appBuild` property is set: `iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0` + * Entity schema if `appBuild` property is not set: `iglu:com.snowplowanalytics.snowplow/application/jsonschema/1-0-0` + */ +export function newAppContextPlugin({ appVersion, appBuild }: AppLifecycleConfiguration): CorePluginConfiguration { + const contexts = () => { + let entities: SelfDescribingJson[] = []; + + if (appVersion) { + // Add application context to all events + if (appBuild) { + entities.push({ + schema: MOBILE_APPLICATION_CONTEXT_SCHEMA, + data: { + version: appVersion, + build: appBuild, + }, + }); + } else { + entities.push({ + schema: APPLICATION_CONTEXT_SCHEMA, + data: { + version: appVersion, + }, + }); + } + } + + return entities; + }; + + return { + plugin: { + contexts, + }, + }; +} diff --git a/trackers/react-native-tracker/src/plugins/app_install/index.ts b/trackers/react-native-tracker/src/plugins/app_install/index.ts new file mode 100644 index 000000000..4e9c8ac58 --- /dev/null +++ b/trackers/react-native-tracker/src/plugins/app_install/index.ts @@ -0,0 +1,37 @@ +import { buildSelfDescribingEvent, CorePluginConfiguration, TrackerCore } from '@snowplow/tracker-core'; +import { AppLifecycleConfiguration, TrackerConfiguration } from '../../types'; +import { APPLICATION_INSTALL_EVENT_SCHEMA } from '../../constants'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * Tracks an application install event on the first run of the app. + * Stores the install event in AsyncStorage to prevent tracking on subsequent runs. + * + * Event schema: `iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0` + */ +export function newAppInstallPlugin( + { namespace, installAutotracking = false }: TrackerConfiguration & AppLifecycleConfiguration, + core: TrackerCore +): CorePluginConfiguration { + if (installAutotracking) { + // Track install event on first run + const key = `snowplow_${namespace}_install`; + setTimeout(async () => { + const installEvent = await AsyncStorage.getItem(key); + if (!installEvent) { + core.track( + buildSelfDescribingEvent({ + event: { + schema: APPLICATION_INSTALL_EVENT_SCHEMA, + data: {}, + }, + }) + ); + await AsyncStorage.setItem(key, new Date().toISOString()); + } + }, 0); + } + return { + plugin: {}, + }; +} diff --git a/trackers/react-native-tracker/src/plugins/app_lifecycle/index.ts b/trackers/react-native-tracker/src/plugins/app_lifecycle/index.ts new file mode 100644 index 000000000..c7eaae200 --- /dev/null +++ b/trackers/react-native-tracker/src/plugins/app_lifecycle/index.ts @@ -0,0 +1,97 @@ +import { + buildSelfDescribingEvent, + CorePluginConfiguration, + SelfDescribingJson, + TrackerCore, +} from '@snowplow/tracker-core'; +import { AppLifecycleConfiguration, EventContext } from '../../types'; +import { BACKGROUND_EVENT_SCHEMA, FOREGROUND_EVENT_SCHEMA, LIFECYCLE_CONTEXT_SCHEMA } from '../../constants'; +import { AppState } from 'react-native'; + +export interface AppLifecyclePlugin extends CorePluginConfiguration { + getIsInBackground: () => boolean | undefined; + getBackgroundIndex: () => number | undefined; + getForegroundIndex: () => number | undefined; +} + +/** + * Tracks foreground and background events automatically when the app state changes. + * Also adds a lifecycle context to all events with information about the app visibility. + */ +export async function newAppLifecyclePlugin( + { lifecycleAutotracking = true }: AppLifecycleConfiguration, + core: TrackerCore +): Promise { + let isInForeground = AppState.currentState !== 'background'; + let foregroundIndex = isInForeground ? 1 : 0; + let backgroundIndex = isInForeground ? 0 : 1; + let subscription: ReturnType | undefined; + + if (lifecycleAutotracking) { + // Subscribe to app state changes and track foreground/background events + subscription = AppState.addEventListener('change', async (nextAppState) => { + if (nextAppState === 'active' && !isInForeground) { + trackForegroundEvent(); + } + if (nextAppState === 'background' && isInForeground) { + trackBackgroundEvent(); + } + }); + } + + const contexts = () => { + let entities: SelfDescribingJson[] = []; + + if (lifecycleAutotracking) { + // Add lifecycle context to all events + entities.push({ + schema: LIFECYCLE_CONTEXT_SCHEMA, + data: { + isVisible: isInForeground, + index: isInForeground ? foregroundIndex : backgroundIndex, + }, + }); + } + + return entities; + }; + + const deactivatePlugin = () => { + if (subscription) { + subscription.remove(); + subscription = undefined; + } + }; + + const trackForegroundEvent = (contexts?: EventContext[]) => { + if (!isInForeground) { + isInForeground = true; + foregroundIndex += 1; + } + core.track( + buildSelfDescribingEvent({ event: { schema: FOREGROUND_EVENT_SCHEMA, data: { foregroundIndex } } }), + contexts + ); + }; + + const trackBackgroundEvent = (contexts?: EventContext[]) => { + if (isInForeground) { + isInForeground = false; + backgroundIndex += 1; + } + core.track( + buildSelfDescribingEvent({ event: { schema: BACKGROUND_EVENT_SCHEMA, data: { backgroundIndex } } }), + contexts + ); + }; + + return { + getIsInBackground: () => (lifecycleAutotracking ? !isInForeground : undefined), + getBackgroundIndex: () => (lifecycleAutotracking ? backgroundIndex : undefined), + getForegroundIndex: () => (lifecycleAutotracking ? foregroundIndex : undefined), + plugin: { + contexts, + deactivatePlugin, + }, + }; +} diff --git a/trackers/react-native-tracker/src/tracker.ts b/trackers/react-native-tracker/src/tracker.ts index ef6608f92..86d375e38 100644 --- a/trackers/react-native-tracker/src/tracker.ts +++ b/trackers/react-native-tracker/src/tracker.ts @@ -14,6 +14,7 @@ import { import { DeepLinkConfiguration, + AppLifecycleConfiguration, EventContext, EventStoreConfiguration, ListItemViewProps, @@ -29,6 +30,9 @@ import { newSessionPlugin } from './plugins/session'; import { newDeepLinksPlugin } from './plugins/deep_links'; import { newPlugins } from './plugins'; import { newPlatformContextPlugin } from './plugins/platform_context'; +import { newAppLifecyclePlugin } from './plugins/app_lifecycle'; +import { newAppInstallPlugin } from './plugins/app_install'; +import { newAppContextPlugin } from './plugins/app_context'; const initializedTrackers: Record = {}; @@ -45,7 +49,8 @@ export async function newTracker( EventStoreConfiguration & ScreenTrackingConfiguration & PlatformContextConfiguration & - DeepLinkConfiguration + DeepLinkConfiguration & + AppLifecycleConfiguration ): Promise { const { namespace, appId, encodeBase64 = false } = configuration; if (configuration.eventStore === undefined) { @@ -83,6 +88,15 @@ export async function newTracker( const platformContextPlugin = await newPlatformContextPlugin(configuration); addPlugin(platformContextPlugin); + const lifecyclePlugin = await newAppLifecyclePlugin(configuration, core); + addPlugin(lifecyclePlugin); + + const installPlugin = newAppInstallPlugin(configuration, core); + addPlugin(installPlugin); + + const appContextPlugin = newAppContextPlugin(configuration); + addPlugin(appContextPlugin); + (configuration.plugins ?? []).forEach((plugin) => addPlugin({ plugin })); const tracker: ReactNativeTracker = { @@ -128,6 +142,9 @@ export async function newTracker( [namespace] ), trackDeepLinkReceivedEvent: deepLinksPlugin.trackDeepLinkReceivedEvent, + getIsInBackground: lifecyclePlugin.getIsInBackground, + getBackgroundIndex: lifecyclePlugin.getBackgroundIndex, + getForegroundIndex: lifecyclePlugin.getForegroundIndex, }; initializedTrackers[namespace] = { tracker, core }; diff --git a/trackers/react-native-tracker/src/types.ts b/trackers/react-native-tracker/src/types.ts index 6441399e5..e078d3047 100755 --- a/trackers/react-native-tracker/src/types.ts +++ b/trackers/react-native-tracker/src/types.ts @@ -42,6 +42,44 @@ export interface SessionConfiguration { backgroundSessionTimeout?: number; } +/** + * Configuration for app lifecycle tracking + */ +export interface AppLifecycleConfiguration { + /** + * Whether to automatically track app lifecycle events (app foreground and background events). + * Also adds a lifecycle context entity to all events. + * + * Foreground event schema: `iglu:com.snowplowanalytics.snowplow/application_foreground/jsonschema/1-0-0` + * Background event schema: `iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0` + * Context entity schema: `iglu:com.snowplowanalytics.mobile/application_lifecycle/jsonschema/1-0-0` + * + * @defaultValue true + */ + lifecycleAutotracking?: boolean; + /** + * Whether to automatically track app install event on first run. + * + * Schema: `iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0` + * + * @defaultValue false + */ + installAutotracking?: boolean; + /** + * Version number of the application e.g 1.1.0 (semver or git commit hash). + * + * Entity schema if `appBuild` property is set: `iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0` + * Entity schema if `appBuild` property is not set: `iglu:com.snowplowanalytics.snowplow/application/jsonschema/1-0-0` + */ + appVersion?: string; + /** + * Build name of the application e.g s9f2k2d or 1.1.0 beta + * + * Entity schema: `iglu:com.snowplowanalytics.mobile/application/jsonschema/1-0-0` + */ + appBuild?: string; +} + /** * The configuration object for initialising the tracker */ @@ -813,29 +851,20 @@ export type ReactNativeTracker = { */ readonly getSessionState: () => Promise; - // TODO: - // /** - // * Gets whether the app is currently in background state - // * - // * @returns {Promise} - // */ - // readonly getIsInBackground: () => Promise; - - // TODO: - // /** - // * Gets the number of background transitions in the current session - // * - // * @returns {Promise} - // */ - // readonly getBackgroundIndex: () => Promise; - - // TODO: - // /** - // * Gets the number of foreground transitions in the current session. - // * - // * @returns {Promise} - // */ - // readonly getForegroundIndex: () => Promise; + /** + * Gets whether the app is currently in background state + */ + readonly getIsInBackground: () => boolean | undefined; + + /** + * Gets the number of background transitions in the current session + */ + readonly getBackgroundIndex: () => number | undefined; + + /** + * Gets the number of foreground transitions in the current session. + */ + readonly getForegroundIndex: () => number | undefined; /** * Enables tracking the platform context with information about the device. diff --git a/trackers/react-native-tracker/test/plugins/app_context.test.ts b/trackers/react-native-tracker/test/plugins/app_context.test.ts new file mode 100644 index 000000000..449f0efdd --- /dev/null +++ b/trackers/react-native-tracker/test/plugins/app_context.test.ts @@ -0,0 +1,76 @@ +import { APPLICATION_CONTEXT_SCHEMA, MOBILE_APPLICATION_CONTEXT_SCHEMA } from '../../src/constants'; +import { newAppContextPlugin } from '../../src/plugins/app_context'; +import { buildPageView, Payload, trackerCore } from '@snowplow/tracker-core'; + +describe('Application context plugin', () => { + it('attaches mobile application context to events if both version and build passed', async () => { + const appContext = await newAppContextPlugin({ + appBuild: '19', + appVersion: '1.0.1', + }); + + const payloads: Payload[] = []; + const tracker = trackerCore({ + corePlugins: [appContext.plugin], + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + expect(payloads.length).toBe(1); + expect(JSON.parse(payloads[0]?.co as string).data).toEqual([ + { + schema: MOBILE_APPLICATION_CONTEXT_SCHEMA, + data: { + version: '1.0.1', + build: '19', + }, + }, + ]); + }); + + it('attaches application context to events if only version passed', async () => { + const appContext = await newAppContextPlugin({ + appVersion: '1.0.1', + }); + + const payloads: Payload[] = []; + const tracker = trackerCore({ + corePlugins: [appContext.plugin], + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + expect(payloads.length).toBe(1); + expect(JSON.parse(payloads[0]?.co as string).data).toEqual([ + { + schema: APPLICATION_CONTEXT_SCHEMA, + data: { + version: '1.0.1', + }, + }, + ]); + }); + + it('doesnt attach any application context to events if version not passed', async () => { + const appContext = await newAppContextPlugin({ + appBuild: '19', + }); + + const payloads: Payload[] = []; + const tracker = trackerCore({ + corePlugins: [appContext.plugin], + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + expect(payloads.length).toBe(1); + expect((payloads[0]?.co as string) ?? '').not.toContain(MOBILE_APPLICATION_CONTEXT_SCHEMA); + expect((payloads[0]?.co as string) ?? '').not.toContain(APPLICATION_CONTEXT_SCHEMA); + }); +}); diff --git a/trackers/react-native-tracker/test/plugins/app_install.test.ts b/trackers/react-native-tracker/test/plugins/app_install.test.ts new file mode 100644 index 000000000..88b9409f5 --- /dev/null +++ b/trackers/react-native-tracker/test/plugins/app_install.test.ts @@ -0,0 +1,90 @@ +import { Payload, trackerCore } from '@snowplow/tracker-core'; +import { newAppInstallPlugin } from '../../src/plugins/app_install'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { APPLICATION_INSTALL_EVENT_SCHEMA } from '../../src/constants'; + +describe('Application install plugin', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + }); + + it('tracks an app install event on first tracker init', async () => { + const tracker = trackerCore({ + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + const appInstallPlugin = newAppInstallPlugin( + { + namespace: 'test', + installAutotracking: true, + }, + tracker + ); + tracker.addPlugin(appInstallPlugin); + + const payloads: Payload[] = []; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(payloads.length).toBe(1); + const [{ ue_pr }] = payloads as any; + expect(ue_pr).toContain(APPLICATION_INSTALL_EVENT_SCHEMA); + }); + + it('does not track an app install event on subsequent tracker inits', async () => { + const tracker1 = trackerCore({ + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + tracker1.addPlugin( + newAppInstallPlugin( + { + namespace: 'test', + installAutotracking: true, + }, + tracker1 + ) + ); + + const payloads: Payload[] = []; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(payloads.length).toBe(1); + + const tracker2 = trackerCore({ + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + tracker2.addPlugin( + newAppInstallPlugin( + { + namespace: 'test', + installAutotracking: true, + }, + tracker2 + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(payloads.length).toBe(1); + }); + + it('does not track an app install event when autotracking is disabled', async () => { + const tracker = trackerCore({ + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + const appInstallPlugin = newAppInstallPlugin( + { + namespace: 'test', + }, + tracker + ); + tracker.addPlugin(appInstallPlugin); + + const payloads: Payload[] = []; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(payloads.length).toBe(0); + }); +}); diff --git a/trackers/react-native-tracker/test/plugins/app_lifecycle.test.ts b/trackers/react-native-tracker/test/plugins/app_lifecycle.test.ts new file mode 100644 index 000000000..f4cb35824 --- /dev/null +++ b/trackers/react-native-tracker/test/plugins/app_lifecycle.test.ts @@ -0,0 +1,97 @@ +import { AppState } from 'react-native'; +import { BACKGROUND_EVENT_SCHEMA, LIFECYCLE_CONTEXT_SCHEMA } from '../../src/constants'; +import { buildPageView, Payload, trackerCore } from '@snowplow/tracker-core'; +import { newAppLifecyclePlugin } from '../../src/plugins/app_lifecycle'; + +describe('Application lifecycle plugin', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('tracks events on app state changes', async () => { + const appStateSpy = jest.spyOn(AppState, 'addEventListener'); + + const payloads: Payload[] = []; + const tracker = trackerCore({ + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + const appLifecyclePlugin = await newAppLifecyclePlugin({}, tracker); + tracker.addPlugin(appLifecyclePlugin); + + appStateSpy.mock.calls?.[0]?.[1]('background'); + + expect(payloads.length).toBe(1); + expect(payloads[0]?.ue_pr ?? '').toContain(BACKGROUND_EVENT_SCHEMA); + }); + + it('attaches lifecycle context to events with the correct properties', async () => { + const appStateSpy = jest.spyOn(AppState, 'addEventListener'); + + const payloads: Payload[] = []; + const tracker = trackerCore({ + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + const appLifecyclePlugin = await newAppLifecyclePlugin({}, tracker); + tracker.addPlugin(appLifecyclePlugin); + + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + expect(payloads.length).toBe(1); + expect(JSON.parse(payloads[0]?.co as string).data).toEqual([ + { + schema: LIFECYCLE_CONTEXT_SCHEMA, + data: { + isVisible: true, + index: 1, + }, + }, + ]); + + payloads.length = 0; + appStateSpy.mock.calls?.[0]?.[1]('background'); + + expect(payloads.length).toBe(1); + expect(JSON.parse(payloads[0]?.co as string).data).toEqual([ + { + schema: LIFECYCLE_CONTEXT_SCHEMA, + data: { + isVisible: false, + index: 1, + }, + }, + ]); + + payloads.length = 0; + appStateSpy.mock.calls?.[0]?.[1]('active'); + + expect(payloads.length).toBe(1); + expect(JSON.parse(payloads[0]?.co as string).data).toEqual([ + { + schema: LIFECYCLE_CONTEXT_SCHEMA, + data: { + isVisible: true, + index: 2, + }, + }, + ]); + }); + + it('removes subscription on tracker deactivation', async () => { + const appStateSpy = jest.spyOn(AppState, 'addEventListener'); + const removeSpy = jest.fn(); + appStateSpy.mockReturnValue({ remove: removeSpy }); + + const tracker = trackerCore({ + callback: () => {}, + base64: false, + }); + const appLifecyclePlugin = await newAppLifecyclePlugin({}, tracker); + tracker.addPlugin(appLifecyclePlugin); + + expect(appStateSpy).toHaveBeenCalledTimes(1); + tracker.deactivate(); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/trackers/react-native-tracker/test/tracker.test.ts b/trackers/react-native-tracker/test/tracker.test.ts index 4ea74c75d..6be0a33a3 100644 --- a/trackers/react-native-tracker/test/tracker.test.ts +++ b/trackers/react-native-tracker/test/tracker.test.ts @@ -93,6 +93,28 @@ describe('Tracker', () => { expect(await tracker.getSessionUserId()).toBeDefined(); }); + it('attaches application context to events', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + appVersion: '1.0.1', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + tracker.trackPageViewEvent({ + pageUrl: 'http://localhost:9090', + pageTitle: 'Home', + }); + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + expect(payload.data[0].co).toContain('/application/'); + expect(payload.data[0].co).toContain('1.0.1'); + }); + it('tracks screen engagement events', async () => { const tracker = await newTracker({ namespace: 'test',