@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.
- package/lib/typescript/config/axios/caseConversion.d.ts.map +1 -1
- package/lib/typescript/config/axios/constants.d.ts +3 -0
- package/lib/typescript/config/axios/constants.d.ts.map +1 -1
- package/lib/typescript/config/axios/index.d.ts.map +1 -1
- package/lib/typescript/config/axios/redact.d.ts +2 -0
- package/lib/typescript/config/axios/redact.d.ts.map +1 -0
- package/lib/typescript/config/bugsnag.d.ts +3 -2
- package/lib/typescript/config/bugsnag.d.ts.map +1 -1
- package/lib/typescript/hooks/query/useAuth.d.ts.map +1 -1
- package/lib/typescript/initializers/constants.d.ts +4 -0
- package/lib/typescript/initializers/constants.d.ts.map +1 -0
- package/lib/typescript/initializers/index.d.ts +2 -2
- package/lib/typescript/initializers/index.d.ts.map +1 -1
- package/lib/typescript/initializers/logRocket.d.ts +11 -1
- package/lib/typescript/initializers/logRocket.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config/axios/caseConversion.ts +6 -2
- package/src/config/axios/constants.ts +19 -0
- package/src/config/axios/index.ts +7 -31
- package/src/config/axios/redact.ts +50 -0
- package/src/config/bugsnag.ts +85 -6
- package/src/helpers/notifications/usePushNotification.ts +3 -3
- package/src/hooks/query/useAuth.ts +6 -1
- package/src/hooks/query/useNotification.ts +2 -2
- package/src/hooks/usePushNotificationPermissions.ts +4 -4
- package/src/initializers/constants.ts +3 -0
- package/src/initializers/index.ts +12 -2
- package/src/initializers/logRocket.ts +111 -20
|
@@ -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;
|
|
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;
|
|
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 @@
|
|
|
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
|
|
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,
|
|
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;;
|
|
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 @@
|
|
|
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 {
|
|
2
|
-
export {
|
|
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,
|
|
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
|
-
|
|
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":"
|
|
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
|
@@ -2,17 +2,21 @@ import {
|
|
|
2
2
|
serializeKeysToSnakeCase,
|
|
3
3
|
keysToCamelCase,
|
|
4
4
|
} from "@bigbinary/neeto-cist";
|
|
5
|
-
import Bugsnag
|
|
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
|
-
|
|
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;
|
package/src/config/bugsnag.ts
CHANGED
|
@@ -1,27 +1,80 @@
|
|
|
1
1
|
import BugsnagPluginReactNavigation from "@bugsnag/plugin-react-navigation";
|
|
2
|
-
import Bugsnag, {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
100
|
+
bugsnagErrorHandler(toNotifiableError(error), "notification");
|
|
101
101
|
setNotificationPermission(false);
|
|
102
102
|
}
|
|
103
103
|
};
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getLogRocketSessionURL,
|
|
3
|
+
identifyLogRocketUser,
|
|
4
|
+
initLogRocket,
|
|
5
|
+
shutdownLogRocket,
|
|
6
|
+
} from "./logRocket";
|
|
2
7
|
|
|
3
|
-
export {
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
};
|