Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: ref to allow sync storage friendlier #198

Merged
merged 15 commits into from
Mar 4, 2024
2 changes: 1 addition & 1 deletion posthog-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -66,7 +66,7 @@ export abstract class PostHogCoreStateless {
protected _events = new SimpleEventEmitter()
protected _flushTimer?: any
protected _retryOptions: RetriableOptions
protected _initPromise: Promise<any>
protected _initPromise: Promise<void>
protected _isInitialized: boolean = false

// Abstract methods to be overridden by implementations
5 changes: 5 additions & 0 deletions posthog-core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -53,3 +53,8 @@ export function safeSetTimeout(fn: () => void, timeout: number): any {
t?.unref && t?.unref()
return t
}

// NOTE: We opt for this slightly imperfect check as the global "Promise" object can get mutated in certain environments
export const isPromise = (obj: any): obj is Promise<any> => {
return obj && typeof obj.then === 'function'
}
1 change: 1 addition & 0 deletions posthog-react-native/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@
- Many methods such as `capture` and `identify` no longer return the `this` object instead returning nothing
- Fixes some typos in types
- `shutdown` and `shutdownAsync` takes a `shutdownTimeoutMs` param with a default of 30000 (30s). This is the time to wait for flushing events before shutting down the client. If the timeout is reached, the client will be shut down regardless of pending events.
- Replaces the option `customAsyncStorage` with `customStorage` to allow for custom synchronous or asynchronous storage implementations.

# 2.11.6 - 2024-02-22

4 changes: 2 additions & 2 deletions posthog-react-native/src/native-deps.tsx
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { OptionalExpoDevice } from './optional/OptionalExpoDevice'
import { OptionalExpoFileSystem } from './optional/OptionalExpoFileSystem'
import { OptionalExpoLocalization } from './optional/OptionalExpoLocalization'
import { OptionalReactNativeDeviceInfo } from './optional/OptionalReactNativeDeviceInfo'
import { PostHogCustomAppProperties, PostHogCustomAsyncStorage } from './types'
import { PostHogCustomAppProperties, PostHogCustomStorage } from './types'

export const getAppProperties = (): PostHogCustomAppProperties => {
let deviceType = 'Mobile'
@@ -63,7 +63,7 @@ const returnPropertyIfNotUnknown = (value: string | null): string | null => {
return null
}

export const buildOptimisiticAsyncStorage = (): PostHogCustomAsyncStorage => {
export const buildOptimisiticAsyncStorage = (): PostHogCustomStorage => {
if (OptionalExpoFileSystem) {
const filesystem = OptionalExpoFileSystem
return {
89 changes: 53 additions & 36 deletions posthog-react-native/src/posthog-rn.ts
Original file line number Diff line number Diff line change
@@ -8,28 +8,39 @@ import {
PostHogFetchResponse,
PostHogPersistedProperty,
} from '../../posthog-core/src'
import { PostHogMemoryStorage } from '../../posthog-core/src/storage-memory'
import { getLegacyValues } from './legacy'
import { SemiAsyncStorage } from './storage'
import { PostHogRNStorage, PostHogRNSyncMemoryStorage } from './storage'
import { version } from './version'
import { buildOptimisiticAsyncStorage, getAppProperties } from './native-deps'
import { PostHogAutocaptureOptions, PostHogCustomAppProperties, PostHogCustomAsyncStorage } from './types'
import { PostHogAutocaptureOptions, PostHogCustomAppProperties, PostHogCustomStorage } from './types'
import { withReactNativeNavigation } from './frameworks/wix-navigation'

export type PostHogOptions = PostHogCoreOptions & {
/** Allows you to provide the storage type. By default 'file'.
* 'file' will try to load the best available storage, the provided 'customStorage', 'customAsyncStorage' or in-memory storage.
*/
persistence?: 'memory' | 'file'
/** Allows you to provide your own implementation of the common information about your App or a function to modify the default App properties generated */
/** Allows you to provide your own implementation of the common information about your App or a function to modify the default App properties generated */
customAppProperties?:
| PostHogCustomAppProperties
| ((properties: PostHogCustomAppProperties) => PostHogCustomAppProperties)
customAsyncStorage?: PostHogCustomAsyncStorage
/** Allows you to provide a custom asynchronous storage such as async-storage, expo-file-system or a synchronous storage such as mmkv.
* If not provided, PostHog will attempt to use the best available storage via optional peer dependencies (async-storage, expo-file-system).
* If `persistence` is set to 'memory', this option will be ignored.
*/
customStorage?: PostHogCustomStorage

// customAsyncStorage?: PostHogCustomAsyncStorage
/** Captures native app lifecycle events such as Application Installed, Application Updated, Application Opened and Application Backgrounded.
* By default is false.
* If you're already using the 'captureLifecycleEvents' options with 'withReactNativeNavigation' or 'PostHogProvider, you should not set this to true, otherwise you may see duplicated events.
*/
captureNativeAppLifecycleEvents?: boolean
}

export class PostHog extends PostHogCore {
private _persistence: PostHogOptions['persistence']
private _memoryStorage = new PostHogMemoryStorage()
private _semiAsyncStorage?: SemiAsyncStorage
private _storage: PostHogRNStorage
private _appProperties: PostHogCustomAppProperties = {}

constructor(apiKey: string, options?: PostHogOptions) {
@@ -47,31 +58,33 @@ export class PostHog extends PostHogCore {
this.flush()
})

let storagePromise: Promise<void> | undefined

if (this._persistence === 'file') {
this._semiAsyncStorage = new SemiAsyncStorage(options?.customAsyncStorage || buildOptimisiticAsyncStorage())
this._storage = new PostHogRNStorage(options?.customStorage ?? buildOptimisiticAsyncStorage())
storagePromise = this._storage.preloadPromise
} else {
this._storage = new PostHogRNSyncMemoryStorage()
}

// Ensure the async storage has been preloaded (this call is cached)
const setupAsync = async (): Promise<void> => {
if (!this._semiAsyncStorage?.isPreloaded) {
await this._semiAsyncStorage?.preloadAsync()
}
this.setupBootstrap(options)

// It is possible that the old library was used so we try to get the legacy distinctID
if (!this._semiAsyncStorage?.getItem(PostHogPersistedProperty.AnonymousId)) {
const legacyValues = await getLegacyValues()
if (legacyValues?.distinctId) {
this._semiAsyncStorage?.setItem(PostHogPersistedProperty.DistinctId, legacyValues.distinctId)
this._semiAsyncStorage?.setItem(PostHogPersistedProperty.AnonymousId, legacyValues.anonymousId)
if (storagePromise) {
storagePromise.then(() => {
// This code is for migrating from V1 to V2 and tries its best to keep the existing anon/distinctIds
// It only applies for async storage
if (!this._storage.getItem(PostHogPersistedProperty.AnonymousId)) {
void getLegacyValues().then((legacyValues) => {
if (legacyValues?.distinctId) {
this._storage?.setItem(PostHogPersistedProperty.DistinctId, legacyValues.distinctId)
this._storage?.setItem(PostHogPersistedProperty.AnonymousId, legacyValues.anonymousId)
}
})
}
}
})
}

this._initPromise = setupAsync()
const initAfterStorage = (): void => {
this.setupBootstrap(options)

// Not everything needs to be in the init promise
this._initPromise.then(async () => {
this._isInitialized = true
if (options?.preloadFeatureFlags !== false) {
this.reloadFeatureFlags()
@@ -83,12 +96,21 @@ export class PostHog extends PostHogCore {
'PostHog was initialised with persistence set to "memory", capturing native app events is not supported.'
)
} else {
await this.captureNativeAppLifecycleEvents()
void this.captureNativeAppLifecycleEvents()
}
}

await this.persistAppVersion()
})
void this.persistAppVersion()
}

// For async storage, we wait for the storage to be ready before we start the SDK
// For sync storage we can start the SDK immediately
if (storagePromise) {
this._initPromise = storagePromise.then(initAfterStorage)
} else {
this._initPromise = Promise.resolve()
initAfterStorage()
}
}

// NOTE: This is purely a helper method for testing purposes or those who wish to be certain the SDK is fully initialised
@@ -97,16 +119,11 @@ export class PostHog extends PostHogCore {
}

getPersistedProperty<T>(key: PostHogPersistedProperty): T | undefined {
return this._semiAsyncStorage
? this._semiAsyncStorage.getItem(key) ?? undefined
: this._memoryStorage.getProperty(key)
return this._storage.getItem(key) as T | undefined
}

setPersistedProperty<T>(key: PostHogPersistedProperty, value: T | null): void {
return this._semiAsyncStorage
? value !== null
? this._semiAsyncStorage.setItem(key, value)
: this._semiAsyncStorage.removeItem(key)
: this._memoryStorage.setProperty(key, value)
return value !== null ? this._storage.setItem(key, value) : this._storage.removeItem(key)
}

fetch(url: string, options: PostHogFetchOptions): Promise<PostHogFetchResponse> {
103 changes: 56 additions & 47 deletions posthog-react-native/src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,90 @@
import { PostHogCustomAsyncStorage } from './types'
import { isPromise } from '../../posthog-core/src/utils'
import { PostHogCustomStorage } from './types'

const POSTHOG_STORAGE_KEY = '.posthog-rn.json'
const POSTHOG_STORAGE_VERSION = 'v1'

type PostHogStorageContents = { [key: string]: any }

// NOTE: The core prefers a synchronous storage so we mimic this by pre-loading all keys
export class SemiAsyncStorage {
private _memoryCache: PostHogStorageContents = {}
private _preloadSemiAsyncStoragePromise: Promise<void> | undefined
private _asyncStorage: PostHogCustomAsyncStorage
public isPreloaded = false
export class PostHogRNStorage {
memoryCache: PostHogStorageContents = {}
storage: PostHogCustomStorage
preloadPromise: Promise<void> | undefined

constructor(asyncStorage: PostHogCustomAsyncStorage) {
this._asyncStorage = asyncStorage
}
constructor(storage: PostHogCustomStorage) {
this.storage = storage

preloadAsync(): Promise<void> {
if (this.isPreloaded) {
return Promise.resolve()
}
const preloadResult = this.storage.getItem(POSTHOG_STORAGE_KEY)

if (this._preloadSemiAsyncStoragePromise) {
return this._preloadSemiAsyncStoragePromise
}
if (isPromise(preloadResult)) {
this.preloadPromise = preloadResult.then((res) => {
this.populateMemoryCache(res)
})

this._preloadSemiAsyncStoragePromise = this._asyncStorage.getItem(POSTHOG_STORAGE_KEY).then((res) => {
try {
const data = res ? JSON.parse(res).content : {}

for (const key in data) {
this._memoryCache[key] = data[key]
}
} catch (e) {
console.warn(
"PostHog failed to load persisted data from storage. This is likely because the storage format is. We'll reset the storage.",
e
)
} finally {
this.isPreloaded = true
}
})

this._preloadSemiAsyncStoragePromise.finally(() => {
this._preloadSemiAsyncStoragePromise = undefined
})

return this._preloadSemiAsyncStoragePromise
this.preloadPromise?.finally(() => {
this.preloadPromise = undefined
})
} else {
this.populateMemoryCache(preloadResult)
}
}

persist(): void {
const payload = {
version: POSTHOG_STORAGE_VERSION,
content: this._memoryCache,
content: this.memoryCache,
}

void this._asyncStorage.setItem(POSTHOG_STORAGE_KEY, JSON.stringify(payload))
void this.storage.setItem(POSTHOG_STORAGE_KEY, JSON.stringify(payload))
}

getItem(key: string): any | null | undefined {
return this._memoryCache[key]
return this.memoryCache[key]
}
setItem(key: string, value: any): void {
this._memoryCache[key] = value
this.memoryCache[key] = value
this.persist()
}
removeItem(key: string): void {
delete this._memoryCache[key]
delete this.memoryCache[key]
this.persist()
}
clear(): void {
for (const key in this._memoryCache) {
delete this._memoryCache[key]
for (const key in this.memoryCache) {
delete this.memoryCache[key]
}
this.persist()
}
getAllKeys(): readonly string[] {
return Object.keys(this._memoryCache)
return Object.keys(this.memoryCache)
}

populateMemoryCache(res: string | null): void {
try {
const data = res ? JSON.parse(res).content : {}

for (const key in data) {
this.memoryCache[key] = data[key]
}
} catch (e) {
console.warn(
"PostHog failed to load persisted data from storage. This is likely because the storage format is. We'll reset the storage.",
e
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
)
}
}
}

export class PostHogRNSyncMemoryStorage extends PostHogRNStorage {
constructor() {
const cache: { [key: string]: any | undefined } = {}
const storage = {
getItem: (key: string) => cache[key],
setItem: (key: string, value: string) => {
cache[key] = value
},
}

super(storage)
}
}
6 changes: 3 additions & 3 deletions posthog-react-native/src/types.ts
Original file line number Diff line number Diff line change
@@ -47,7 +47,7 @@ export interface PostHogCustomAppProperties {
$timezone?: string | null
}

export interface PostHogCustomAsyncStorage {
getItem: (key: string) => Promise<string | null>
setItem: (key: string, value: string) => Promise<void>
export interface PostHogCustomStorage {
getItem: (key: string) => string | null | Promise<string | null>
setItem: (key: string, value: string) => void | Promise<void>
}
Loading