@bigbinary/neeto-commons-rn 2.1.0 → 2.2.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"caseConversion.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/caseConversion.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAkBrD,eAAO,MAAM,kBAAkB,GAC7B,MAAM,OAAO,EACb,UAAS,qBAA0B,YAcpC,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAC9B,MAAM,OAAO,EACb,UAAS,qBAA0B,QAcpC,CAAC"}
1
+ {"version":3,"file":"caseConversion.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/caseConversion.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAsBrD,eAAO,MAAM,kBAAkB,GAC7B,MAAM,OAAO,EACb,UAAS,qBAA0B,YAcpC,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAC9B,MAAM,OAAO,EACb,UAAS,qBAA0B,QAcpC,CAAC"}
@@ -2,4 +2,7 @@ import { type CaseConversionOptions } from "./types";
2
2
  export declare const DEFAULT_CASE_CONVERSION: boolean;
3
3
  export declare const UUID_SEGMENT: RegExp;
4
4
  export declare const shouldEnableCaseConversion: (options?: CaseConversionOptions) => boolean;
5
+ export declare const SENSITIVE_KEY_PATTERNS: RegExp[];
6
+ export declare const REDACTED = "<redacted>";
7
+ export declare const MAX_REDACT_DEPTH = 6;
5
8
  //# sourceMappingURL=constants.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/constants.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAErD,eAAO,MAAM,uBAAuB,SAA2C,CAAC;AAEhF,eAAO,MAAM,YAAY,QAC0C,CAAC;AAEpE,eAAO,MAAM,0BAA0B,GACrC,UAAS,qBAA0B,YASpC,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/constants.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAErD,eAAO,MAAM,uBAAuB,SAA2C,CAAC;AAEhF,eAAO,MAAM,YAAY,QAC0C,CAAC;AAEpE,eAAO,MAAM,0BAA0B,GACrC,UAAS,qBAA0B,YASpC,CAAC;AAOF,eAAO,MAAM,sBAAsB,UAQlC,CAAC;AAEF,eAAO,MAAM,QAAQ,eAAe,CAAC;AAErC,eAAO,MAAM,gBAAgB,IAAI,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/index.ts"],"names":[],"mappings":"AAIA,OAAc,EACZ,KAAK,UAAU,EAEf,KAAK,0BAA0B,EAChC,MAAM,OAAO,CAAC;AAoBf,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AA2CD,eAAO,MAAM,cAAc,GAAI,eAAe;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,WACG,CAAC;AAgCvE,eAAO,MAAM,eAAe,GAC1B,MAAM,YAAY,GAAG,SAAS,EAC9B,OAAO,UAAU,KAChB,MAMF,CAAC;AA+IF,eAAO,MAAM,wBAAwB,YAUpC,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,SAAS,0BAA0B,kDAepC,CAAC;AAEF,eAAO,MAAM,cAAc,YAI1B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/index.ts"],"names":[],"mappings":"AAIA,OAAc,EACZ,KAAK,UAAU,EAEf,KAAK,0BAA0B,EAChC,MAAM,OAAO,CAAC;AAqBf,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAYD,eAAO,MAAM,cAAc,GAAI,eAAe;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,WACG,CAAC;AAgCvE,eAAO,MAAM,eAAe,GAC1B,MAAM,YAAY,GAAG,SAAS,EAC9B,OAAO,UAAU,KAChB,MAMF,CAAC;AAqJF,eAAO,MAAM,wBAAwB,YAUpC,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,SAAS,0BAA0B,kDAepC,CAAC;AAEF,eAAO,MAAM,cAAc,YAI1B,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare const filterSensitiveData: (data: unknown) => unknown;
2
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/redact.ts"],"names":[],"mappings":"AAgDA,eAAO,MAAM,mBAAmB,GAAI,MAAM,OAAO,YACK,CAAC"}
@@ -1,4 +1,5 @@
1
- import { type NotifiableError } from "@bugsnag/react-native";
2
- export declare const bugsnagErrorHandler: (err: NotifiableError, origin: string, groupingHash?: string) => void;
1
+ import { type Event, type NotifiableError } from "@bugsnag/react-native";
2
+ export declare const toNotifiableError: (error: unknown) => Error;
3
+ export declare const bugsnagErrorHandler: (err: NotifiableError, origin: string, groupingHash?: string, severity?: Event["severity"]) => void;
3
4
  export declare const configureBugsnag: () => void;
4
5
  //# sourceMappingURL=bugsnag.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"bugsnag.d.ts","sourceRoot":"","sources":["../../../src/config/bugsnag.ts"],"names":[],"mappings":"AACA,OAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAStE,eAAO,MAAM,mBAAmB,GAC9B,KAAK,eAAe,EACpB,QAAQ,MAAM,EAGd,eAAe,MAAM,SAkBtB,CAAC;AAEF,eAAO,MAAM,gBAAgB,YAsB5B,CAAC"}
1
+ {"version":3,"file":"bugsnag.d.ts","sourceRoot":"","sources":["../../../src/config/bugsnag.ts"],"names":[],"mappings":"AACA,OAAgB,EACd,KAAK,KAAK,EACV,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AAgD/B,eAAO,MAAM,iBAAiB,GAAI,OAAO,OAAO,KAAG,KAMlD,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAC9B,KAAK,eAAe,EACpB,QAAQ,MAAM,EAGd,eAAe,MAAM,EAGrB,WAAU,KAAK,CAAC,UAAU,CAAa,SAkBxC,CAAC;AAEF,eAAO,MAAM,gBAAgB,YAgD5B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"useAuth.d.ts","sourceRoot":"","sources":["../../../../src/hooks/query/useAuth.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,OAAO,CAAC;AAUxC,OAAO,EAAE,KAAK,YAAY,EAAmB,MAAM,oBAAoB,CAAC;AASxE,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,mBAAmB,CAAC;AAE3B,eAAO,MAAM,OAAO;4CAQf;QACD,YAAY,EAAE,YAAY,CAAC;KAC5B;;eAyIc,MAAM;4BAAsB,OAAO;;;cA1E1C,MAAM;gBACJ,MAAM;;;wBAqGyC,MAAM;;;2BAapC,MAAM;uBACV,MAAM;;;sBAYP,YAAY;;CAoBnC,CAAC"}
1
+ {"version":3,"file":"useAuth.d.ts","sourceRoot":"","sources":["../../../../src/hooks/query/useAuth.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,OAAO,CAAC;AAUxC,OAAO,EAAE,KAAK,YAAY,EAAmB,MAAM,oBAAoB,CAAC;AASxE,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,mBAAmB,CAAC;AAE3B,eAAO,MAAM,OAAO;4CAQf;QACD,YAAY,EAAE,YAAY,CAAC;KAC5B;;eA8Ic,MAAM;4BAAsB,OAAO;;;cA/E1C,MAAM;gBACJ,MAAM;;;wBA0GyC,MAAM;;;2BAapC,MAAM;uBACV,MAAM;;;sBAYP,YAAY;;CAoBnC,CAAC"}
@@ -0,0 +1,4 @@
1
+ export declare const SAMPLE_STORAGE_KEY = "LOG_ROCKET_SAMPLED";
2
+ export declare const DEFAULT_SAMPLE_RATE = 10;
3
+ export declare const SAMPLE_DECISION_TTL_MS: number;
4
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/initializers/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AACvD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AACtC,eAAO,MAAM,sBAAsB,QAA2B,CAAC"}
@@ -1,3 +1,3 @@
1
- import { logRocket } from "./logRocket";
2
- export { logRocket };
1
+ import { getLogRocketSessionURL, identifyLogRocketUser, initLogRocket, shutdownLogRocket } from "./logRocket";
2
+ export { getLogRocketSessionURL, identifyLogRocketUser, initLogRocket, shutdownLogRocket, };
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/initializers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,SAAS,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/initializers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,qBAAqB,EACrB,aAAa,EACb,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,sBAAsB,EACtB,qBAAqB,EACrB,aAAa,EACb,iBAAiB,GAClB,CAAC"}
@@ -1,2 +1,12 @@
1
- export declare const logRocket: () => void;
1
+ interface LogRocketUser {
2
+ userId: string;
3
+ name: string;
4
+ email: string;
5
+ subdomain: string;
6
+ }
7
+ export declare const getLogRocketSessionURL: () => string | null;
8
+ export declare const initLogRocket: () => Promise<void>;
9
+ export declare const identifyLogRocketUser: ({ userId, name, email, subdomain, }: LogRocketUser) => Promise<void>;
10
+ export declare const shutdownLogRocket: () => void;
11
+ export {};
2
12
  //# sourceMappingURL=logRocket.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"logRocket.d.ts","sourceRoot":"","sources":["../../../src/initializers/logRocket.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,SAAS,YAmBrB,CAAC"}
1
+ {"version":3,"file":"logRocket.d.ts","sourceRoot":"","sources":["../../../src/initializers/logRocket.ts"],"names":[],"mappings":"AAiBA,UAAU,aAAa;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAWD,eAAO,MAAM,sBAAsB,qBAAmB,CAAC;AA0BvD,eAAO,MAAM,aAAa,qBAyBzB,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAAU,qCAKzC,aAAa,kBAYf,CAAC;AAEF,eAAO,MAAM,iBAAiB,YAU7B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigbinary/neeto-commons-rn",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "license": "UNLICENSED",
5
5
  "main": "./src/index.ts",
6
6
  "react-native": "./src/index.ts",
@@ -2,17 +2,21 @@ import {
2
2
  serializeKeysToSnakeCase,
3
3
  keysToCamelCase,
4
4
  } from "@bigbinary/neeto-cist";
5
- import Bugsnag, { type NotifiableError } from "@bugsnag/react-native";
5
+ import Bugsnag from "@bugsnag/react-native";
6
6
 
7
7
  import { shouldEnableCaseConversion } from "./constants";
8
8
  import { type CaseConversionOptions } from "./types";
9
9
 
10
+ import { toNotifiableError } from "../bugsnag";
11
+
10
12
  const logCaseConversionError = (
11
13
  error: unknown,
12
14
  context: Record<string, unknown>
13
15
  ) => {
14
16
  if (!__DEV__) {
15
- Bugsnag.notify(error as NotifiableError, event => {
17
+ // Normalized rather than cast: a non-Error throw here would otherwise
18
+ // land in the same message-less "{}" group as everything else.
19
+ Bugsnag.notify(toNotifiableError(error), event => {
16
20
  event.severity = "warning";
17
21
  event.context = "case_conversion";
18
22
  event.addMetadata("caseConversion", {
@@ -18,3 +18,22 @@ export const shouldEnableCaseConversion = (
18
18
 
19
19
  return DEFAULT_CASE_CONVERSION;
20
20
  };
21
+
22
+ // Keys whose values must never leave the device on a Bugsnag report. Matched
23
+ // case-insensitively, because axios hands back "Authorization" as readily as
24
+ // "authorization" — and an exact-match list of literals let both that and
25
+ // `auth_token`, the field the login response actually uses (slices/auth.ts),
26
+ // through untouched.
27
+ export const SENSITIVE_KEY_PATTERNS = [
28
+ /^authorization$/,
29
+ /^(set-)?cookie$/,
30
+ /^x-auth-(token|email)$/,
31
+ /password/,
32
+ /(^|[-_])api[-_]?key$/,
33
+ /(^|[-_])(auth|access|refresh|session|id)[-_]?token$/,
34
+ /^token$/,
35
+ ];
36
+
37
+ export const REDACTED = "<redacted>";
38
+
39
+ export const MAX_REDACT_DEPTH = 6;
@@ -14,6 +14,7 @@ import { convertRequestKeys, convertResponseKeys } from "./caseConversion";
14
14
  import { normalizeUrlForGrouping } from "./groupingKey";
15
15
  import { handleNetworkError } from "./networkErrorHandler";
16
16
  import setParamsSerializer from "./paramsSerializer";
17
+ import { filterSensitiveData } from "./redact";
17
18
 
18
19
  import { STORAGE_KEYS } from "../../constants";
19
20
  import { removeItem } from "../../helpers/asyncStore";
@@ -31,37 +32,6 @@ export interface ApiErrorData {
31
32
  errors?: string[];
32
33
  }
33
34
 
34
- const filterSensitiveData = (data: unknown) => {
35
- if (!data) return null;
36
-
37
- const sensitiveKeys = [
38
- "x-auth-token",
39
- "x-auth-email",
40
- "password",
41
- "password_confirmation",
42
- "current_password",
43
- "token",
44
- "access_token",
45
- "refresh_token",
46
- ];
47
-
48
- const filteredData = { ...(data as Record<string, unknown>) };
49
-
50
- const filterNestedData = (obj: Record<string, unknown>) => {
51
- Object.keys(obj).forEach(key => {
52
- if (sensitiveKeys.includes(key)) {
53
- delete obj[key];
54
- } else if (obj[key] && typeof obj[key] === "object") {
55
- filterNestedData(obj[key] as Record<string, unknown>);
56
- }
57
- });
58
- };
59
-
60
- filterNestedData(filteredData);
61
-
62
- return filteredData;
63
- };
64
-
65
35
  const isAuthEndpoint = (url: string) =>
66
36
  url.includes("/api/mobile/v1/verify_otp") ||
67
37
  url.includes("/api/mobile/v1/generate_otp") ||
@@ -194,6 +164,12 @@ const registerResponseIntercept = () => {
194
164
  message,
195
165
  name,
196
166
  code,
167
+ // Captured before `report.context` is overwritten below. The
168
+ // react-navigation plugin puts the current screen there, and
169
+ // the synthetic error's stack points at this interceptor, so
170
+ // without this the report says nothing about where the request
171
+ // came from.
172
+ screen: report.context,
197
173
  requestUrl: config?.url,
198
174
  requestMethod: config?.method?.toUpperCase(),
199
175
  baseURL: config?.baseURL,
@@ -0,0 +1,50 @@
1
+ // Redaction for anything attached to a Bugsnag report: request headers,
2
+ // query params and response bodies all pass through here on the way out.
3
+
4
+ import {
5
+ MAX_REDACT_DEPTH,
6
+ REDACTED,
7
+ SENSITIVE_KEY_PATTERNS,
8
+ } from "./constants";
9
+
10
+ const isSensitiveKey = (key: string) => {
11
+ const normalized = key.toLowerCase();
12
+
13
+ return SENSITIVE_KEY_PATTERNS.some(pattern => pattern.test(normalized));
14
+ };
15
+
16
+ // Builds a redacted deep copy. The previous version spread only the top level
17
+ // and then deleted keys in place, so every nested object it walked was still
18
+ // the caller's own — redacting for a bug report mutated the live response
19
+ // body and request config. Cycles are cut rather than followed: axios configs
20
+ // and error responses can self-reference, and the recursion used to overflow.
21
+ const redact = (
22
+ value: unknown,
23
+ depth: number,
24
+ seen: WeakSet<object>
25
+ ): unknown => {
26
+ if (value === null || typeof value !== "object") return value;
27
+
28
+ if (depth > MAX_REDACT_DEPTH) return "<truncated>";
29
+
30
+ if (seen.has(value)) return "<circular>";
31
+
32
+ seen.add(value);
33
+
34
+ const redacted = Array.isArray(value)
35
+ ? value.map(item => redact(item, depth + 1, seen))
36
+ : Object.fromEntries(
37
+ Object.entries(value as Record<string, unknown>).map(([key, item]) => [
38
+ key,
39
+ isSensitiveKey(key) ? REDACTED : redact(item, depth + 1, seen),
40
+ ])
41
+ );
42
+
43
+ // Only ancestors can form a cycle; siblings sharing a reference are fine.
44
+ seen.delete(value);
45
+
46
+ return redacted;
47
+ };
48
+
49
+ export const filterSensitiveData = (data: unknown) =>
50
+ data ? redact(data, 0, new WeakSet<object>()) : null;
@@ -1,27 +1,80 @@
1
1
  import BugsnagPluginReactNavigation from "@bugsnag/plugin-react-navigation";
2
- import Bugsnag, { type NotifiableError } from "@bugsnag/react-native";
2
+ import Bugsnag, {
3
+ type Event,
4
+ type NotifiableError,
5
+ } from "@bugsnag/react-native";
6
+ import Config from "react-native-config";
3
7
 
4
8
  import {
5
9
  isEventErrorsNetworkRelated,
6
10
  isNetworkError,
7
11
  } from "./axios/networkErrorHandler";
8
12
 
13
+ import { getLogRocketSessionURL } from "../initializers/logRocket";
9
14
  import { Store } from "../store";
10
15
 
16
+ // Mirrors the stage the native layer reports. Host apps set ENVIRONMENT per
17
+ // flavour in their .env files and feed the same value to the native Bugsnag
18
+ // config; without it every build reported as "production" — the native
19
+ // default for a release build — so QA traffic from staging installs was
20
+ // indistinguishable from real user traffic in the dashboard.
21
+ const releaseStage = Config.ENVIRONMENT || "production";
22
+
23
+ // Errors we must see even though their message reads like network noise.
24
+ // isNetworkError matches /timeout/i, and FCM rejects token registration with
25
+ // "java.io.IOException: TIMEOUT" — so restoring a real message to these
26
+ // reports would otherwise make them disappear entirely, which is worse than
27
+ // the "{}" it replaced. The global onError filter runs *before* any
28
+ // per-notify callback (@bugsnag/core/client.js), so a callback cannot undo
29
+ // the drop; the marker has to travel on the error itself. A WeakSet keeps it
30
+ // off the reported payload.
31
+ const alwaysReported = new WeakSet<object>();
32
+
33
+ const isAlwaysReported = (error: unknown): boolean =>
34
+ typeof error === "object" && error !== null && alwaysReported.has(error);
35
+
36
+ // Survives a circular throw, and `undefined` rather than an empty message.
37
+ const describeThrown = (error: unknown): string => {
38
+ try {
39
+ return JSON.stringify(error) ?? String(error);
40
+ } catch {
41
+ return String(error);
42
+ }
43
+ };
44
+
45
+ // Normalizes a caught `unknown` before it reaches Bugsnag, and exempts it
46
+ // from the network filter above. Never hand a caught value to JSON.stringify
47
+ // on the way in: Bugsnag reads `message` and `stack` off an Error and both
48
+ // are non-enumerable, and NativeFirebaseError defines every one of its own
49
+ // fields (`code`, `userInfo`, `nativeErrorCode`) non-enumerable too. A
50
+ // stringified FCM rejection lands in the dashboard as the literal "{}" — no
51
+ // message, and a "(native)" line-0 frame that groups it with every other
52
+ // message-less report.
53
+ export const toNotifiableError = (error: unknown): Error => {
54
+ const normalized =
55
+ error instanceof Error ? error : new Error(describeThrown(error));
56
+ alwaysReported.add(normalized);
57
+
58
+ return normalized;
59
+ };
60
+
11
61
  export const bugsnagErrorHandler = (
12
62
  err: NotifiableError,
13
63
  origin: string,
14
64
  // Pass a hash for failures that would otherwise fragment into a group per
15
65
  // resource or per status code.
16
- groupingHash?: string
66
+ groupingHash?: string,
67
+ // Handled reports default to "warning"; pass "error" for a failure that
68
+ // breaks the feature rather than degrading it.
69
+ severity: Event["severity"] = "warning"
17
70
  ) => {
18
71
  if (!__DEV__) {
19
72
  Bugsnag.notify(err, event => {
20
- if (isNetworkError(err)) {
73
+ if (!isAlwaysReported(err) && isNetworkError(err)) {
21
74
  return false;
22
75
  }
23
76
 
24
- event.severity = "warning";
77
+ event.severity = severity;
25
78
  event.context = origin;
26
79
 
27
80
  if (groupingHash) {
@@ -35,6 +88,12 @@ export const bugsnagErrorHandler = (
35
88
 
36
89
  export const configureBugsnag = () => {
37
90
  if (!__DEV__) {
91
+ // releaseStage and enabledReleaseStages are deliberately absent: the RN
92
+ // notifier only accepts onError/onBreadcrumb/logger/metadata/user/
93
+ // context/plugins/codeBundleId/featureFlags here, because the native
94
+ // layer owns the stage so that native crashes are tagged correctly too.
95
+ // Host apps set it in AndroidManifest.xml and Info.plist; see
96
+ // docs/bugsnag-release-stages.md.
38
97
  Bugsnag.start({
39
98
  onError: event => {
40
99
  const auth = Store.getState().auth;
@@ -42,14 +101,34 @@ export const configureBugsnag = () => {
42
101
  event.setUser(auth.userId, auth.email, auth.name);
43
102
  }
44
103
 
45
- if (event.device?.emulator) {
104
+ // Only in production: QA runs release builds on emulators on purpose,
105
+ // and dropping those would hide the very failures they are hunting.
106
+ // Bugsnag's own flag is also unreliable — the Android 16 images QA
107
+ // uses report `emulator: false` — so this catches less than it looks
108
+ // like it does. Stage separation above is what actually keeps QA
109
+ // traffic out of the production dashboard.
110
+ if (releaseStage === "production" && event.device?.emulator) {
46
111
  return false;
47
112
  }
48
113
 
49
- if (isEventErrorsNetworkRelated(event)) {
114
+ // Handled axios noise only. This filter matches on message text
115
+ // (/timeout/i, /network error/i…), and an unhandled crash whose
116
+ // message happens to contain one of those words is still a crash —
117
+ // "Timeout of 30000ms exceeded while rendering TicketList" was being
118
+ // discarded outright.
119
+ if (
120
+ !event.unhandled &&
121
+ !isAlwaysReported(event.originalError) &&
122
+ isEventErrorsNetworkRelated(event)
123
+ ) {
50
124
  return false;
51
125
  }
52
126
 
127
+ const sessionURL = getLogRocketSessionURL();
128
+ if (sessionURL) {
129
+ event.addMetadata("LogRocket", { sessionURL });
130
+ }
131
+
53
132
  return true;
54
133
  },
55
134
  plugins: [new BugsnagPluginReactNavigation()],
@@ -13,7 +13,7 @@ import {
13
13
  type PressedNotification,
14
14
  } from "./types";
15
15
 
16
- import { bugsnagErrorHandler } from "../../config/bugsnag";
16
+ import { bugsnagErrorHandler, toNotifiableError } from "../../config/bugsnag";
17
17
  import { STORAGE_KEYS } from "../../constants";
18
18
  import { useNotification } from "../../hooks/query/useNotification";
19
19
  import { useAppState } from "../../hooks/useAppState";
@@ -52,7 +52,7 @@ export const usePushNotification = (
52
52
  const { status } = await checkNotifications();
53
53
  setNotificationPermission(status === RESULTS.GRANTED);
54
54
  } catch (error) {
55
- bugsnagErrorHandler(JSON.stringify(error), "notification");
55
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
56
56
  }
57
57
  };
58
58
 
@@ -194,7 +194,7 @@ export const usePushNotification = (
194
194
  }
195
195
  await handleTokenRegistration(token);
196
196
  } catch (error) {
197
- bugsnagErrorHandler(JSON.stringify(error), "notification");
197
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
198
198
  }
199
199
  }, [createChannels, handleTokenRegistration]);
200
200
 
@@ -166,7 +166,12 @@ export const useAuth = () => {
166
166
  text2: getErrorMessage(error?.response?.data, error),
167
167
  });
168
168
 
169
- bugsnagErrorHandler(error, "verify_otp_error");
169
+ // A 401 here is a wrong or expired OTP — a user typo, not a defect.
170
+ // Reporting it made this the single loudest error in the project. The
171
+ // axios interceptor already skips 401s; this path bypassed it.
172
+ if (status !== 401) {
173
+ bugsnagErrorHandler(error, "verify_otp_error");
174
+ }
170
175
  }
171
176
  };
172
177
 
@@ -16,7 +16,7 @@ import {
16
16
  fetchNotificationsAPI,
17
17
  markAllNotificationsReadAPI,
18
18
  } from "../../api/notifications";
19
- import { bugsnagErrorHandler } from "../../config/bugsnag";
19
+ import { bugsnagErrorHandler, toNotifiableError } from "../../config/bugsnag";
20
20
  import { STORAGE_KEYS, DEFAULT_PAGE_SIZE } from "../../constants";
21
21
  import { removeItem, setItem } from "../../helpers/asyncStore";
22
22
  import { removeSecureValue } from "../../helpers/keychain";
@@ -35,7 +35,7 @@ export const useNotification = () => {
35
35
  setItem(STORAGE_KEYS.DEVICE_TOKEN, response.device.device_token);
36
36
  },
37
37
  onError: error => {
38
- bugsnagErrorHandler(JSON.stringify(error), "notification");
38
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
39
39
  },
40
40
  }),
41
41
  useFetchNotifications: () => {
@@ -8,7 +8,7 @@ import {
8
8
  type PermissionStatus,
9
9
  } from "react-native-permissions";
10
10
 
11
- import { bugsnagErrorHandler } from "../config/bugsnag";
11
+ import { bugsnagErrorHandler, toNotifiableError } from "../config/bugsnag";
12
12
  import { STORAGE_KEYS } from "../constants";
13
13
  import { getItem, setItem } from "../helpers/asyncStore";
14
14
 
@@ -42,7 +42,7 @@ export const usePushNotificationPermissions = () => {
42
42
  status,
43
43
  };
44
44
  } catch (error) {
45
- bugsnagErrorHandler(JSON.stringify(error), "notification");
45
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
46
46
 
47
47
  return { isGranted: false };
48
48
  }
@@ -73,7 +73,7 @@ export const usePushNotificationPermissions = () => {
73
73
 
74
74
  return false;
75
75
  } catch (error) {
76
- bugsnagErrorHandler(JSON.stringify(error), "notification");
76
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
77
77
 
78
78
  return false;
79
79
  }
@@ -97,7 +97,7 @@ export const usePushNotificationPermissions = () => {
97
97
  setNotificationPermission(false);
98
98
  }
99
99
  } catch (error) {
100
- bugsnagErrorHandler(JSON.stringify(error), "notification");
100
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
101
101
  setNotificationPermission(false);
102
102
  }
103
103
  };
@@ -0,0 +1,3 @@
1
+ export const SAMPLE_STORAGE_KEY = "LOG_ROCKET_SAMPLED";
2
+ export const DEFAULT_SAMPLE_RATE = 10;
3
+ export const SAMPLE_DECISION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -1,3 +1,13 @@
1
- import { logRocket } from "./logRocket";
1
+ import {
2
+ getLogRocketSessionURL,
3
+ identifyLogRocketUser,
4
+ initLogRocket,
5
+ shutdownLogRocket,
6
+ } from "./logRocket";
2
7
 
3
- export { logRocket };
8
+ export {
9
+ getLogRocketSessionURL,
10
+ identifyLogRocketUser,
11
+ initLogRocket,
12
+ shutdownLogRocket,
13
+ };
@@ -1,25 +1,116 @@
1
1
  import LogRocket from "@logrocket/react-native";
2
2
  import Config from "react-native-config";
3
3
 
4
- import { Store } from "../store";
5
-
6
- export const logRocket = () => {
7
- if (!Config.LOG_ROCKET_API_KEY || __DEV__) return;
8
-
9
- const { isLoggedIn, userId, name, email, subdomain } = Store.getState().auth;
10
-
11
- LogRocket.init(Config.LOG_ROCKET_API_KEY);
12
-
13
- if (!isLoggedIn) {
14
- //End the current session gracefully during logout
15
- LogRocket.shutdown();
16
- // Reinitialize LogRocket to capture the login screen after logout
17
- LogRocket.init(Config.LOG_ROCKET_API_KEY);
18
- } else {
19
- LogRocket.identify(userId, {
20
- name,
21
- email,
22
- organization: subdomain,
23
- });
4
+ import {
5
+ DEFAULT_SAMPLE_RATE,
6
+ SAMPLE_DECISION_TTL_MS,
7
+ SAMPLE_STORAGE_KEY,
8
+ } from "./constants";
9
+
10
+ import { getItem, setItem } from "../helpers/asyncStore";
11
+
12
+ interface SampleDecision {
13
+ sampled: boolean;
14
+ rate: number;
15
+ rolledAt: number;
16
+ }
17
+
18
+ interface LogRocketUser {
19
+ userId: string;
20
+ name: string;
21
+ email: string;
22
+ subdomain: string;
23
+ }
24
+
25
+ let initialized = false;
26
+ let initPromise: Promise<void> | null = null;
27
+ let sessionURL: string | null = null;
28
+ // Bumped by shutdownLogRocket so an init still awaiting the sampling roll
29
+ // knows it was cancelled and must not start recording.
30
+ let generation = 0;
31
+
32
+ // Null until LogRocket's backend accepts the session (1-5s after init), and
33
+ // null on unsampled devices — callers must handle the absence.
34
+ export const getLogRocketSessionURL = () => sessionURL;
35
+
36
+ // 0 falls back to the default: turning recording off is done by removing or
37
+ // rotating the API key, never through the sample rate.
38
+ const getSampleRate = () =>
39
+ parseInt(Config.LOG_ROCKET_SAMPLE_RATE ?? "", 10) || DEFAULT_SAMPLE_RATE;
40
+
41
+ // A device keeps its decision until the TTL passes or the rate changes, so
42
+ // the sampled population rotates instead of locking in the first 10% forever.
43
+ const shouldSample = async () => {
44
+ const rate = getSampleRate();
45
+ const stored = await getItem<SampleDecision>(SAMPLE_STORAGE_KEY);
46
+
47
+ const isCurrent =
48
+ stored !== null &&
49
+ stored.rate === rate &&
50
+ Date.now() - stored.rolledAt < SAMPLE_DECISION_TTL_MS;
51
+
52
+ if (isCurrent) return stored.sampled;
53
+
54
+ const sampled = Math.random() * 100 < rate;
55
+ await setItem(SAMPLE_STORAGE_KEY, { sampled, rate, rolledAt: Date.now() });
56
+
57
+ return sampled;
58
+ };
59
+
60
+ export const initLogRocket = async () => {
61
+ const apiKey = Config.LOG_ROCKET_API_KEY;
62
+ if (initialized || __DEV__ || !apiKey) return;
63
+
64
+ initPromise ??= (async () => {
65
+ try {
66
+ const startedAs = generation;
67
+ if (!(await shouldSample()) || startedAs !== generation) return;
68
+
69
+ LogRocket.init(apiKey);
70
+ initialized = true;
71
+ LogRocket.getSessionURL(url => {
72
+ // The backend accepts the session 1-5s after init; by then a logout
73
+ // may have ended it, and its URL must not resurface.
74
+ if (startedAs === generation) {
75
+ sessionURL = url;
76
+ }
77
+ });
78
+ } catch {
79
+ // Recording is expendable; a LogRocket failure must never reject the
80
+ // promise that initLogRocket and identifyLogRocketUser await.
81
+ }
82
+ })();
83
+
84
+ await initPromise;
85
+ };
86
+
87
+ export const identifyLogRocketUser = async ({
88
+ userId,
89
+ name,
90
+ email,
91
+ subdomain,
92
+ }: LogRocketUser) => {
93
+ // Callers invoke this right after initLogRocket(), often in the same tick;
94
+ // waiting on the pending init keeps that pattern from dropping the identity.
95
+ if (initPromise) await initPromise;
96
+
97
+ if (!initialized) return;
98
+
99
+ try {
100
+ LogRocket.identify(userId, { name, email, organization: subdomain });
101
+ } catch {
102
+ // Fire-and-forget callers must never see a rejection from LogRocket.
24
103
  }
25
104
  };
105
+
106
+ export const shutdownLogRocket = () => {
107
+ generation += 1;
108
+ initPromise = null;
109
+ // The replay ends here, so an error after logout must not link to it.
110
+ sessionURL = null;
111
+
112
+ if (!initialized) return;
113
+
114
+ LogRocket.shutdown();
115
+ initialized = false;
116
+ };