@formo/analytics-react-native 1.0.0 → 1.0.2
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/lib/commonjs/FormoAnalytics.js +141 -12
- package/lib/commonjs/FormoAnalytics.js.map +1 -1
- package/lib/commonjs/FormoAnalyticsProvider.js +3 -0
- package/lib/commonjs/FormoAnalyticsProvider.js.map +1 -1
- package/lib/commonjs/constants/events.js +30 -3
- package/lib/commonjs/constants/events.js.map +1 -1
- package/lib/commonjs/lib/crash/index.js +121 -0
- package/lib/commonjs/lib/crash/index.js.map +1 -0
- package/lib/commonjs/lib/event/EventFactory.js +88 -8
- package/lib/commonjs/lib/event/EventFactory.js.map +1 -1
- package/lib/commonjs/lib/lifecycle/index.js +29 -6
- package/lib/commonjs/lib/lifecycle/index.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/FormoAnalytics.js +138 -8
- package/lib/module/FormoAnalytics.js.map +1 -1
- package/lib/module/FormoAnalyticsProvider.js +3 -0
- package/lib/module/FormoAnalyticsProvider.js.map +1 -1
- package/lib/module/constants/events.js +29 -2
- package/lib/module/constants/events.js.map +1 -1
- package/lib/module/lib/crash/index.js +117 -0
- package/lib/module/lib/crash/index.js.map +1 -0
- package/lib/module/lib/event/EventFactory.js +86 -8
- package/lib/module/lib/event/EventFactory.js.map +1 -1
- package/lib/module/lib/lifecycle/index.js +29 -6
- package/lib/module/lib/lifecycle/index.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/commonjs/FormoAnalytics.d.ts +52 -1
- package/lib/typescript/commonjs/FormoAnalytics.d.ts.map +1 -1
- package/lib/typescript/commonjs/FormoAnalyticsProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/constants/events.d.ts +23 -0
- package/lib/typescript/commonjs/constants/events.d.ts.map +1 -1
- package/lib/typescript/commonjs/lib/crash/index.d.ts +41 -0
- package/lib/typescript/commonjs/lib/crash/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/lib/event/EventFactory.d.ts +26 -7
- package/lib/typescript/commonjs/lib/event/EventFactory.d.ts.map +1 -1
- package/lib/typescript/commonjs/lib/lifecycle/index.d.ts +10 -0
- package/lib/typescript/commonjs/lib/lifecycle/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/types/base.d.ts +40 -0
- package/lib/typescript/commonjs/types/base.d.ts.map +1 -1
- package/lib/typescript/commonjs/version.d.ts +1 -1
- package/lib/typescript/module/FormoAnalytics.d.ts +52 -1
- package/lib/typescript/module/FormoAnalytics.d.ts.map +1 -1
- package/lib/typescript/module/FormoAnalyticsProvider.d.ts.map +1 -1
- package/lib/typescript/module/constants/events.d.ts +23 -0
- package/lib/typescript/module/constants/events.d.ts.map +1 -1
- package/lib/typescript/module/lib/crash/index.d.ts +41 -0
- package/lib/typescript/module/lib/crash/index.d.ts.map +1 -0
- package/lib/typescript/module/lib/event/EventFactory.d.ts +26 -7
- package/lib/typescript/module/lib/event/EventFactory.d.ts.map +1 -1
- package/lib/typescript/module/lib/lifecycle/index.d.ts +10 -0
- package/lib/typescript/module/lib/lifecycle/index.d.ts.map +1 -1
- package/lib/typescript/module/types/base.d.ts +40 -0
- package/lib/typescript/module/types/base.d.ts.map +1 -1
- package/lib/typescript/module/version.d.ts +1 -1
- package/package.json +8 -8
- package/src/FormoAnalytics.ts +151 -8
- package/src/FormoAnalyticsProvider.tsx +3 -0
- package/src/constants/events.ts +32 -2
- package/src/lib/crash/index.ts +145 -0
- package/src/lib/event/EventFactory.ts +115 -21
- package/src/lib/lifecycle/index.ts +35 -6
- package/src/types/base.ts +47 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JavaScript crash reporting.
|
|
3
|
+
*
|
|
4
|
+
* Emits the Segment-spec `Application Crashed` event for unhandled JS errors by
|
|
5
|
+
* wrapping React Native's global error handler.
|
|
6
|
+
*
|
|
7
|
+
* Scope: JavaScript errors only. A native crash (a Swift/Kotlin exception, an
|
|
8
|
+
* OOM kill, a watchdog termination) never reaches the JS runtime, so it cannot
|
|
9
|
+
* be observed from here — that needs a native crash reporter.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { LIFECYCLE_EVENT } from "../../constants/events";
|
|
13
|
+
import { logger } from "../logger";
|
|
14
|
+
|
|
15
|
+
/** RN's global handler signature. */
|
|
16
|
+
type ErrorHandler = (error: Error, isFatal?: boolean) => void;
|
|
17
|
+
|
|
18
|
+
interface ErrorUtilsLike {
|
|
19
|
+
getGlobalHandler?: () => ErrorHandler | undefined;
|
|
20
|
+
setGlobalHandler?: (handler: ErrorHandler) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Interface for the analytics instance to avoid circular deps */
|
|
24
|
+
interface IAnalyticsInstance {
|
|
25
|
+
track(event: string, properties?: Record<string, unknown>): Promise<void>;
|
|
26
|
+
flush(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stack traces can be long; cap them so one crash can't dominate a batch. */
|
|
30
|
+
const MAX_STACK_LENGTH = 4000;
|
|
31
|
+
|
|
32
|
+
function getErrorUtils(): ErrorUtilsLike | undefined {
|
|
33
|
+
// ErrorUtils is a React Native global, not an importable module. It is absent
|
|
34
|
+
// under plain Node (unit tests, SSR), hence the guarded lookup.
|
|
35
|
+
return (globalThis as { ErrorUtils?: ErrorUtilsLike }).ErrorUtils;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CrashReporter {
|
|
39
|
+
private analytics: IAnalyticsInstance;
|
|
40
|
+
private previousHandler: ErrorHandler | undefined;
|
|
41
|
+
private installedHandler: ErrorHandler | undefined;
|
|
42
|
+
private started = false;
|
|
43
|
+
|
|
44
|
+
constructor(analytics: IAnalyticsInstance) {
|
|
45
|
+
this.analytics = analytics;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Install the global error handler.
|
|
50
|
+
*
|
|
51
|
+
* The previous handler is always called afterwards — React Native's default
|
|
52
|
+
* handler is what shows the redbox in dev and terminates the app on a fatal
|
|
53
|
+
* error in production, and a customer's crash reporter (Sentry, Bugsnag) may
|
|
54
|
+
* also be in the chain. Swallowing it would break both.
|
|
55
|
+
*/
|
|
56
|
+
start(): void {
|
|
57
|
+
if (this.started) return;
|
|
58
|
+
|
|
59
|
+
const errorUtils = getErrorUtils();
|
|
60
|
+
if (!errorUtils?.setGlobalHandler || !errorUtils?.getGlobalHandler) {
|
|
61
|
+
logger.debug(
|
|
62
|
+
"CrashReporter: ErrorUtils unavailable, skipping crash tracking",
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Captured in the closure, NOT read from `this` at crash time. If another
|
|
68
|
+
// reporter wraps us afterwards our handler stays reachable through its
|
|
69
|
+
// chain, and cleanup() clearing the field would otherwise sever the chain
|
|
70
|
+
// at the moment it matters: A.start, B.start, A.cleanup, crash — B calls
|
|
71
|
+
// our handler, which would then forward to `undefined` and the real
|
|
72
|
+
// RN/default handler would never run.
|
|
73
|
+
const previousHandler = errorUtils.getGlobalHandler();
|
|
74
|
+
this.previousHandler = previousHandler;
|
|
75
|
+
|
|
76
|
+
const handler: ErrorHandler = (error, isFatal) => {
|
|
77
|
+
// Nothing in here may throw: this runs while the app is already failing,
|
|
78
|
+
// and an exception would replace the real crash with ours.
|
|
79
|
+
//
|
|
80
|
+
// `started` is checked so a cleaned-up reporter still FORWARDS (keeping
|
|
81
|
+
// the chain intact) but no longer reports — otherwise the sequence above
|
|
82
|
+
// would emit a duplicate Application Crashed from the stopped instance.
|
|
83
|
+
if (this.started) {
|
|
84
|
+
try {
|
|
85
|
+
this.report(error, isFatal);
|
|
86
|
+
} catch (reportingError) {
|
|
87
|
+
logger.debug("CrashReporter: failed to report crash", reportingError);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
previousHandler?.(error, isFatal);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this.installedHandler = handler;
|
|
95
|
+
errorUtils.setGlobalHandler(handler);
|
|
96
|
+
this.started = true;
|
|
97
|
+
logger.info("CrashReporter: Started");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private report(error: Error, isFatal?: boolean): void {
|
|
101
|
+
const stack = typeof error?.stack === "string" ? error.stack : "";
|
|
102
|
+
|
|
103
|
+
// Fire and forget. On a fatal error the process is about to die, so the
|
|
104
|
+
// flush is a best effort — the queue also flushes on background and on the
|
|
105
|
+
// next launch's retry, which is where most fatal crashes are actually
|
|
106
|
+
// recovered from.
|
|
107
|
+
void this.analytics
|
|
108
|
+
.track(LIFECYCLE_EVENT.APPLICATION_CRASHED, {
|
|
109
|
+
message: error?.message ?? String(error),
|
|
110
|
+
name: error?.name ?? "Error",
|
|
111
|
+
stack: stack.slice(0, MAX_STACK_LENGTH),
|
|
112
|
+
stack_truncated: stack.length > MAX_STACK_LENGTH,
|
|
113
|
+
fatal: Boolean(isFatal),
|
|
114
|
+
})
|
|
115
|
+
.then(() => this.analytics.flush())
|
|
116
|
+
.catch((trackError) => {
|
|
117
|
+
logger.debug("CrashReporter: failed to send crash event", trackError);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Restore the previous handler.
|
|
123
|
+
*
|
|
124
|
+
* Only restores if ours is still the installed handler — if something else
|
|
125
|
+
* wrapped us afterwards, replacing the chain would unhook that too.
|
|
126
|
+
*/
|
|
127
|
+
cleanup(): void {
|
|
128
|
+
if (!this.started) return;
|
|
129
|
+
|
|
130
|
+
const errorUtils = getErrorUtils();
|
|
131
|
+
if (
|
|
132
|
+
errorUtils?.getGlobalHandler?.() === this.installedHandler &&
|
|
133
|
+
errorUtils?.setGlobalHandler &&
|
|
134
|
+
this.previousHandler
|
|
135
|
+
) {
|
|
136
|
+
errorUtils.setGlobalHandler(this.previousHandler);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// previousHandler is deliberately NOT cleared: if another reporter wrapped
|
|
140
|
+
// us, our installed handler is still in its chain and must keep forwarding.
|
|
141
|
+
this.started = false;
|
|
142
|
+
this.installedHandler = undefined;
|
|
143
|
+
logger.info("CrashReporter: Cleaned up");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -117,6 +117,70 @@ export function getSessionId(): string {
|
|
|
117
117
|
* containing the tokens the classifier keys off (iphone/ipad/android + version)
|
|
118
118
|
* so mobile device and OS resolve correctly. Returns "" for unknown platforms.
|
|
119
119
|
*/
|
|
120
|
+
/**
|
|
121
|
+
* Resolve `device_type` from expo-device's `deviceType` enum.
|
|
122
|
+
*
|
|
123
|
+
* Extracted and guarded because the Expo branch of getDeviceInfo() runs when
|
|
124
|
+
* EITHER expo-device or expo-application is installed, so `deviceType` may be
|
|
125
|
+
* undefined. A bare `deviceType === DeviceType.TABLET` is then
|
|
126
|
+
* `undefined === undefined` — true — and every device reports as a tablet,
|
|
127
|
+
* which also flips the synthesized user agent to iPad/Tablet and corrupts
|
|
128
|
+
* device breakdowns downstream. Unknown means "mobile", the safe default for a
|
|
129
|
+
* React Native app.
|
|
130
|
+
*/
|
|
131
|
+
interface DeviceInfoResult {
|
|
132
|
+
os_name: string;
|
|
133
|
+
os_version: string;
|
|
134
|
+
device_model: string;
|
|
135
|
+
device_manufacturer: string;
|
|
136
|
+
device_name: string;
|
|
137
|
+
device_type: string;
|
|
138
|
+
user_agent: string;
|
|
139
|
+
app_name: string;
|
|
140
|
+
app_version: string;
|
|
141
|
+
app_build: string;
|
|
142
|
+
app_bundle_id: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compose a screen view's `page_url` as `app://<bundle id>/<screen>`.
|
|
147
|
+
*
|
|
148
|
+
* Well-formed on purpose: the bundle id occupies the authority slot and the
|
|
149
|
+
* screen the path, so a standard URL parser yields both without any
|
|
150
|
+
* mobile-specific handling — the same shape as `https://<host>/<path>`.
|
|
151
|
+
*
|
|
152
|
+
* @param bundleId Application bundle id, e.g. "com.acme.wallet". When empty the
|
|
153
|
+
* authority is omitted (`app:///<screen>`), which still parses to a correct
|
|
154
|
+
* path; the pipeline resolves origin from context in that case.
|
|
155
|
+
* @param name Screen name. Leading slashes are stripped so a router-style name
|
|
156
|
+
* ("/tabs/leaderboard") does not produce a doubled separator.
|
|
157
|
+
*/
|
|
158
|
+
export function buildScreenUrl(bundleId: string, name: string): string {
|
|
159
|
+
const screen = (name ?? "")
|
|
160
|
+
.replace(/^\/+/, "")
|
|
161
|
+
// Percent-encode the characters that would otherwise change the URL's
|
|
162
|
+
// STRUCTURE rather than its path. '?' starts a query and '#' a fragment, so
|
|
163
|
+
// screen("Checkout?coupon=X") would parse with pathname "/Checkout" and the
|
|
164
|
+
// rest silently dropped from the screen name. '/' is deliberately NOT
|
|
165
|
+
// encoded — router-style names like "/tabs/leaderboard" are meant to be
|
|
166
|
+
// path segments.
|
|
167
|
+
// '%' FIRST, so the transform stays injective. Without it a screen literally
|
|
168
|
+
// named "Checkout%3Fx" and one named "Checkout?x" both produce
|
|
169
|
+
// ".../Checkout%3Fx" and two distinct screens merge in the analytics.
|
|
170
|
+
.replace(/%/g, "%25")
|
|
171
|
+
.replace(/\?/g, "%3F")
|
|
172
|
+
.replace(/#/g, "%23");
|
|
173
|
+
return `app://${bundleId ?? ""}/${screen}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function resolveExpoDeviceType(
|
|
177
|
+
deviceType: number | null | undefined,
|
|
178
|
+
tabletEnumValue: number | null | undefined,
|
|
179
|
+
): "tablet" | "mobile" {
|
|
180
|
+
if (deviceType == null || tabletEnumValue == null) return "mobile";
|
|
181
|
+
return deviceType === tabletEnumValue ? "tablet" : "mobile";
|
|
182
|
+
}
|
|
183
|
+
|
|
120
184
|
export function synthesizeUserAgent(info: {
|
|
121
185
|
os_name: string;
|
|
122
186
|
os_version: string;
|
|
@@ -146,6 +210,8 @@ export function synthesizeUserAgent(info: {
|
|
|
146
210
|
*/
|
|
147
211
|
class EventFactory implements IEventFactory {
|
|
148
212
|
private options?: Options;
|
|
213
|
+
/** Memoised device/app identity — see getDeviceInfo(). */
|
|
214
|
+
private deviceInfoPromise?: Promise<DeviceInfoResult>;
|
|
149
215
|
|
|
150
216
|
constructor(options?: Options) {
|
|
151
217
|
this.options = options;
|
|
@@ -269,19 +335,32 @@ class EventFactory implements IEventFactory {
|
|
|
269
335
|
* Get device information
|
|
270
336
|
* Supports both react-native-device-info (bare RN) and expo-device/expo-application (Expo Go)
|
|
271
337
|
*/
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
338
|
+
/**
|
|
339
|
+
* The app bundle id as it will appear in context.
|
|
340
|
+
*
|
|
341
|
+
* Must mirror generateContext's precedence: an explicitly configured
|
|
342
|
+
* `options.app.bundleId` overrides whatever the native modules report. Reading
|
|
343
|
+
* getDeviceInfo() alone would ignore that configuration and silently fall back
|
|
344
|
+
* to the authority-less URL form — and on React Native Web, where neither
|
|
345
|
+
* react-native-device-info nor expo-application resolves a bundle id, that is
|
|
346
|
+
* the ONLY value available.
|
|
347
|
+
*/
|
|
348
|
+
private async resolveAppBundleId(): Promise<string> {
|
|
349
|
+
return (
|
|
350
|
+
this.options?.app?.bundleId || (await this.getDeviceInfo()).app_bundle_id || ""
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async getDeviceInfo(): Promise<DeviceInfoResult> {
|
|
355
|
+
// Device and app identity do not change for the lifetime of the process,
|
|
356
|
+
// and resolving them crosses the native bridge. Memoise the promise so
|
|
357
|
+
// every event after the first is free, and so callers that need only the
|
|
358
|
+
// app identifier (screen events) can ask without paying twice.
|
|
359
|
+
this.deviceInfoPromise ??= this.resolveDeviceInfo();
|
|
360
|
+
return this.deviceInfoPromise;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async resolveDeviceInfo(): Promise<DeviceInfoResult> {
|
|
285
364
|
// Try react-native-device-info first (bare RN and Expo dev builds)
|
|
286
365
|
if (DeviceInfo) {
|
|
287
366
|
try {
|
|
@@ -324,11 +403,13 @@ class EventFactory implements IEventFactory {
|
|
|
324
403
|
// Fall back to Expo modules (Expo Go)
|
|
325
404
|
if (ExpoDevice || ExpoApplication) {
|
|
326
405
|
try {
|
|
327
|
-
const isTablet = ExpoDevice?.deviceType === ExpoDevice?.DeviceType?.TABLET;
|
|
328
406
|
const os_name = ExpoDevice?.osName || Platform.OS;
|
|
329
407
|
const os_version = ExpoDevice?.osVersion || String(Platform.Version);
|
|
330
408
|
const device_model = ExpoDevice?.modelName || "Unknown";
|
|
331
|
-
const device_type =
|
|
409
|
+
const device_type = resolveExpoDeviceType(
|
|
410
|
+
ExpoDevice?.deviceType,
|
|
411
|
+
ExpoDevice?.DeviceType?.TABLET,
|
|
412
|
+
);
|
|
332
413
|
return {
|
|
333
414
|
os_name,
|
|
334
415
|
os_version,
|
|
@@ -488,15 +569,28 @@ class EventFactory implements IEventFactory {
|
|
|
488
569
|
const props = { ...(properties ?? {}), name, ...(category && { category }) };
|
|
489
570
|
|
|
490
571
|
// Map screen name to page-equivalent context fields so mobile screens flow
|
|
491
|
-
// through the same analytics as web page views.
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
// host
|
|
572
|
+
// through the same analytics as web page views.
|
|
573
|
+
//
|
|
574
|
+
// The URL is app://<bundle id>/<screen>, which is a WELL-FORMED URL: the
|
|
575
|
+
// bundle id is the authority and the screen is the path, exactly mirroring
|
|
576
|
+
// https://<host>/<path> on web. That is what lets the ingestion pipeline
|
|
577
|
+
// parse it with the same URL functions it uses for web — the authority
|
|
578
|
+
// becomes `origin` and the path becomes `page_path`, with no mobile
|
|
579
|
+
// special-casing.
|
|
580
|
+
//
|
|
581
|
+
// The earlier form was app://<screen>, which is malformed: the screen name
|
|
582
|
+
// lands in the authority slot and there is no path at all, so a URL parser
|
|
583
|
+
// yields an empty path and the pipeline had to reconstruct both fields by
|
|
584
|
+
// hand. The bundle id is also the right choice of authority because, like a
|
|
585
|
+
// hostname, it is stable and globally unique — a display name is neither.
|
|
586
|
+
//
|
|
587
|
+
// Falls back to an empty authority (app:///<screen>) when the bundle id is
|
|
588
|
+
// unavailable, which keeps the path parseable and lets the pipeline resolve
|
|
589
|
+
// origin from context instead.
|
|
496
590
|
// User-supplied context values take precedence (spread last).
|
|
497
591
|
const screenContext: IFormoEventContext = {
|
|
498
592
|
page_title: name,
|
|
499
|
-
page_url:
|
|
593
|
+
page_url: buildScreenUrl(await this.resolveAppBundleId(), name),
|
|
500
594
|
...(context ?? {}),
|
|
501
595
|
};
|
|
502
596
|
|
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
* - Application Updated (version/build changed)
|
|
7
7
|
* - Application Opened (every cold start + foreground return)
|
|
8
8
|
* - Application Backgrounded (app goes to background)
|
|
9
|
+
* - Application Foregrounded (foreground return; opt-in, see AutocaptureOptions)
|
|
9
10
|
*
|
|
10
11
|
* Detection is JS-side using AsyncStorage (no native modules required).
|
|
12
|
+
*
|
|
13
|
+
* The remaining spec events live outside this manager because they are not
|
|
14
|
+
* AppState-driven: `Deep Link Opened` is emitted from the Linking hook in
|
|
15
|
+
* FormoAnalytics, `Application Crashed` from lib/crash, and the push
|
|
16
|
+
* notification events from explicit FormoAnalytics methods (the SDK cannot
|
|
17
|
+
* observe push delivery without a native module).
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
20
|
import { AppState, AppStateStatus, Linking } from "react-native";
|
|
@@ -18,6 +25,7 @@ import {
|
|
|
18
25
|
LOCAL_APP_BUILD_KEY,
|
|
19
26
|
} from "../../constants/storage";
|
|
20
27
|
import { getStoredTrafficSource } from "../../utils/trafficSource";
|
|
28
|
+
import { LIFECYCLE_EVENT } from "../../constants/events";
|
|
21
29
|
|
|
22
30
|
/** Interface for the analytics instance to avoid circular deps */
|
|
23
31
|
interface IAnalyticsInstance {
|
|
@@ -75,6 +83,7 @@ export class AppLifecycleManager {
|
|
|
75
83
|
private appStateSubscription: { remove: () => void } | null = null;
|
|
76
84
|
private lastAppState: AppStateStatus = AppState.currentState;
|
|
77
85
|
private appVersionInfo: AppVersionInfo = { version: "", build: "" };
|
|
86
|
+
private trackForegrounded = false;
|
|
78
87
|
|
|
79
88
|
constructor(analytics: IAnalyticsInstance) {
|
|
80
89
|
this.analytics = analytics;
|
|
@@ -85,8 +94,10 @@ export class AppLifecycleManager {
|
|
|
85
94
|
* Detects install/update, fires Application Opened, and sets up AppState listener.
|
|
86
95
|
*/
|
|
87
96
|
async start(
|
|
88
|
-
appOptions?: { version?: string; build?: string }
|
|
97
|
+
appOptions?: { version?: string; build?: string },
|
|
98
|
+
options?: { trackForegrounded?: boolean }
|
|
89
99
|
): Promise<void> {
|
|
100
|
+
this.trackForegrounded = options?.trackForegrounded ?? false;
|
|
90
101
|
this.appVersionInfo = await resolveAppVersionInfo(appOptions);
|
|
91
102
|
|
|
92
103
|
// Detect install vs update
|
|
@@ -103,7 +114,7 @@ export class AppLifecycleManager {
|
|
|
103
114
|
// Linking not available
|
|
104
115
|
}
|
|
105
116
|
|
|
106
|
-
await this.analytics.track(
|
|
117
|
+
await this.analytics.track(LIFECYCLE_EVENT.APPLICATION_OPENED, {
|
|
107
118
|
version: this.appVersionInfo.version,
|
|
108
119
|
build: this.appVersionInfo.build,
|
|
109
120
|
from_background: false,
|
|
@@ -151,7 +162,7 @@ export class AppLifecycleManager {
|
|
|
151
162
|
Object.entries(trafficSource).filter(([, value]) => Boolean(value))
|
|
152
163
|
);
|
|
153
164
|
logger.info("AppLifecycleManager: Application Installed");
|
|
154
|
-
await this.analytics.track(
|
|
165
|
+
await this.analytics.track(LIFECYCLE_EVENT.APPLICATION_INSTALLED, {
|
|
155
166
|
version,
|
|
156
167
|
build,
|
|
157
168
|
...attribution,
|
|
@@ -159,7 +170,7 @@ export class AppLifecycleManager {
|
|
|
159
170
|
} else if (previousVersion !== version || previousBuild !== build) {
|
|
160
171
|
// Version or build changed — update
|
|
161
172
|
logger.info("AppLifecycleManager: Application Updated");
|
|
162
|
-
await this.analytics.track(
|
|
173
|
+
await this.analytics.track(LIFECYCLE_EVENT.APPLICATION_UPDATED, {
|
|
163
174
|
version,
|
|
164
175
|
build,
|
|
165
176
|
previous_version: previousVersion || "",
|
|
@@ -186,7 +197,7 @@ export class AppLifecycleManager {
|
|
|
186
197
|
if (nextAppState === "active" && this.lastAppState === "background") {
|
|
187
198
|
// Returning from background
|
|
188
199
|
this.analytics
|
|
189
|
-
.track(
|
|
200
|
+
.track(LIFECYCLE_EVENT.APPLICATION_OPENED, {
|
|
190
201
|
version: this.appVersionInfo.version,
|
|
191
202
|
build: this.appVersionInfo.build,
|
|
192
203
|
from_background: true,
|
|
@@ -194,12 +205,30 @@ export class AppLifecycleManager {
|
|
|
194
205
|
.catch((error) => {
|
|
195
206
|
logger.error("AppLifecycleManager: Error tracking Application Opened", error);
|
|
196
207
|
});
|
|
208
|
+
|
|
209
|
+
// The Segment spec's dedicated name for this same transition. Opt-in,
|
|
210
|
+
// because emitting it alongside Application Opened doubles foreground
|
|
211
|
+
// volume for no extra information — it exists for consumers that key on
|
|
212
|
+
// the spec name rather than on `from_background`.
|
|
213
|
+
if (this.trackForegrounded) {
|
|
214
|
+
this.analytics
|
|
215
|
+
.track(LIFECYCLE_EVENT.APPLICATION_FOREGROUNDED, {
|
|
216
|
+
version: this.appVersionInfo.version,
|
|
217
|
+
build: this.appVersionInfo.build,
|
|
218
|
+
})
|
|
219
|
+
.catch((error) => {
|
|
220
|
+
logger.error(
|
|
221
|
+
"AppLifecycleManager: Error tracking Application Foregrounded",
|
|
222
|
+
error
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
197
226
|
}
|
|
198
227
|
|
|
199
228
|
if (nextAppState === "background" && this.lastAppState === "active") {
|
|
200
229
|
// Going to background
|
|
201
230
|
this.analytics
|
|
202
|
-
.track(
|
|
231
|
+
.track(LIFECYCLE_EVENT.APPLICATION_BACKGROUNDED, {
|
|
203
232
|
version: this.appVersionInfo.version,
|
|
204
233
|
build: this.appVersionInfo.build,
|
|
205
234
|
})
|
package/src/types/base.ts
CHANGED
|
@@ -95,6 +95,13 @@ export interface IFormoAnalytics {
|
|
|
95
95
|
callback?: (...args: unknown[]) => void
|
|
96
96
|
): Promise<void>;
|
|
97
97
|
|
|
98
|
+
// Push notification lifecycle events. Not autocaptured: push delivery is
|
|
99
|
+
// invisible to JavaScript without a native module, so the host app forwards
|
|
100
|
+
// these from its own push handler.
|
|
101
|
+
pushNotificationReceived(properties?: IFormoEventProperties): Promise<void>;
|
|
102
|
+
pushNotificationTapped(properties?: IFormoEventProperties): Promise<void>;
|
|
103
|
+
pushNotificationBounced(properties?: IFormoEventProperties): Promise<void>;
|
|
104
|
+
|
|
98
105
|
// Event flushing
|
|
99
106
|
flush(): Promise<void>;
|
|
100
107
|
|
|
@@ -158,6 +165,46 @@ export interface AutocaptureOptions {
|
|
|
158
165
|
* @default true
|
|
159
166
|
*/
|
|
160
167
|
lifecycle?: boolean;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Emit `Application Foregrounded` on every background → active transition,
|
|
171
|
+
* in addition to the `Application Opened` (with `from_background: true`) that
|
|
172
|
+
* already fires there.
|
|
173
|
+
*
|
|
174
|
+
* Off by default because it doubles foreground event volume and adds no
|
|
175
|
+
* information: `Application Opened` with `from_background: true` already
|
|
176
|
+
* marks the same transition. Enable it if you consume the Segment spec's
|
|
177
|
+
* `Application Foregrounded` name directly — e.g. when migrating dashboards
|
|
178
|
+
* or destinations from Segment or RudderStack. Requires `lifecycle`.
|
|
179
|
+
* @default false
|
|
180
|
+
*/
|
|
181
|
+
foregrounded?: boolean;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Track `Deep Link Opened` when the app is launched or resumed via a deep
|
|
185
|
+
* link or universal link, with the `url` property.
|
|
186
|
+
*
|
|
187
|
+
* Independent of `attribution.deeplinks`, which controls whether the link's
|
|
188
|
+
* UTM/referral parameters are parsed into event context. This option is only
|
|
189
|
+
* about emitting the event; the SDK still needs `attribution.deeplinks` to
|
|
190
|
+
* observe links at all.
|
|
191
|
+
* @default true
|
|
192
|
+
*/
|
|
193
|
+
deepLinks?: boolean;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Track `Application Crashed` on unhandled JavaScript errors, with the error
|
|
197
|
+
* message, stack, and whether React Native considered it fatal.
|
|
198
|
+
*
|
|
199
|
+
* Off by default because enabling it installs a global error handler
|
|
200
|
+
* (`ErrorUtils.setGlobalHandler`). The previous handler is always invoked
|
|
201
|
+
* afterwards, so React Native's redbox and any crash reporter you already use
|
|
202
|
+
* keep working — but installing one implicitly on an SDK upgrade is a
|
|
203
|
+
* surprise, so it is opt-in. Covers JS errors only: native crashes need a
|
|
204
|
+
* native crash reporter.
|
|
205
|
+
* @default false
|
|
206
|
+
*/
|
|
207
|
+
crashes?: boolean;
|
|
161
208
|
}
|
|
162
209
|
|
|
163
210
|
/**
|
package/src/version.ts
CHANGED