Skip to content

Commit

Permalink
feat: Add react native autocapture lifecycle events (#108)
Browse files Browse the repository at this point in the history
* Add react-native installation/update autocapture

* Add Application Opened and Application Backgrounded

* Rename to captureNativeAppLifecycleEvents

* Remove "types" field for tsconfig, so that all @types are included

* Add unit tests for capturing lifecycle events

* Add warning when using RN lib with memory persistence

* in RN, persist app version even if not capturing lifecycle events

* Remove __onConstructed
  • Loading branch information
robbie-c authored Sep 6, 2023
1 parent 7ad76f9 commit 4a707fd
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 6 deletions.
2 changes: 2 additions & 0 deletions posthog-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export enum PostHogPersistedProperty {
SessionLastTimestamp = 'session_timestamp',
PersonProperties = 'person_properties',
GroupProperties = 'group_properties',
InstalledAppBuild = 'installed_app_build', // only used by posthog-react-native
InstalledAppVersion = 'installed_app_version', // only used by posthog-react-native
}

export type PostHogFetchOptions = {
Expand Down
88 changes: 84 additions & 4 deletions posthog-react-native/src/posthog-rn.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { AppState, Dimensions } from 'react-native'
import { AppState, Dimensions, Linking } from 'react-native'

import {
PostHogCore,
PosthogCoreOptions,
PostHogFetchOptions,
PostHogFetchResponse,
PostHogPersistedProperty,
} from '../../posthog-core/src'
} from '../../posthog-core'
import { PostHogMemoryStorage } from '../../posthog-core/src/storage-memory'
import { getLegacyValues } from './legacy'
import { SemiAsyncStorage } from './storage'
Expand All @@ -22,6 +22,7 @@ export type PostHogOptions = PosthogCoreOptions & {
| PostHogCustomAppProperties
| ((properties: PostHogCustomAppProperties) => PostHogCustomAppProperties)
customAsyncStorage?: PostHogCustomAsyncStorage
captureNativeAppLifecycleEvents?: boolean
}

const clientMap = new Map<string, Promise<PostHog>>()
Expand All @@ -31,6 +32,7 @@ export class PostHog extends PostHogCore {
private _memoryStorage = new PostHogMemoryStorage()
private _semiAsyncStorage?: SemiAsyncStorage
private _appProperties: PostHogCustomAppProperties = {}
private _setupPromise?: Promise<void>

static _resetClientCache(): void {
// NOTE: this method is intended for testing purposes only
Expand All @@ -55,7 +57,9 @@ export class PostHog extends PostHogCore {
console.warn('PostHog.initAsync called twice with the same apiKey. The first instance will be used.')
}

return posthog
const resolved = await posthog
await resolved._setupPromise
return resolved
}

constructor(apiKey: string, options?: PostHogOptions, storage?: SemiAsyncStorage) {
Expand Down Expand Up @@ -103,9 +107,20 @@ export class PostHog extends PostHogCore {
if (options?.preloadFeatureFlags !== false) {
this.reloadFeatureFlags()
}

if (options?.captureNativeAppLifecycleEvents) {
if (this._persistence === 'memory') {
console.warn(
'PostHog was initialised with persistence set to "memory", capturing native app events is not supported.'
)
} else {
await this.captureNativeAppLifecycleEvents()
}
}
await this.persistAppVersion()
}

void setupAsync()
this._setupPromise = setupAsync()
}

getPersistedProperty<T>(key: PostHogPersistedProperty): T | undefined {
Expand Down Expand Up @@ -160,4 +175,69 @@ export class PostHog extends PostHogCore {
initReactNativeNavigation(options: PostHogAutocaptureOptions): boolean {
return withReactNativeNavigation(this, options)
}

async captureNativeAppLifecycleEvents(): Promise<void> {
// See the other implementations for reference:
// ios: https://github.com/PostHog/posthog-ios/blob/3a6afc24d6bde730a19470d4e6b713f44d076ad9/PostHog/Classes/PHGPostHog.m#L140
// android: https://github.com/PostHog/posthog-android/blob/09940e6921bafa9e01e7d68b8c9032671a21ae73/posthog/src/main/java/com/posthog/android/PostHog.java#L278
// android: https://github.com/PostHog/posthog-android/blob/09940e6921bafa9e01e7d68b8c9032671a21ae73/posthog/src/main/java/com/posthog/android/PostHogActivityLifecycleCallbacks.java#L126

const prevAppBuild = this.getPersistedProperty(PostHogPersistedProperty.InstalledAppBuild)
const prevAppVersion = this.getPersistedProperty(PostHogPersistedProperty.InstalledAppVersion)
const appBuild = this._appProperties.$app_build
const appVersion = this._appProperties.$app_version

if (!appBuild || !appVersion) {
console.warn(
'PostHog could not track installation/update/open, as the build and version were not set. ' +
'This can happen if some dependencies are not installed correctly, or if you have provided' +
'customAppProperties but not included $app_build or $app_version.'
)
}
if (appBuild) {
if (!prevAppBuild) {
// new app install
this.capture('Application Installed', {
version: appVersion,
build: appBuild,
})
} else if (prevAppBuild !== appBuild) {
// app updated
this.capture('Application Updated', {
previous_version: prevAppVersion,
previous_build: prevAppBuild,
version: appVersion,
build: appBuild,
})
}
}

const initialUrl = (await Linking.getInitialURL()) ?? undefined

this.capture('Application Opened', {
version: appVersion,
build: appBuild,
from_background: false,
url: initialUrl,
})

AppState.addEventListener('change', (state) => {
if (state === 'active') {
this.capture('Application Opened', {
version: appVersion,
build: appBuild,
from_background: true,
})
} else if (state === 'background') {
this.capture('Application Backgrounded')
}
})
}

async persistAppVersion(): Promise<void> {
const appBuild = this._appProperties.$app_build
const appVersion = this._appProperties.$app_version
this.setPersistedProperty(PostHogPersistedProperty.InstalledAppBuild, appBuild)
this.setPersistedProperty(PostHogPersistedProperty.InstalledAppVersion, appVersion)
}
}
191 changes: 190 additions & 1 deletion posthog-react-native/test/posthog.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { PostHogPersistedProperty } from 'posthog-core'
import { PostHog, PostHogCustomAsyncStorage } from '../index'
import { PostHog, PostHogCustomAsyncStorage, PostHogOptions } from '../index'
import { Linking, AppState, AppStateStatus } from 'react-native'
import { SemiAsyncStorage } from '../src/storage'

Linking.getInitialURL = jest.fn(() => Promise.resolve(null))
AppState.addEventListener = jest.fn()

describe('PostHog React Native', () => {
let mockStorage: PostHogCustomAsyncStorage
let mockSemiAsyncStorage: SemiAsyncStorage
let cache: any = {}

jest.setTimeout(500)
Expand Down Expand Up @@ -34,6 +40,7 @@ describe('PostHog React Native', () => {
cache[key] = value
},
}
mockSemiAsyncStorage = new SemiAsyncStorage(mockStorage)

PostHog._resetClientCache()
})
Expand Down Expand Up @@ -154,4 +161,186 @@ describe('PostHog React Native', () => {
expect(posthog.getPersistedProperty(PostHogPersistedProperty.Props)).toEqual(undefined)
})
})

describe('captureNativeAppLifecycleEvents', () => {
const createTestClient = async (
apiKey: string,
options?: Partial<PostHogOptions> | undefined,
onCapture?: jest.Mock | undefined
): Promise<PostHog> => {
const posthog = new PostHog(
apiKey,
{
...(options || {}),
persistence: 'file',
customAsyncStorage: mockStorage,
flushInterval: 0,
},
mockSemiAsyncStorage
)
if (onCapture) {
posthog.on('capture', onCapture)
}
// @ts-expect-error
await posthog._setupPromise
return posthog
}

it('should trigger an Application Installed event', async () => {
// arrange
const onCapture = jest.fn()

// act
posthog = await createTestClient(
'test-install',
{
captureNativeAppLifecycleEvents: true,
customAppProperties: {
$app_build: '1',
$app_version: '1.0.0',
},
},
onCapture
)

// assert
expect(onCapture).toHaveBeenCalledTimes(2)
expect(onCapture.mock.calls[0][0]).toMatchObject({
event: 'Application Installed',
properties: {
$app_build: '1',
$app_version: '1.0.0',
},
})
expect(onCapture.mock.calls[1][0]).toMatchObject({
event: 'Application Opened',
properties: {
$app_build: '1',
$app_version: '1.0.0',
from_background: false,
},
})
})
it('should trigger an Application Updated event', async () => {
// arrange
const onCapture = jest.fn()
posthog = await PostHog.initAsync('1', {
customAsyncStorage: mockStorage,
captureNativeAppLifecycleEvents: true,
customAppProperties: {
$app_build: '1',
$app_version: '1.0.0',
},
})

// act
posthog = await createTestClient(
'2',
{
captureNativeAppLifecycleEvents: true,
customAppProperties: {
$app_build: '2',
$app_version: '2.0.0',
},
},
onCapture
)

// assert
expect(onCapture).toHaveBeenCalledTimes(2)
expect(onCapture.mock.calls[0][0]).toMatchObject({
event: 'Application Updated',
properties: {
$app_build: '2',
$app_version: '2.0.0',
previous_build: '1',
previous_version: '1.0.0',
},
})
expect(onCapture.mock.calls[1][0]).toMatchObject({
event: 'Application Opened',
properties: {
$app_build: '2',
$app_version: '2.0.0',
from_background: false,
},
})
})
it('should include the initial url', async () => {
// arrange
Linking.getInitialURL = jest.fn(() => Promise.resolve('https://example.com'))
const onCapture = jest.fn()
posthog = await createTestClient('test-open', {
captureNativeAppLifecycleEvents: true,
customAppProperties: {
$app_build: '1',
$app_version: '1.0.0',
},
})

// act
posthog = await createTestClient(
'test-open2',
{
captureNativeAppLifecycleEvents: true,
customAppProperties: {
$app_build: '1',
$app_version: '1.0.0',
},
},
onCapture
)

// assert
expect(onCapture).toHaveBeenCalledTimes(1)
expect(onCapture.mock.calls[0][0]).toMatchObject({
event: 'Application Opened',
properties: {
$app_build: '1',
$app_version: '1.0.0',
from_background: false,
url: 'https://example.com',
},
})
})

it('should track app background and foreground', async () => {
// arrange
const onCapture = jest.fn()
posthog = await createTestClient(
'test-change',
{
captureNativeAppLifecycleEvents: true,
customAppProperties: {
$app_build: '1',
$app_version: '1.0.0',
},
},
onCapture
)
const cb: (state: AppStateStatus) => void = (AppState.addEventListener as jest.Mock).mock.calls[1][1]

// act
cb('background')
cb('active')

// assert
expect(onCapture).toHaveBeenCalledTimes(4)
expect(onCapture.mock.calls[2][0]).toMatchObject({
event: 'Application Backgrounded',
properties: {
$app_build: '1',
$app_version: '1.0.0',
},
})
expect(onCapture.mock.calls[3][0]).toMatchObject({
event: 'Application Opened',
properties: {
$app_build: '1',
$app_version: '1.0.0',
from_background: true,
},
})
})
})
})
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"baseUrl": "./", // this must be specified if "paths" is specified.
"rootDir": "./",
"lib": ["ES2015", "ES2022.Error"],
"types": ["node", "jest"],
"jsx": "react"
},
"exclude": ["**/dist/**", "node_modules", "dist", "**/*.spec.*"]
Expand Down

0 comments on commit 4a707fd

Please sign in to comment.