taro-posthog-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,467 @@
1
+ import Taro from '@tarojs/taro'
2
+ import { version } from './version'
3
+ import type {
4
+ CaptureOptions,
5
+ CaptureResult,
6
+ PageviewRoute,
7
+ PageviewTrackingOptions,
8
+ PersistedState,
9
+ PostHogMiniProgramOptions,
10
+ Properties,
11
+ TaroPage,
12
+ } from './types'
13
+
14
+ declare const setTimeout: (handler: () => void, timeout?: number) => unknown
15
+ declare const clearTimeout: (timeoutId: unknown) => void
16
+ declare const console: { log: (message?: unknown, ...optionalParams: unknown[]) => void }
17
+ declare const getCurrentPages: (() => TaroPage[]) | undefined
18
+
19
+ export * from './types'
20
+
21
+ const DEFAULT_HOST = 'https://us.i.posthog.com'
22
+ const DEFAULT_FLUSH_AT = 20
23
+ const DEFAULT_FLUSH_INTERVAL = 10000
24
+ const DEFAULT_REQUEST_TIMEOUT = 10000
25
+ const LIBRARY = 'miniprogram'
26
+
27
+ type TaroWithRouteEvents = typeof Taro & {
28
+ onAppRoute?: (callback: (route: PageviewRoute) => void) => void
29
+ offAppRoute?: (callback: (route: PageviewRoute) => void) => void
30
+ }
31
+
32
+ const taro = Taro as TaroWithRouteEvents
33
+
34
+ const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, '')
35
+
36
+ const now = (): string => new Date().toISOString()
37
+
38
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
39
+ !!value && typeof value === 'object' && !Array.isArray(value)
40
+
41
+ const stringifyQuery = (query?: PageviewRoute['query']): string => {
42
+ if (!query) {
43
+ return ''
44
+ }
45
+
46
+ const parts = Object.entries(query)
47
+ .filter(([, value]) => value !== undefined)
48
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
49
+
50
+ return parts.length ? `?${parts.join('&')}` : ''
51
+ }
52
+
53
+ const createUuid = (): string => {
54
+ const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
55
+ return template.replace(/[xy]/g, (char) => {
56
+ const random = Math.floor(Math.random() * 16)
57
+ const value = char === 'x' ? random : (random & 0x3) | 0x8
58
+ return value.toString(16)
59
+ })
60
+ }
61
+
62
+ const getCurrentTaroPages = (taro?: TaroWithRouteEvents): TaroPage[] => {
63
+ try {
64
+ if (typeof taro?.getCurrentPages === 'function') {
65
+ return taro.getCurrentPages() as TaroPage[]
66
+ }
67
+ return typeof getCurrentPages === 'function' ? getCurrentPages() : []
68
+ } catch {
69
+ return []
70
+ }
71
+ }
72
+
73
+ const normalizeRoute = (route?: PageviewRoute, taro?: TaroWithRouteEvents): PageviewRoute => {
74
+ if (route?.path || route?.route) {
75
+ return route
76
+ }
77
+
78
+ const pages = getCurrentTaroPages(taro)
79
+ const currentPage: TaroPage | undefined = pages[pages.length - 1]
80
+ const path = currentPage?.route || currentPage?.path || currentPage?.$taroPath
81
+
82
+ return {
83
+ path,
84
+ route: path,
85
+ query: currentPage?.options,
86
+ }
87
+ }
88
+
89
+ const getString = (value: unknown): string | undefined => (typeof value === 'string' && value ? value : undefined)
90
+
91
+ const getNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined)
92
+
93
+ export class PostHogMiniProgram {
94
+ private readonly apiKey: string
95
+ private readonly host: string
96
+ private readonly flushAt: number
97
+ private readonly flushInterval: number
98
+ private readonly requestTimeout: number
99
+ private readonly storageKey: string
100
+ private readonly options: PostHogMiniProgramOptions
101
+ private state: PersistedState = {}
102
+ private flushTimer?: ReturnType<typeof setTimeout>
103
+ private flushPromise?: Promise<void>
104
+ private lastPagePath?: string
105
+ private systemInfoProperties?: Properties
106
+ private disabled = false
107
+ private appRouteHandler?: (route: PageviewRoute) => void
108
+ private appHideHandler?: () => void
109
+
110
+ constructor(apiKey: string, options: PostHogMiniProgramOptions = {}) {
111
+ this.apiKey = (apiKey || '').trim()
112
+ this.options = options
113
+ this.host = trimTrailingSlash(options.api_host || DEFAULT_HOST)
114
+ this.flushAt = options.flush_at ?? DEFAULT_FLUSH_AT
115
+ this.flushInterval = options.flush_interval ?? DEFAULT_FLUSH_INTERVAL
116
+ this.requestTimeout = options.request_timeout ?? DEFAULT_REQUEST_TIMEOUT
117
+ this.storageKey = `ph_${options.persistence_name || this.apiKey}_miniprogram`
118
+ this.disabled = !this.apiKey
119
+
120
+ this.state = this.readState()
121
+ this.setupIdentity()
122
+ this.persistState()
123
+
124
+ if (options.capture_pageview !== false) {
125
+ this.installPageviewTracking()
126
+ }
127
+
128
+ this.appHideHandler = () => {
129
+ void this.flush().catch(() => undefined)
130
+ }
131
+ taro.onAppHide?.(this.appHideHandler)
132
+
133
+ options.loaded?.(this)
134
+ }
135
+
136
+ capture(event: string, properties: Properties = {}, options: CaptureOptions = {}): CaptureResult | undefined {
137
+ if (this.disabled || !event) {
138
+ return
139
+ }
140
+
141
+ const eventPayload: CaptureResult = {
142
+ event,
143
+ properties: {
144
+ ...(this.state.super_properties || {}),
145
+ ...this.getSystemInfoProperties(),
146
+ ...properties,
147
+ distinct_id: this.getDistinctId(),
148
+ $device_id: this.getDeviceId(),
149
+ $lib: LIBRARY,
150
+ $lib_version: version,
151
+ ...((options.disable_geoip ?? this.options.disable_geoip) ? { $geoip_disable: true } : {}),
152
+ },
153
+ distinct_id: this.getDistinctId(),
154
+ timestamp: this.normalizeTimestamp(options.timestamp),
155
+ uuid: options.uuid || createUuid(),
156
+ type: 'capture',
157
+ library: LIBRARY,
158
+ library_version: version,
159
+ }
160
+
161
+ const preparedEvent = this.options.before_send ? this.options.before_send(eventPayload) : eventPayload
162
+ if (!preparedEvent) {
163
+ return
164
+ }
165
+
166
+ this.enqueue(preparedEvent)
167
+
168
+ if (options.send_instantly || this.getQueue().length >= this.flushAt) {
169
+ void this.flush().catch((error) => this.log('flush failed', error))
170
+ } else {
171
+ this.scheduleFlush()
172
+ }
173
+
174
+ return preparedEvent
175
+ }
176
+
177
+ capturePageview(route?: PageviewRoute, properties: Properties = {}): CaptureResult | undefined {
178
+ return this.captureScreen(route, properties)
179
+ }
180
+
181
+ captureScreen(route?: PageviewRoute, properties: Properties = {}): CaptureResult | undefined {
182
+ const normalizedRoute = normalizeRoute(route, taro)
183
+ const path = normalizedRoute.path || normalizedRoute.route || ''
184
+ const query = stringifyQuery(normalizedRoute.query)
185
+ const currentUrl = `${path}${query}`
186
+
187
+ if (currentUrl && currentUrl === this.lastPagePath) {
188
+ return
189
+ }
190
+
191
+ const pageviewProperties: Properties = {
192
+ $current_url: currentUrl,
193
+ $pathname: path,
194
+ $referrer: '$direct',
195
+ $screen_name: currentUrl || path,
196
+ taro_open_type: normalizedRoute.openType,
197
+ ...(this.options.get_pageview_properties?.(normalizedRoute) || {}),
198
+ ...properties,
199
+ }
200
+
201
+ this.lastPagePath = currentUrl
202
+ return this.capture('$screen', pageviewProperties)
203
+ }
204
+
205
+ identify(distinctId: string, userPropertiesToSet?: Properties, userPropertiesToSetOnce?: Properties): void {
206
+ if (!distinctId) {
207
+ return
208
+ }
209
+
210
+ const previousDistinctId = this.getDistinctId()
211
+ this.state.distinct_id = distinctId
212
+ this.persistState()
213
+
214
+ if (previousDistinctId !== distinctId) {
215
+ this.capture('$identify', {
216
+ distinct_id: distinctId,
217
+ $anon_distinct_id: previousDistinctId,
218
+ $set: userPropertiesToSet,
219
+ $set_once: userPropertiesToSetOnce,
220
+ })
221
+ }
222
+ }
223
+
224
+ alias(alias: string, distinctId: string = this.getDistinctId()): void {
225
+ if (!alias) {
226
+ return
227
+ }
228
+
229
+ this.capture('$create_alias', {
230
+ alias,
231
+ distinct_id: distinctId,
232
+ })
233
+ }
234
+
235
+ register(properties: Properties): void {
236
+ this.state.super_properties = {
237
+ ...(this.state.super_properties || {}),
238
+ ...properties,
239
+ }
240
+ this.persistState()
241
+ }
242
+
243
+ unregister(property: string): void {
244
+ if (!this.state.super_properties) {
245
+ return
246
+ }
247
+
248
+ delete this.state.super_properties[property]
249
+ this.persistState()
250
+ }
251
+
252
+ reset(): void {
253
+ const queue = this.getQueue()
254
+ const anonymousId = createUuid()
255
+ this.state = {
256
+ anonymous_id: anonymousId,
257
+ distinct_id: anonymousId,
258
+ device_id: anonymousId,
259
+ queue,
260
+ }
261
+ this.persistState()
262
+ }
263
+
264
+ getDistinctId(): string {
265
+ return this.state.distinct_id || this.state.anonymous_id || ''
266
+ }
267
+
268
+ getDeviceId(): string {
269
+ return this.state.device_id || this.getDistinctId()
270
+ }
271
+
272
+ installPageviewTracking(options: PageviewTrackingOptions = {}): void {
273
+ if (options.capture_initial !== false) {
274
+ setTimeout(() => this.captureScreen(), 0)
275
+ }
276
+
277
+ if (!taro.onAppRoute || this.appRouteHandler) {
278
+ return
279
+ }
280
+
281
+ this.appRouteHandler = (route: PageviewRoute) => {
282
+ this.captureScreen(route)
283
+ }
284
+ taro.onAppRoute(this.appRouteHandler)
285
+ }
286
+
287
+ async flush(): Promise<void> {
288
+ if (this.disabled) {
289
+ return
290
+ }
291
+
292
+ if (this.flushPromise) {
293
+ return this.flushPromise
294
+ }
295
+
296
+ this.clearFlushTimer()
297
+ this.flushPromise = this.flushQueue().finally(() => {
298
+ this.flushPromise = undefined
299
+ })
300
+
301
+ return this.flushPromise
302
+ }
303
+
304
+ stop(): void {
305
+ this.clearFlushTimer()
306
+
307
+ if (this.appRouteHandler) {
308
+ taro.offAppRoute?.(this.appRouteHandler)
309
+ this.appRouteHandler = undefined
310
+ }
311
+
312
+ if (this.appHideHandler) {
313
+ taro.offAppHide?.(this.appHideHandler)
314
+ this.appHideHandler = undefined
315
+ }
316
+ }
317
+
318
+ private setupIdentity(): void {
319
+ if (this.state.distinct_id) {
320
+ return
321
+ }
322
+
323
+ const bootstrapDistinctId = this.options.bootstrap?.distinctID
324
+ const anonymousId =
325
+ bootstrapDistinctId && !this.options.bootstrap?.isIdentifiedID ? bootstrapDistinctId : createUuid()
326
+
327
+ this.state.anonymous_id = this.state.anonymous_id || anonymousId
328
+ this.state.device_id = this.state.device_id || this.state.anonymous_id
329
+ this.state.distinct_id = bootstrapDistinctId || this.state.anonymous_id
330
+ }
331
+
332
+ private normalizeTimestamp(timestamp?: Date | string): string {
333
+ if (timestamp instanceof Date) {
334
+ return timestamp.toISOString()
335
+ }
336
+
337
+ return timestamp || now()
338
+ }
339
+
340
+ private getSystemInfoProperties(): Properties {
341
+ if (this.systemInfoProperties) {
342
+ return this.systemInfoProperties
343
+ }
344
+
345
+ let systemInfo: Record<string, unknown> = {}
346
+ try {
347
+ systemInfo = (taro.getSystemInfoSync?.() || {}) as unknown as Record<string, unknown>
348
+ } catch {
349
+ systemInfo = {}
350
+ }
351
+
352
+ this.systemInfoProperties = {
353
+ $screen_height: getNumber(systemInfo.screenHeight),
354
+ $screen_width: getNumber(systemInfo.screenWidth),
355
+ $viewport_height: getNumber(systemInfo.windowHeight),
356
+ $viewport_width: getNumber(systemInfo.windowWidth),
357
+ $device_model: getString(systemInfo.model),
358
+ $device_manufacturer: getString(systemInfo.brand),
359
+ $os: getString(systemInfo.platform),
360
+ $os_version: getString(systemInfo.system),
361
+ taro_pixel_ratio: getNumber(systemInfo.pixelRatio),
362
+ taro_sdk_version: getString(systemInfo.SDKVersion),
363
+ taro_language: getString(systemInfo.language),
364
+ taro_theme: getString(systemInfo.theme),
365
+ }
366
+
367
+ return this.systemInfoProperties
368
+ }
369
+
370
+ private enqueue(event: CaptureResult): void {
371
+ this.state.queue = [...this.getQueue(), event]
372
+ this.persistState()
373
+ this.log('queued event', event)
374
+ }
375
+
376
+ private getQueue(): CaptureResult[] {
377
+ return this.state.queue || []
378
+ }
379
+
380
+ private scheduleFlush(): void {
381
+ if (this.flushTimer || this.flushInterval <= 0) {
382
+ return
383
+ }
384
+
385
+ this.flushTimer = setTimeout(() => {
386
+ void this.flush().catch((error) => this.log('flush failed', error))
387
+ }, this.flushInterval)
388
+ }
389
+
390
+ private clearFlushTimer(): void {
391
+ if (this.flushTimer) {
392
+ clearTimeout(this.flushTimer)
393
+ this.flushTimer = undefined
394
+ }
395
+ }
396
+
397
+ private async flushQueue(): Promise<void> {
398
+ while (this.getQueue().length > 0) {
399
+ const batch = this.getQueue().slice(0, this.flushAt)
400
+
401
+ await this.sendBatch(batch)
402
+
403
+ this.state.queue = this.getQueue().slice(batch.length)
404
+ this.persistState()
405
+ }
406
+ }
407
+
408
+ private sendBatch(batch: CaptureResult[]): Promise<void> {
409
+ return new Promise((resolve, reject) => {
410
+ taro.request({
411
+ url: `${this.host}/batch/`,
412
+ method: 'POST',
413
+ data: {
414
+ api_key: this.apiKey,
415
+ batch,
416
+ sent_at: now(),
417
+ },
418
+ header: {
419
+ 'Content-Type': 'application/json',
420
+ },
421
+ timeout: this.requestTimeout,
422
+ success: (result) => {
423
+ if (result.statusCode >= 200 && result.statusCode < 300) {
424
+ resolve()
425
+ } else {
426
+ reject(new Error(`PostHog request failed with status ${result.statusCode}`))
427
+ }
428
+ },
429
+ fail: reject,
430
+ })
431
+ })
432
+ }
433
+
434
+ private readState(): PersistedState {
435
+ try {
436
+ const value = taro.getStorageSync?.(this.storageKey)
437
+ if (typeof value === 'string') {
438
+ const parsed: unknown = JSON.parse(value)
439
+ return isRecord(parsed) ? (parsed as PersistedState) : {}
440
+ }
441
+
442
+ return isRecord(value) ? (value as PersistedState) : {}
443
+ } catch {
444
+ return {}
445
+ }
446
+ }
447
+
448
+ private persistState(): void {
449
+ try {
450
+ taro.setStorageSync?.(this.storageKey, JSON.stringify(this.state))
451
+ } catch {
452
+ return
453
+ }
454
+ }
455
+
456
+ private log(message: string, payload?: unknown): void {
457
+ if (this.options.debug) {
458
+ console.log(`[PostHog MiniProgram] ${message}`, payload)
459
+ }
460
+ }
461
+ }
462
+
463
+ export const init = (apiKey: string, options?: PostHogMiniProgramOptions): PostHogMiniProgram => {
464
+ return new PostHogMiniProgram(apiKey, options)
465
+ }
466
+
467
+ export default PostHogMiniProgram
package/src/types.ts ADDED
@@ -0,0 +1,76 @@
1
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue | undefined }
2
+
3
+ export type Properties = Record<string, JsonValue | undefined>
4
+
5
+ export interface CaptureOptions {
6
+ timestamp?: Date | string
7
+ uuid?: string
8
+ send_instantly?: boolean
9
+ disable_geoip?: boolean
10
+ }
11
+
12
+ export interface CaptureResult {
13
+ event: string
14
+ properties: Properties
15
+ distinct_id: string
16
+ timestamp: string
17
+ uuid: string
18
+ type: string
19
+ library: string
20
+ library_version: string
21
+ }
22
+
23
+ export type BeforeSendFn = (event: CaptureResult) => CaptureResult | null | undefined
24
+
25
+ export interface BootstrapOptions {
26
+ distinctID?: string
27
+ isIdentifiedID?: boolean
28
+ }
29
+
30
+ export interface PageviewRoute {
31
+ path?: string
32
+ route?: string
33
+ query?: Record<string, string | number | boolean | undefined>
34
+ openType?: string
35
+ }
36
+
37
+ export interface PageviewTrackingOptions {
38
+ capture_initial?: boolean
39
+ }
40
+
41
+ export interface PostHogMiniProgramOptions {
42
+ api_host?: string
43
+ flush_at?: number
44
+ flush_interval?: number
45
+ request_timeout?: number
46
+ persistence_name?: string
47
+ capture_pageview?: boolean
48
+ debug?: boolean
49
+ disable_geoip?: boolean
50
+ bootstrap?: BootstrapOptions
51
+ before_send?: BeforeSendFn
52
+ get_pageview_properties?: (route: PageviewRoute) => Properties
53
+ loaded?: (posthog: PostHogMiniProgramLike) => void
54
+ }
55
+
56
+ export interface PostHogMiniProgramLike {
57
+ capture(event: string, properties?: Properties, options?: CaptureOptions): CaptureResult | undefined
58
+ captureScreen(route?: PageviewRoute, properties?: Properties): CaptureResult | undefined
59
+ capturePageview(route?: PageviewRoute, properties?: Properties): CaptureResult | undefined
60
+ flush(): Promise<void>
61
+ }
62
+
63
+ export interface TaroPage {
64
+ route?: string
65
+ path?: string
66
+ $taroPath?: string
67
+ options?: Record<string, string | number | boolean | undefined>
68
+ }
69
+
70
+ export interface PersistedState {
71
+ distinct_id?: string
72
+ anonymous_id?: string
73
+ device_id?: string
74
+ super_properties?: Properties
75
+ queue?: CaptureResult[]
76
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const version = "1.0.0"