@kissmyappss/react-native 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +178 -2
- package/lib/client.d.ts +127 -0
- package/lib/client.js +511 -0
- package/lib/device.d.ts +2 -0
- package/lib/device.js +59 -0
- package/lib/index.d.ts +33 -0
- package/lib/index.js +89 -0
- package/lib/navigation.d.ts +10 -0
- package/lib/navigation.js +18 -0
- package/package.json +34 -4
- package/src/client.ts +598 -0
- package/src/device.ts +59 -0
- package/src/index.ts +88 -0
- package/src/navigation.ts +18 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// The KissMyApps app client, with no React Native in it: the app's storage, device and
|
|
2
|
+
// network come in through `Environment`, so the same code runs in tests and in Node.
|
|
3
|
+
// The Android and iOS SDKs follow the same rules; see integrations/analytics-mobile.
|
|
4
|
+
|
|
5
|
+
export const SDK_VERSION = '1.0.0'
|
|
6
|
+
export const DEFAULT_ENDPOINT = 'https://events.kissmyapps.dev'
|
|
7
|
+
|
|
8
|
+
export type Properties = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
/** AsyncStorage, MMKV or anything with the same two methods. */
|
|
11
|
+
export interface Storage {
|
|
12
|
+
getItem(key: string): Promise<string | null> | string | null
|
|
13
|
+
setItem(key: string, value: string): Promise<void> | void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DeviceInfo {
|
|
17
|
+
platform: 'ios' | 'android'
|
|
18
|
+
os_name: string
|
|
19
|
+
os_version: string
|
|
20
|
+
device_type: 'mobile' | 'tablet'
|
|
21
|
+
device_model?: string
|
|
22
|
+
screen_width: number
|
|
23
|
+
screen_height: number
|
|
24
|
+
timezone: string
|
|
25
|
+
language: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ClientOptions {
|
|
29
|
+
/** The project's client key, kma_pk_live_… or kma_pk_test_…. */
|
|
30
|
+
key: string
|
|
31
|
+
endpoint?: string
|
|
32
|
+
/** The app's version, such as 2.4.0, and build number, such as 120. */
|
|
33
|
+
appVersion?: string
|
|
34
|
+
build?: string
|
|
35
|
+
/** A session ends after this long in the background or without events. Default 30. */
|
|
36
|
+
sessionTimeoutMinutes?: number
|
|
37
|
+
/** How often queued events are sent while the app is open. Default 10. */
|
|
38
|
+
flushIntervalSeconds?: number
|
|
39
|
+
/** The most events kept while offline; the oldest go first. Default 1000. */
|
|
40
|
+
maxQueue?: number
|
|
41
|
+
/** Record installs, updates, opens and backgrounding. Default true. */
|
|
42
|
+
trackLifecycle?: boolean
|
|
43
|
+
debug?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Environment {
|
|
47
|
+
storage: Storage | null
|
|
48
|
+
device(): DeviceInfo
|
|
49
|
+
fetch: (url: string, init: RequestInit) => Promise<Pick<Response, 'ok' | 'status' | 'text'>>
|
|
50
|
+
sdk: string
|
|
51
|
+
now?: () => number
|
|
52
|
+
log?: (message: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Session {
|
|
56
|
+
id: string
|
|
57
|
+
started: number
|
|
58
|
+
last: number
|
|
59
|
+
firstScreen?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface State {
|
|
63
|
+
anonymousId: string
|
|
64
|
+
userId: string | null
|
|
65
|
+
traits: Properties | null
|
|
66
|
+
session: Session | null
|
|
67
|
+
optedOut: boolean
|
|
68
|
+
version?: string
|
|
69
|
+
build?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface QueuedEvent {
|
|
73
|
+
name: string
|
|
74
|
+
insert_id: string
|
|
75
|
+
time: string
|
|
76
|
+
screen?: string
|
|
77
|
+
properties: Properties
|
|
78
|
+
user_id: string | null
|
|
79
|
+
anonymous_id: string
|
|
80
|
+
session_id: string
|
|
81
|
+
session_started_at: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const EVENT_NAME = /^[A-Za-z][A-Za-z0-9_.:-]{0,79}$/
|
|
85
|
+
const KEY = /^kma_pk_(live|test)_[A-Za-z0-9]{20,64}$/
|
|
86
|
+
const BATCH_SIZE = 50
|
|
87
|
+
const FLUSH_AT = 30
|
|
88
|
+
const HEARTBEAT_MS = 60_000
|
|
89
|
+
const MAX_PROPERTIES_BYTES = 32_000
|
|
90
|
+
|
|
91
|
+
export function uuid(): string {
|
|
92
|
+
// Hermes has no crypto.randomUUID; a polyfill may add it.
|
|
93
|
+
const random = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
|
|
94
|
+
if (random?.randomUUID) return random.randomUUID()
|
|
95
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
96
|
+
const r = (Math.random() * 16) | 0
|
|
97
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class KmaClient {
|
|
102
|
+
readonly options: ClientOptions & { endpoint: string }
|
|
103
|
+
private readonly env: Environment
|
|
104
|
+
private readonly storageKey: string
|
|
105
|
+
private state: State
|
|
106
|
+
private queue: QueuedEvent[] = []
|
|
107
|
+
private screenName: string | null = null
|
|
108
|
+
private foregroundAt = 0
|
|
109
|
+
private loaded = false
|
|
110
|
+
private readonly waiting: (() => void)[] = []
|
|
111
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
112
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
113
|
+
private saveTimer: ReturnType<typeof setTimeout> | null = null
|
|
114
|
+
private inflight: Promise<void> | null = null
|
|
115
|
+
private failures = 0
|
|
116
|
+
private lastSent = 0
|
|
117
|
+
private identityPending = false
|
|
118
|
+
private inForeground = false
|
|
119
|
+
private firstLaunch = false
|
|
120
|
+
private stopped = false
|
|
121
|
+
/** Settles once saved state is read and the launch is recorded. */
|
|
122
|
+
readonly ready: Promise<void>
|
|
123
|
+
|
|
124
|
+
constructor(options: ClientOptions, env: Environment) {
|
|
125
|
+
if (!KEY.test(options.key ?? '')) {
|
|
126
|
+
throw new Error('KissMyApps: init needs a client key that starts with kma_pk_')
|
|
127
|
+
}
|
|
128
|
+
this.options = {
|
|
129
|
+
...options,
|
|
130
|
+
endpoint: (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, ''),
|
|
131
|
+
}
|
|
132
|
+
this.env = env
|
|
133
|
+
this.storageKey = `kma:${options.key.slice(-8)}`
|
|
134
|
+
this.state = {
|
|
135
|
+
anonymousId: uuid(),
|
|
136
|
+
userId: null,
|
|
137
|
+
traits: null,
|
|
138
|
+
session: null,
|
|
139
|
+
optedOut: false,
|
|
140
|
+
}
|
|
141
|
+
this.ready = this.load()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Public API ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** Records something a person did. */
|
|
147
|
+
track(name: string, properties: Properties = {}): void {
|
|
148
|
+
this.whenLoaded(() => {
|
|
149
|
+
if (this.stopped || this.state.optedOut) return
|
|
150
|
+
if (!EVENT_NAME.test(name)) {
|
|
151
|
+
this.warn(
|
|
152
|
+
`"${name}" is not a valid event name: use letters, numbers and _ . : - (up to 80).`,
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
this.enqueue(name, properties, this.ensureSession())
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Records a screen view; later events carry the screen's name. */
|
|
161
|
+
screen(name: string, properties: Properties = {}): void {
|
|
162
|
+
const screen = String(name ?? '')
|
|
163
|
+
.trim()
|
|
164
|
+
.slice(0, 300)
|
|
165
|
+
if (!screen) return this.warn('screen needs a name.')
|
|
166
|
+
this.whenLoaded(() => {
|
|
167
|
+
if (this.stopped || this.state.optedOut) return
|
|
168
|
+
const previous = this.screenName
|
|
169
|
+
this.screenName = screen
|
|
170
|
+
const session = this.ensureSession()
|
|
171
|
+
if (!session.firstScreen) {
|
|
172
|
+
session.firstScreen = screen
|
|
173
|
+
this.save()
|
|
174
|
+
}
|
|
175
|
+
this.enqueue(
|
|
176
|
+
'screen_viewed',
|
|
177
|
+
{ name: screen, ...(previous ? { previous } : {}), ...properties },
|
|
178
|
+
session,
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Ties this install to your own user ID, with optional traits such as email, name or plan. */
|
|
184
|
+
identify(userId: string | number, traits?: Properties): void {
|
|
185
|
+
const id = String(userId ?? '').trim()
|
|
186
|
+
if (!id || id.length > 200) return this.warn('identify needs a user ID of 1 to 200 characters.')
|
|
187
|
+
this.whenLoaded(() => {
|
|
188
|
+
this.state.userId = id
|
|
189
|
+
if (traits) this.state.traits = { ...(this.state.traits ?? {}), ...this.clean(traits) }
|
|
190
|
+
this.identityPending = true
|
|
191
|
+
this.save()
|
|
192
|
+
this.scheduleFlush(0)
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Updates traits for the current person without changing who they are. */
|
|
197
|
+
setTraits(traits: Properties): void {
|
|
198
|
+
this.whenLoaded(() => {
|
|
199
|
+
this.state.traits = { ...(this.state.traits ?? {}), ...this.clean(traits) }
|
|
200
|
+
this.identityPending = true
|
|
201
|
+
this.save()
|
|
202
|
+
this.scheduleFlush(0)
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Forgets the user, for example on sign out: what follows is a new person and session. */
|
|
207
|
+
reset(): void {
|
|
208
|
+
this.whenLoaded(() => {
|
|
209
|
+
void this.flush()
|
|
210
|
+
this.state.userId = null
|
|
211
|
+
this.state.traits = null
|
|
212
|
+
this.identityPending = false
|
|
213
|
+
this.state.anonymousId = uuid()
|
|
214
|
+
this.state.session = null
|
|
215
|
+
this.save()
|
|
216
|
+
if (this.inForeground) this.ensureSession()
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Stops recording and drops anything not sent yet, until optIn. Remembered across launches. */
|
|
221
|
+
optOut(): void {
|
|
222
|
+
this.whenLoaded(() => {
|
|
223
|
+
this.state.optedOut = true
|
|
224
|
+
this.queue = []
|
|
225
|
+
this.save()
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
optIn(): void {
|
|
230
|
+
this.whenLoaded(() => {
|
|
231
|
+
this.state.optedOut = false
|
|
232
|
+
this.save()
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getAnonymousId(): string {
|
|
237
|
+
return this.state.anonymousId
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
getUserId(): string | null {
|
|
241
|
+
return this.state.userId
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
getSessionId(): string | null {
|
|
245
|
+
return this.state.session?.id ?? null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Sends everything queued now. */
|
|
249
|
+
flush(): Promise<void> {
|
|
250
|
+
if (!this.loaded) return this.ready.then(() => this.flush())
|
|
251
|
+
if (this.inflight) return this.inflight
|
|
252
|
+
this.inflight = this.sendQueued().finally(() => {
|
|
253
|
+
this.inflight = null
|
|
254
|
+
})
|
|
255
|
+
return this.inflight
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Stops timers; the client records nothing more. */
|
|
259
|
+
shutdown(): void {
|
|
260
|
+
this.stopped = true
|
|
261
|
+
this.inForeground = false
|
|
262
|
+
if (this.flushTimer) clearTimeout(this.flushTimer)
|
|
263
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
264
|
+
if (this.saveTimer) {
|
|
265
|
+
clearTimeout(this.saveTimer)
|
|
266
|
+
void this.writeNow()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// App lifecycle, called by the platform layer ------------------------------------------
|
|
271
|
+
|
|
272
|
+
/** The app came to the front. The first call is the launch. */
|
|
273
|
+
foreground(): void {
|
|
274
|
+
this.whenLoaded(() => {
|
|
275
|
+
if (this.stopped || this.inForeground) return
|
|
276
|
+
const launch = this.foregroundAt === 0
|
|
277
|
+
this.inForeground = true
|
|
278
|
+
this.foregroundAt = this.now()
|
|
279
|
+
if (this.state.optedOut) return
|
|
280
|
+
const session = this.ensureSession()
|
|
281
|
+
if (this.options.trackLifecycle !== false) {
|
|
282
|
+
if (launch) this.recordInstallOrUpdate(session)
|
|
283
|
+
this.enqueue('app_opened', { from_background: !launch }, session)
|
|
284
|
+
}
|
|
285
|
+
this.heartbeatTimer ??= setInterval(() => this.heartbeat(), HEARTBEAT_MS)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The app went to the background: record it and send what is queued while it can. */
|
|
290
|
+
background(): void {
|
|
291
|
+
this.whenLoaded(() => {
|
|
292
|
+
if (this.stopped || !this.inForeground) return
|
|
293
|
+
this.inForeground = false
|
|
294
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
295
|
+
this.heartbeatTimer = null
|
|
296
|
+
if (this.state.optedOut) return
|
|
297
|
+
const session = this.state.session
|
|
298
|
+
if (session && this.options.trackLifecycle !== false) {
|
|
299
|
+
const seconds = Math.round((this.now() - this.foregroundAt) / 1000)
|
|
300
|
+
this.enqueue('app_backgrounded', { seconds_in_foreground: Math.max(0, seconds) }, session)
|
|
301
|
+
}
|
|
302
|
+
if (session) session.last = this.now()
|
|
303
|
+
void this.writeNow()
|
|
304
|
+
void this.flush()
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// State --------------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
private async load(): Promise<void> {
|
|
311
|
+
const storage = this.env.storage
|
|
312
|
+
if (!storage) this.warn('No storage was given, so each launch counts as a new install.')
|
|
313
|
+
let saved: Partial<State> | null = null
|
|
314
|
+
let queue: QueuedEvent[] = []
|
|
315
|
+
try {
|
|
316
|
+
const raw = await storage?.getItem(this.storageKey)
|
|
317
|
+
saved = raw ? (JSON.parse(raw) as Partial<State>) : null
|
|
318
|
+
const rawQueue = await storage?.getItem(`${this.storageKey}:queue`)
|
|
319
|
+
queue = rawQueue ? (JSON.parse(rawQueue) as QueuedEvent[]) : []
|
|
320
|
+
} catch {
|
|
321
|
+
saved = null
|
|
322
|
+
}
|
|
323
|
+
if (saved?.anonymousId) {
|
|
324
|
+
this.state = {
|
|
325
|
+
anonymousId: saved.anonymousId,
|
|
326
|
+
userId: saved.userId ?? null,
|
|
327
|
+
traits: saved.traits ?? null,
|
|
328
|
+
session: saved.session ?? null,
|
|
329
|
+
optedOut: saved.optedOut === true,
|
|
330
|
+
version: saved.version,
|
|
331
|
+
build: saved.build,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Nothing saved means a first launch, or the app's data was cleared.
|
|
335
|
+
this.firstLaunch = !saved?.anonymousId
|
|
336
|
+
this.queue = Array.isArray(queue) ? queue : []
|
|
337
|
+
this.identityPending = Boolean(this.state.traits)
|
|
338
|
+
this.loaded = true
|
|
339
|
+
// What an earlier launch could not send goes now.
|
|
340
|
+
if (this.queue.length) this.scheduleFlush(0)
|
|
341
|
+
for (const run of this.waiting.splice(0)) run()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private whenLoaded(run: () => void): void {
|
|
345
|
+
if (this.loaded) run()
|
|
346
|
+
else this.waiting.push(run)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private recordInstallOrUpdate(session: Session): void {
|
|
350
|
+
const version = this.options.appVersion
|
|
351
|
+
const build = this.options.build
|
|
352
|
+
if (this.firstLaunch) {
|
|
353
|
+
this.enqueue('app_installed', this.clean({ version, build }), session)
|
|
354
|
+
} else if (
|
|
355
|
+
(version && this.state.version && version !== this.state.version) ||
|
|
356
|
+
(build && this.state.build && build !== this.state.build)
|
|
357
|
+
) {
|
|
358
|
+
this.enqueue(
|
|
359
|
+
'app_updated',
|
|
360
|
+
this.clean({
|
|
361
|
+
version,
|
|
362
|
+
build,
|
|
363
|
+
previous_version: this.state.version,
|
|
364
|
+
previous_build: this.state.build,
|
|
365
|
+
}),
|
|
366
|
+
session,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
this.firstLaunch = false
|
|
370
|
+
this.state.version = version ?? this.state.version
|
|
371
|
+
this.state.build = build ?? this.state.build
|
|
372
|
+
this.save()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private save(): void {
|
|
376
|
+
if (this.saveTimer) return
|
|
377
|
+
this.saveTimer = setTimeout(() => {
|
|
378
|
+
this.saveTimer = null
|
|
379
|
+
void this.writeNow()
|
|
380
|
+
}, 250)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private async writeNow(): Promise<void> {
|
|
384
|
+
if (this.saveTimer) {
|
|
385
|
+
clearTimeout(this.saveTimer)
|
|
386
|
+
this.saveTimer = null
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
await this.env.storage?.setItem(this.storageKey, JSON.stringify(this.state))
|
|
390
|
+
await this.env.storage?.setItem(`${this.storageKey}:queue`, JSON.stringify(this.queue))
|
|
391
|
+
} catch {
|
|
392
|
+
// Storage full or failing: the client keeps working in memory.
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Sessions and events -------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
private now(): number {
|
|
399
|
+
return this.env.now?.() ?? Date.now()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private ensureSession(): Session {
|
|
403
|
+
const now = this.now()
|
|
404
|
+
const timeout = (this.options.sessionTimeoutMinutes ?? 30) * 60_000
|
|
405
|
+
let session = this.state.session
|
|
406
|
+
if (!session || now - session.last > timeout) {
|
|
407
|
+
session = { id: uuid(), started: now, last: now, firstScreen: this.screenName ?? undefined }
|
|
408
|
+
this.state.session = session
|
|
409
|
+
this.enqueue('session_started', {}, session)
|
|
410
|
+
} else {
|
|
411
|
+
session.last = now
|
|
412
|
+
}
|
|
413
|
+
return session
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private enqueue(name: string, properties: Properties, session: Session): void {
|
|
417
|
+
this.queue.push({
|
|
418
|
+
name,
|
|
419
|
+
insert_id: uuid(),
|
|
420
|
+
time: new Date(this.now()).toISOString(),
|
|
421
|
+
screen: this.screenName ?? undefined,
|
|
422
|
+
properties: this.clean(properties),
|
|
423
|
+
user_id: this.state.userId,
|
|
424
|
+
anonymous_id: this.state.anonymousId,
|
|
425
|
+
session_id: session.id,
|
|
426
|
+
session_started_at: new Date(session.started).toISOString(),
|
|
427
|
+
})
|
|
428
|
+
const max = this.options.maxQueue ?? 1000
|
|
429
|
+
if (this.queue.length > max) this.queue.splice(0, this.queue.length - max)
|
|
430
|
+
this.save()
|
|
431
|
+
this.scheduleFlush(
|
|
432
|
+
this.queue.length >= FLUSH_AT ? 0 : (this.options.flushIntervalSeconds ?? 10) * 1000,
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private clean(properties: Properties): Properties {
|
|
437
|
+
try {
|
|
438
|
+
const json = JSON.stringify(properties ?? {})
|
|
439
|
+
if (json.length > MAX_PROPERTIES_BYTES) {
|
|
440
|
+
this.warn('Event properties are over 32 KB and were dropped.')
|
|
441
|
+
return {}
|
|
442
|
+
}
|
|
443
|
+
const parsed = JSON.parse(json)
|
|
444
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
445
|
+
} catch {
|
|
446
|
+
return {}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Sending ------------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
private context(session: Session | null): Properties {
|
|
453
|
+
const device = this.env.device()
|
|
454
|
+
return {
|
|
455
|
+
platform: device.platform,
|
|
456
|
+
sdk: this.env.sdk,
|
|
457
|
+
timezone: device.timezone,
|
|
458
|
+
language: device.language,
|
|
459
|
+
device_type: device.device_type,
|
|
460
|
+
device_model: device.device_model,
|
|
461
|
+
os_name: device.os_name,
|
|
462
|
+
os_version: device.os_version,
|
|
463
|
+
app_version: this.options.appVersion,
|
|
464
|
+
screen_width: Math.round(device.screen_width),
|
|
465
|
+
screen_height: Math.round(device.screen_height),
|
|
466
|
+
page_path: session?.firstScreen,
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** One request's body: queued events share a person and session; with none, a heartbeat. */
|
|
471
|
+
body(events: QueuedEvent[]): Properties {
|
|
472
|
+
const head = events[0]
|
|
473
|
+
const session = this.state.session
|
|
474
|
+
const sessionId = head?.session_id ?? session?.id
|
|
475
|
+
const startedAt =
|
|
476
|
+
head?.session_started_at ?? (session ? new Date(session.started).toISOString() : undefined)
|
|
477
|
+
const live = session?.id === sessionId ? session : null
|
|
478
|
+
const current = !head || head.anonymous_id === this.state.anonymousId
|
|
479
|
+
const userId = current ? this.state.userId : events[events.length - 1].user_id
|
|
480
|
+
const body: Properties = {
|
|
481
|
+
anonymous_id: head?.anonymous_id ?? this.state.anonymousId,
|
|
482
|
+
session: sessionId ? { id: sessionId, started_at: startedAt } : undefined,
|
|
483
|
+
sent_at: new Date(this.now()).toISOString(),
|
|
484
|
+
context: this.context(live),
|
|
485
|
+
events: events.map(({ name, insert_id, time, screen, properties, user_id }) => ({
|
|
486
|
+
name,
|
|
487
|
+
insert_id,
|
|
488
|
+
time,
|
|
489
|
+
...(screen ? { screen } : {}),
|
|
490
|
+
properties,
|
|
491
|
+
user_id,
|
|
492
|
+
})),
|
|
493
|
+
}
|
|
494
|
+
if (userId) body.user_id = userId
|
|
495
|
+
if (current && this.state.traits) body.traits = this.state.traits
|
|
496
|
+
return JSON.parse(JSON.stringify(body))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private nextBatch(): QueuedEvent[] {
|
|
500
|
+
const head = this.queue[0]
|
|
501
|
+
if (!head) return []
|
|
502
|
+
const batch: QueuedEvent[] = []
|
|
503
|
+
for (const event of this.queue) {
|
|
504
|
+
if (
|
|
505
|
+
event.session_id !== head.session_id ||
|
|
506
|
+
event.anonymous_id !== head.anonymous_id ||
|
|
507
|
+
batch.length >= BATCH_SIZE
|
|
508
|
+
)
|
|
509
|
+
break
|
|
510
|
+
batch.push(event)
|
|
511
|
+
}
|
|
512
|
+
return batch
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private async post(body: Properties) {
|
|
516
|
+
return this.env.fetch(`${this.options.endpoint}/v1/events`, {
|
|
517
|
+
method: 'POST',
|
|
518
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${this.options.key}` },
|
|
519
|
+
body: JSON.stringify(body),
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async sendQueued(): Promise<void> {
|
|
524
|
+
while (!this.stopped && !this.state.optedOut) {
|
|
525
|
+
const batch = this.nextBatch()
|
|
526
|
+
if (!batch.length && !(this.identityPending && this.state.session)) return
|
|
527
|
+
const traitsSent = this.state.traits
|
|
528
|
+
const carriesIdentity = !batch.length || batch[0].anonymous_id === this.state.anonymousId
|
|
529
|
+
let response: Awaited<ReturnType<Environment['fetch']>>
|
|
530
|
+
try {
|
|
531
|
+
response = await this.post(this.body(batch))
|
|
532
|
+
} catch {
|
|
533
|
+
this.retryLater()
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
const refused =
|
|
537
|
+
response.status >= 400 &&
|
|
538
|
+
response.status < 500 &&
|
|
539
|
+
response.status !== 408 &&
|
|
540
|
+
response.status !== 429
|
|
541
|
+
if (!response.ok && !refused) {
|
|
542
|
+
this.retryLater()
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
if (refused)
|
|
546
|
+
this.warn(
|
|
547
|
+
`Events were refused (${response.status}): ${await response.text().catch(() => '')}`,
|
|
548
|
+
)
|
|
549
|
+
const sent = new Set(batch.map((event) => event.insert_id))
|
|
550
|
+
this.queue = this.queue.filter((event) => !sent.has(event.insert_id))
|
|
551
|
+
if (response.ok && carriesIdentity && this.state.traits === traitsSent) {
|
|
552
|
+
this.state.traits = null
|
|
553
|
+
this.identityPending = false
|
|
554
|
+
}
|
|
555
|
+
this.failures = 0
|
|
556
|
+
this.lastSent = this.now()
|
|
557
|
+
this.save()
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private retryLater(): void {
|
|
562
|
+
this.failures += 1
|
|
563
|
+
this.scheduleFlush(Math.min(60_000, 1000 * 2 ** Math.min(this.failures, 6)), true)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private scheduleFlush(delay: number, replace = false): void {
|
|
567
|
+
if (this.stopped) return
|
|
568
|
+
if (this.flushTimer) {
|
|
569
|
+
if (delay > 0 && !replace) return
|
|
570
|
+
clearTimeout(this.flushTimer)
|
|
571
|
+
}
|
|
572
|
+
this.flushTimer = setTimeout(() => {
|
|
573
|
+
this.flushTimer = null
|
|
574
|
+
void this.flush()
|
|
575
|
+
}, delay)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Keeps an open app counted as online. */
|
|
579
|
+
private heartbeat(): void {
|
|
580
|
+
const session = this.state.session
|
|
581
|
+
if (this.stopped || this.state.optedOut || !this.inForeground || !session) return
|
|
582
|
+
session.last = this.now()
|
|
583
|
+
if (this.queue.length || this.identityPending) {
|
|
584
|
+
void this.flush()
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
if (this.now() - this.lastSent < HEARTBEAT_MS - 1000) return
|
|
588
|
+
this.post(this.body([]))
|
|
589
|
+
.then(() => {
|
|
590
|
+
this.lastSent = this.now()
|
|
591
|
+
})
|
|
592
|
+
.catch(() => undefined)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private warn(message: string): void {
|
|
596
|
+
if (this.options.debug) (this.env.log ?? console.warn)(`KissMyApps: ${message}`)
|
|
597
|
+
}
|
|
598
|
+
}
|
package/src/device.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What React Native itself can tell about the device, with no native module to link.
|
|
2
|
+
|
|
3
|
+
import { Dimensions, NativeModules, Platform } from 'react-native'
|
|
4
|
+
import type { DeviceInfo } from './client'
|
|
5
|
+
|
|
6
|
+
function timezone(): string {
|
|
7
|
+
try {
|
|
8
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
9
|
+
} catch {
|
|
10
|
+
return 'UTC'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function language(): string {
|
|
15
|
+
const fromSettings: unknown =
|
|
16
|
+
Platform.OS === 'ios'
|
|
17
|
+
? (NativeModules.SettingsManager?.settings?.AppleLocale ??
|
|
18
|
+
NativeModules.SettingsManager?.settings?.AppleLanguages?.[0])
|
|
19
|
+
: NativeModules.I18nManager?.localeIdentifier
|
|
20
|
+
if (typeof fromSettings === 'string' && fromSettings)
|
|
21
|
+
return fromSettings.replace(/_/g, '-').split('@')[0]
|
|
22
|
+
try {
|
|
23
|
+
return Intl.DateTimeFormat().resolvedOptions().locale || 'en'
|
|
24
|
+
} catch {
|
|
25
|
+
return 'en'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function deviceInfo(): DeviceInfo {
|
|
30
|
+
const screen = Dimensions.get('screen')
|
|
31
|
+
const shortSide = Math.min(screen.width, screen.height)
|
|
32
|
+
const constants = (Platform.constants ?? {}) as Record<string, unknown>
|
|
33
|
+
if (Platform.OS === 'ios') {
|
|
34
|
+
const pad = Platform.isPad === true || constants.interfaceIdiom === 'pad'
|
|
35
|
+
return {
|
|
36
|
+
platform: 'ios',
|
|
37
|
+
os_name: pad ? 'iPadOS' : 'iOS',
|
|
38
|
+
os_version: String(constants.osVersion ?? Platform.Version),
|
|
39
|
+
device_type: pad ? 'tablet' : 'mobile',
|
|
40
|
+
device_model: pad ? 'iPad' : 'iPhone',
|
|
41
|
+
screen_width: screen.width,
|
|
42
|
+
screen_height: screen.height,
|
|
43
|
+
timezone: timezone(),
|
|
44
|
+
language: language(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const model = typeof constants.Model === 'string' ? constants.Model : undefined
|
|
48
|
+
return {
|
|
49
|
+
platform: 'android',
|
|
50
|
+
os_name: 'Android',
|
|
51
|
+
os_version: String(constants.Release ?? Platform.Version),
|
|
52
|
+
device_type: shortSide >= 600 ? 'tablet' : 'mobile',
|
|
53
|
+
device_model: model,
|
|
54
|
+
screen_width: screen.width,
|
|
55
|
+
screen_height: screen.height,
|
|
56
|
+
timezone: timezone(),
|
|
57
|
+
language: language(),
|
|
58
|
+
}
|
|
59
|
+
}
|