@fixback/expo 0.1.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 +118 -0
- package/dist/FeedbackModal.d.ts +23 -0
- package/dist/FeedbackModal.js +201 -0
- package/dist/FixbackProvider.d.ts +35 -0
- package/dist/FixbackProvider.js +102 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.js +143 -0
- package/dist/auto-report-backoff.d.ts +42 -0
- package/dist/auto-report-backoff.js +67 -0
- package/dist/boot.d.ts +65 -0
- package/dist/boot.js +60 -0
- package/dist/breadcrumbs.d.ts +276 -0
- package/dist/breadcrumbs.js +731 -0
- package/dist/client.d.ts +174 -0
- package/dist/client.js +351 -0
- package/dist/error-capture.d.ts +168 -0
- package/dist/error-capture.js +395 -0
- package/dist/http.d.ts +40 -0
- package/dist/http.js +20 -0
- package/dist/identity.d.ts +25 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +10 -0
- package/dist/report.d.ts +93 -0
- package/dist/report.js +67 -0
- package/dist/scrub.d.ts +55 -0
- package/dist/scrub.js +184 -0
- package/dist/shake.d.ts +51 -0
- package/dist/shake.js +79 -0
- package/dist/submit.d.ts +98 -0
- package/dist/submit.js +154 -0
- package/dist/tokens.d.ts +41 -0
- package/dist/tokens.js +46 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +13 -0
- package/package.json +63 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic error capture on React Native — ADR-0011's model with mobile
|
|
3
|
+
* triggers (spec 0004 §E), ported from `packages/sdk/src/error-capture.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Uncaught JS errors arrive through `ErrorUtils.setGlobalHandler` — React
|
|
6
|
+
* Native's one global error seam — and become `source: auto`, `Kind = bug`
|
|
7
|
+
* Feedback for the current session's Reporter. The **previous handler is always
|
|
8
|
+
* chained afterwards**, so dev redboxes and other crash reporters keep working.
|
|
9
|
+
* Each firing is deduped by a per-session fingerprint, rate-limited by a
|
|
10
|
+
* token-bucket burst limiter and a per-session cap, scrubbed through the same
|
|
11
|
+
* `beforeSend` choke point as manual reports, and shipped through the same
|
|
12
|
+
* transport (which honours ingest's `429` / `Retry-After` backpressure).
|
|
13
|
+
*
|
|
14
|
+
* Per-firing order: **`canSubmit`/Gate → dedup → rate-limit/cap → `beforeSend`
|
|
15
|
+
* scrub → enqueue Feedback**. The Gate is honoured by construction: the client
|
|
16
|
+
* installs this only when boot returned `canSubmit`. Occurrence-count flushes
|
|
17
|
+
* ride `AppState` → `background`/`inactive` (the `pagehide` equivalent).
|
|
18
|
+
* Unhandled promise rejections have no stable public Hermes hook and are not
|
|
19
|
+
* captured (ADR-0021 "Revisit when").
|
|
20
|
+
*
|
|
21
|
+
* The whole module is defensive — a Fixback problem (or an error thrown while
|
|
22
|
+
* capturing an error) never surfaces in the host app.
|
|
23
|
+
*/
|
|
24
|
+
import type { IdentityInputs } from "./boot";
|
|
25
|
+
import { type BreadcrumbBuffer, type Teardown } from "./breadcrumbs";
|
|
26
|
+
import { type EnvironmentInputs } from "./report";
|
|
27
|
+
import { type BeforeSend } from "./scrub";
|
|
28
|
+
import { type SubmitDeps, type SubmitInput, type SubmitResult } from "./submit";
|
|
29
|
+
export type { Teardown };
|
|
30
|
+
/** Burst limiter capacity — how many auto-reports may fire back-to-back. */
|
|
31
|
+
export declare const DEFAULT_BURST_CAPACITY = 5;
|
|
32
|
+
/** Burst limiter refill — one token returns every this-many ms. */
|
|
33
|
+
export declare const DEFAULT_BURST_REFILL_MS = 2000;
|
|
34
|
+
/** Per-session ceiling on distinct auto-Feedback; beyond it, only a dropped-count. */
|
|
35
|
+
export declare const DEFAULT_MAX_DISTINCT_AUTO = 20;
|
|
36
|
+
/**
|
|
37
|
+
* Collapse the volatile parts of an error message so a changing string doesn't
|
|
38
|
+
* split one bug: UUIDs, URLs, `0x…` and long hex runs, and long digit runs are
|
|
39
|
+
* replaced with stable placeholders. Short numbers and stable text are kept so
|
|
40
|
+
* genuinely distinct bugs stay distinct.
|
|
41
|
+
*/
|
|
42
|
+
export declare function normalize(value: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* A dependency-free FNV-1a hash rendered in base-36. It only has to be stable
|
|
45
|
+
* and well-distributed within one session (the client key is a flood guard; the
|
|
46
|
+
* server does canonical cross-session clustering).
|
|
47
|
+
*/
|
|
48
|
+
export declare function hashString(input: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Extract a compact, stable signature of the top frames of a stack: up to
|
|
51
|
+
* {@link FINGERPRINT_FRAME_LIMIT} frames as `function@basename:line:col`,
|
|
52
|
+
* origin and cache-busting query stripped. Returns `""` when there is no
|
|
53
|
+
* usable stack (message-only fallback).
|
|
54
|
+
*/
|
|
55
|
+
export declare function extractTopFrames(stack: string | undefined, limit?: number): string;
|
|
56
|
+
/**
|
|
57
|
+
* The per-session fingerprint:
|
|
58
|
+
* `hash(errorType + "|" + normalize(value) + "|" + topFrames)`. Stack frames
|
|
59
|
+
* dominate when present; otherwise it falls back to type + normalized value.
|
|
60
|
+
*/
|
|
61
|
+
export declare function computeFingerprint(type: string, value: string, stack?: string): string;
|
|
62
|
+
/** Configuration for {@link TokenBucket}. */
|
|
63
|
+
export interface TokenBucketOptions {
|
|
64
|
+
readonly capacity: number;
|
|
65
|
+
readonly refillIntervalMs: number;
|
|
66
|
+
/** Clock source, injectable for tests. Defaults to `Date.now`. */
|
|
67
|
+
readonly now?: () => number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A token bucket: starts full at `capacity`, refills one token every
|
|
71
|
+
* `refillIntervalMs`, and refuses (`take() === false`) when empty. So a fast
|
|
72
|
+
* error loop that dodges dedup with distinct fingerprints still can't
|
|
73
|
+
* machine-gun ingest.
|
|
74
|
+
*/
|
|
75
|
+
export declare class TokenBucket {
|
|
76
|
+
private readonly options;
|
|
77
|
+
private tokens;
|
|
78
|
+
private last;
|
|
79
|
+
private readonly now;
|
|
80
|
+
constructor(options: TokenBucketOptions);
|
|
81
|
+
/** Consume a token if one is available (refilling first), else refuse. */
|
|
82
|
+
take(): boolean;
|
|
83
|
+
}
|
|
84
|
+
/** React Native's global error handler signature. */
|
|
85
|
+
export type GlobalErrorHandler = (error: unknown, isFatal?: boolean) => void;
|
|
86
|
+
/** The `ErrorUtils` surface the capture uses — injectable for tests. */
|
|
87
|
+
export interface ErrorUtilsLike {
|
|
88
|
+
getGlobalHandler?: () => GlobalErrorHandler | null | undefined;
|
|
89
|
+
setGlobalHandler: (handler: GlobalErrorHandler | null) => void;
|
|
90
|
+
}
|
|
91
|
+
/** React Native's global `ErrorUtils`, when present. */
|
|
92
|
+
export declare function globalErrorUtils(): ErrorUtilsLike | undefined;
|
|
93
|
+
/** A removable native event subscription. */
|
|
94
|
+
export interface AppStateSubscription {
|
|
95
|
+
remove(): void;
|
|
96
|
+
}
|
|
97
|
+
/** The `AppState` slice the flush hook uses — injectable for tests. */
|
|
98
|
+
export interface AppStateLike {
|
|
99
|
+
addEventListener(type: "change", listener: (state: string) => void): AppStateSubscription;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Injectable collaborators, defaulted to the real implementations.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately screenshot-free: a `source: auto` report on mobile never
|
|
105
|
+
* attaches a screenshot. The composer's compensating privacy controls —
|
|
106
|
+
* preview and removal by the Reporter — cannot exist on a machine-filed
|
|
107
|
+
* report, and there is no client-side masking on mobile, so an auto
|
|
108
|
+
* screenshot would ship whatever sensitive screen happened to be open
|
|
109
|
+
* (ADR-0021; spec 0004 §E).
|
|
110
|
+
*/
|
|
111
|
+
export interface AutoCaptureDeps {
|
|
112
|
+
readonly submitReport: (apiUrl: string, input: SubmitInput, deps?: SubmitDeps) => Promise<SubmitResult>;
|
|
113
|
+
}
|
|
114
|
+
/** The Reporter's self-provided display name / email — display only. */
|
|
115
|
+
export interface ReporterDisplay {
|
|
116
|
+
readonly name?: string;
|
|
117
|
+
readonly email?: string;
|
|
118
|
+
}
|
|
119
|
+
/** Configuration for {@link installErrorCapture}. */
|
|
120
|
+
export interface AutoCaptureConfig {
|
|
121
|
+
readonly apiUrl: string;
|
|
122
|
+
readonly key: string;
|
|
123
|
+
/** The configured origin, forwarded on every submission (spec 0004 §A). */
|
|
124
|
+
readonly origin: string;
|
|
125
|
+
readonly identity?: IdentityInputs;
|
|
126
|
+
/** Display-only name/email carried on auto-captured Feedback too. */
|
|
127
|
+
readonly display?: ReporterDisplay;
|
|
128
|
+
readonly sdkVersion?: string;
|
|
129
|
+
/** The shared trace buffer; the failing error is added to it before filing. */
|
|
130
|
+
readonly buffer?: BreadcrumbBuffer | null;
|
|
131
|
+
/** Per-project client scrub hook, run at the `beforeSend` choke point. */
|
|
132
|
+
readonly beforeSend?: BeforeSend;
|
|
133
|
+
/** Run the built-in default scrubbers. Defaults to `true`. */
|
|
134
|
+
readonly scrub?: boolean;
|
|
135
|
+
/** Reads the device environment at filing time. */
|
|
136
|
+
readonly environment?: () => EnvironmentInputs;
|
|
137
|
+
/** The report `url` at filing time (`<origin>/<screen>` or the origin). */
|
|
138
|
+
readonly currentUrl?: () => string | undefined;
|
|
139
|
+
/** The `ErrorUtils` seam. Defaults to the React Native global. */
|
|
140
|
+
readonly errorUtils?: ErrorUtilsLike | null;
|
|
141
|
+
/** The `AppState` seam for occurrence flushes. Optional. */
|
|
142
|
+
readonly appState?: AppStateLike | null;
|
|
143
|
+
readonly deps?: Partial<AutoCaptureDeps>;
|
|
144
|
+
/** Burst limiter capacity. Defaults to {@link DEFAULT_BURST_CAPACITY}. */
|
|
145
|
+
readonly burstCapacity?: number;
|
|
146
|
+
/** Burst limiter refill interval (ms). Defaults to {@link DEFAULT_BURST_REFILL_MS}. */
|
|
147
|
+
readonly burstRefillMs?: number;
|
|
148
|
+
/** Per-session distinct-Feedback cap. Defaults to {@link DEFAULT_MAX_DISTINCT_AUTO}. */
|
|
149
|
+
readonly maxDistinct?: number;
|
|
150
|
+
/** Clock source, injectable for tests. Defaults to `Date.now`. */
|
|
151
|
+
readonly now?: () => number;
|
|
152
|
+
}
|
|
153
|
+
/** A running auto-capture: its teardown, plus the local dropped-count. */
|
|
154
|
+
export interface AutoCaptureHandle {
|
|
155
|
+
/** Restore the previous handler and remove the flush hook. Safe to repeat. */
|
|
156
|
+
readonly destroy: Teardown;
|
|
157
|
+
/** Distinct auto-Feedback dropped by the burst limiter or the session cap. */
|
|
158
|
+
droppedCount(): number;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Install automatic error capture through `ErrorUtils` and return a handle
|
|
162
|
+
* whose `destroy` restores the previous global handler and removes the flush
|
|
163
|
+
* hook. The caller installs this only after boot returned `canSubmit`, so the
|
|
164
|
+
* Gate is respected and the auto-error inherits the session Reporter's tier.
|
|
165
|
+
* Without a usable `ErrorUtils` (no `getGlobalHandler` to restore from) nothing
|
|
166
|
+
* is installed — the SDK never replaces a handler it could not put back.
|
|
167
|
+
*/
|
|
168
|
+
export declare function installErrorCapture(config: AutoCaptureConfig): AutoCaptureHandle;
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic error capture on React Native — ADR-0011's model with mobile
|
|
3
|
+
* triggers (spec 0004 §E), ported from `packages/sdk/src/error-capture.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Uncaught JS errors arrive through `ErrorUtils.setGlobalHandler` — React
|
|
6
|
+
* Native's one global error seam — and become `source: auto`, `Kind = bug`
|
|
7
|
+
* Feedback for the current session's Reporter. The **previous handler is always
|
|
8
|
+
* chained afterwards**, so dev redboxes and other crash reporters keep working.
|
|
9
|
+
* Each firing is deduped by a per-session fingerprint, rate-limited by a
|
|
10
|
+
* token-bucket burst limiter and a per-session cap, scrubbed through the same
|
|
11
|
+
* `beforeSend` choke point as manual reports, and shipped through the same
|
|
12
|
+
* transport (which honours ingest's `429` / `Retry-After` backpressure).
|
|
13
|
+
*
|
|
14
|
+
* Per-firing order: **`canSubmit`/Gate → dedup → rate-limit/cap → `beforeSend`
|
|
15
|
+
* scrub → enqueue Feedback**. The Gate is honoured by construction: the client
|
|
16
|
+
* installs this only when boot returned `canSubmit`. Occurrence-count flushes
|
|
17
|
+
* ride `AppState` → `background`/`inactive` (the `pagehide` equivalent).
|
|
18
|
+
* Unhandled promise rejections have no stable public Hermes hook and are not
|
|
19
|
+
* captured (ADR-0021 "Revisit when").
|
|
20
|
+
*
|
|
21
|
+
* The whole module is defensive — a Fixback problem (or an error thrown while
|
|
22
|
+
* capturing an error) never surfaces in the host app.
|
|
23
|
+
*/
|
|
24
|
+
import { errorCrumb, } from "./breadcrumbs";
|
|
25
|
+
import { assembleContent, collectEnvironment, } from "./report";
|
|
26
|
+
import { runBeforeSend } from "./scrub";
|
|
27
|
+
import { submitReport, } from "./submit";
|
|
28
|
+
import { EXPO_SDK_VERSION } from "./version";
|
|
29
|
+
/** Burst limiter capacity — how many auto-reports may fire back-to-back. */
|
|
30
|
+
export const DEFAULT_BURST_CAPACITY = 5;
|
|
31
|
+
/** Burst limiter refill — one token returns every this-many ms. */
|
|
32
|
+
export const DEFAULT_BURST_REFILL_MS = 2_000;
|
|
33
|
+
/** Per-session ceiling on distinct auto-Feedback; beyond it, only a dropped-count. */
|
|
34
|
+
export const DEFAULT_MAX_DISTINCT_AUTO = 20;
|
|
35
|
+
/** How many top stack frames feed the fingerprint. */
|
|
36
|
+
const FINGERPRINT_FRAME_LIMIT = 5;
|
|
37
|
+
/** How many preceding entries an auto-error's causal pointer names. */
|
|
38
|
+
const CAUSED_BY_LIMIT = 5;
|
|
39
|
+
/**
|
|
40
|
+
* The ids of up to the last {@link CAUSED_BY_LIMIT} entries preceding the
|
|
41
|
+
* throw, in time order — the causal pointer an auto-captured error carries so a
|
|
42
|
+
* machine-filed crash names its lead-up. Entries without an id are skipped.
|
|
43
|
+
*/
|
|
44
|
+
function precedingIds(entries) {
|
|
45
|
+
if (!entries || entries.length === 0)
|
|
46
|
+
return [];
|
|
47
|
+
const ids = [];
|
|
48
|
+
for (let i = entries.length - 1; i >= 0 && ids.length < CAUSED_BY_LIMIT; i -= 1) {
|
|
49
|
+
const id = entries[i]?.id;
|
|
50
|
+
if (typeof id === "string" && id.length > 0)
|
|
51
|
+
ids.unshift(id);
|
|
52
|
+
}
|
|
53
|
+
return ids;
|
|
54
|
+
}
|
|
55
|
+
// --- Fingerprint (per-session flood-guard key) --------------------------------
|
|
56
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
57
|
+
const URL_RE = /\bhttps?:\/\/[^\s"')]+/gi;
|
|
58
|
+
const HEX_0X_RE = /\b0x[0-9a-f]+\b/gi;
|
|
59
|
+
const HEX_RUN_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
60
|
+
const DIGIT_RUN_RE = /\d{4,}/g;
|
|
61
|
+
/**
|
|
62
|
+
* Collapse the volatile parts of an error message so a changing string doesn't
|
|
63
|
+
* split one bug: UUIDs, URLs, `0x…` and long hex runs, and long digit runs are
|
|
64
|
+
* replaced with stable placeholders. Short numbers and stable text are kept so
|
|
65
|
+
* genuinely distinct bugs stay distinct.
|
|
66
|
+
*/
|
|
67
|
+
export function normalize(value) {
|
|
68
|
+
if (typeof value !== "string" || value.length === 0)
|
|
69
|
+
return "";
|
|
70
|
+
return value
|
|
71
|
+
.replace(UUID_RE, "<uuid>")
|
|
72
|
+
.replace(URL_RE, "<url>")
|
|
73
|
+
.replace(HEX_0X_RE, "<hex>")
|
|
74
|
+
.replace(HEX_RUN_RE, "<hex>")
|
|
75
|
+
.replace(DIGIT_RUN_RE, "<n>")
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A dependency-free FNV-1a hash rendered in base-36. It only has to be stable
|
|
80
|
+
* and well-distributed within one session (the client key is a flood guard; the
|
|
81
|
+
* server does canonical cross-session clustering).
|
|
82
|
+
*/
|
|
83
|
+
export function hashString(input) {
|
|
84
|
+
let h = 0x811c9dc5;
|
|
85
|
+
for (let i = 0; i < input.length; i++) {
|
|
86
|
+
h ^= input.charCodeAt(i);
|
|
87
|
+
h = Math.imul(h, 0x01000193);
|
|
88
|
+
}
|
|
89
|
+
return (h >>> 0).toString(36);
|
|
90
|
+
}
|
|
91
|
+
/** Reduce a frame location to `basename:line:col`, dropping origin and query. */
|
|
92
|
+
function compactLocation(location) {
|
|
93
|
+
const noQuery = location.replace(/\?[^:]*/, "");
|
|
94
|
+
const lastSlash = noQuery.lastIndexOf("/");
|
|
95
|
+
return lastSlash >= 0 ? noQuery.slice(lastSlash + 1) : noQuery;
|
|
96
|
+
}
|
|
97
|
+
/** Parse one stack line into a compact `function@basename:line:col` frame id. */
|
|
98
|
+
function parseFrame(line) {
|
|
99
|
+
// V8 / Hermes: "at fn (loc)" | "at loc"
|
|
100
|
+
const v8Named = line.match(/^at\s+(.+?)\s+\((.+)\)$/);
|
|
101
|
+
if (v8Named)
|
|
102
|
+
return `${v8Named[1] ?? ""}@${compactLocation(v8Named[2] ?? "")}`;
|
|
103
|
+
const v8Bare = line.match(/^at\s+(.+)$/);
|
|
104
|
+
if (v8Bare)
|
|
105
|
+
return `@${compactLocation(v8Bare[1] ?? "")}`;
|
|
106
|
+
// JSC: "fn@loc" | "@loc"
|
|
107
|
+
const at = line.indexOf("@");
|
|
108
|
+
if (at >= 0) {
|
|
109
|
+
const fn = line.slice(0, at);
|
|
110
|
+
return `${fn}@${compactLocation(line.slice(at + 1))}`;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Extract a compact, stable signature of the top frames of a stack: up to
|
|
116
|
+
* {@link FINGERPRINT_FRAME_LIMIT} frames as `function@basename:line:col`,
|
|
117
|
+
* origin and cache-busting query stripped. Returns `""` when there is no
|
|
118
|
+
* usable stack (message-only fallback).
|
|
119
|
+
*/
|
|
120
|
+
export function extractTopFrames(stack, limit = FINGERPRINT_FRAME_LIMIT) {
|
|
121
|
+
if (typeof stack !== "string" || stack.length === 0)
|
|
122
|
+
return "";
|
|
123
|
+
const frames = [];
|
|
124
|
+
for (const raw of stack.split("\n")) {
|
|
125
|
+
const frame = parseFrame(raw.trim());
|
|
126
|
+
if (frame) {
|
|
127
|
+
frames.push(frame);
|
|
128
|
+
if (frames.length >= limit)
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return frames.join(" < ");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The per-session fingerprint:
|
|
136
|
+
* `hash(errorType + "|" + normalize(value) + "|" + topFrames)`. Stack frames
|
|
137
|
+
* dominate when present; otherwise it falls back to type + normalized value.
|
|
138
|
+
*/
|
|
139
|
+
export function computeFingerprint(type, value, stack) {
|
|
140
|
+
return hashString(`${type}|${normalize(value)}|${extractTopFrames(stack)}`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A token bucket: starts full at `capacity`, refills one token every
|
|
144
|
+
* `refillIntervalMs`, and refuses (`take() === false`) when empty. So a fast
|
|
145
|
+
* error loop that dodges dedup with distinct fingerprints still can't
|
|
146
|
+
* machine-gun ingest.
|
|
147
|
+
*/
|
|
148
|
+
export class TokenBucket {
|
|
149
|
+
options;
|
|
150
|
+
tokens;
|
|
151
|
+
last;
|
|
152
|
+
now;
|
|
153
|
+
constructor(options) {
|
|
154
|
+
this.options = options;
|
|
155
|
+
this.now = options.now ?? Date.now;
|
|
156
|
+
this.tokens = Math.max(0, options.capacity);
|
|
157
|
+
this.last = this.now();
|
|
158
|
+
}
|
|
159
|
+
/** Consume a token if one is available (refilling first), else refuse. */
|
|
160
|
+
take() {
|
|
161
|
+
const now = this.now();
|
|
162
|
+
const { capacity, refillIntervalMs } = this.options;
|
|
163
|
+
if (refillIntervalMs > 0 && now > this.last) {
|
|
164
|
+
const refill = Math.floor((now - this.last) / refillIntervalMs);
|
|
165
|
+
if (refill > 0) {
|
|
166
|
+
this.tokens = Math.min(capacity, this.tokens + refill);
|
|
167
|
+
this.last += refill * refillIntervalMs;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (this.tokens >= 1) {
|
|
171
|
+
this.tokens -= 1;
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function asString(value) {
|
|
178
|
+
if (typeof value === "string")
|
|
179
|
+
return value;
|
|
180
|
+
if (value == null)
|
|
181
|
+
return "";
|
|
182
|
+
try {
|
|
183
|
+
return String(value);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Distil whatever `ErrorUtils` handed us into a fingerprint-able shape. */
|
|
190
|
+
function extractGlobalError(error) {
|
|
191
|
+
if (error && typeof error === "object") {
|
|
192
|
+
const err = error;
|
|
193
|
+
return {
|
|
194
|
+
type: asString(err.name) || "Error",
|
|
195
|
+
value: asString(err.message) || asString(error),
|
|
196
|
+
stack: typeof err.stack === "string" ? err.stack : undefined,
|
|
197
|
+
original: error,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const value = asString(error);
|
|
201
|
+
return { type: "Error", value, original: error ?? value };
|
|
202
|
+
}
|
|
203
|
+
/** React Native's global `ErrorUtils`, when present. */
|
|
204
|
+
export function globalErrorUtils() {
|
|
205
|
+
const g = globalThis;
|
|
206
|
+
const utils = g.ErrorUtils;
|
|
207
|
+
return utils && typeof utils.setGlobalHandler === "function" ? utils : undefined;
|
|
208
|
+
}
|
|
209
|
+
const NOOP_HANDLE = { destroy: () => { }, droppedCount: () => 0 };
|
|
210
|
+
/**
|
|
211
|
+
* Install automatic error capture through `ErrorUtils` and return a handle
|
|
212
|
+
* whose `destroy` restores the previous global handler and removes the flush
|
|
213
|
+
* hook. The caller installs this only after boot returned `canSubmit`, so the
|
|
214
|
+
* Gate is respected and the auto-error inherits the session Reporter's tier.
|
|
215
|
+
* Without a usable `ErrorUtils` (no `getGlobalHandler` to restore from) nothing
|
|
216
|
+
* is installed — the SDK never replaces a handler it could not put back.
|
|
217
|
+
*/
|
|
218
|
+
export function installErrorCapture(config) {
|
|
219
|
+
const errorUtils = config.errorUtils === null ? undefined : config.errorUtils ?? globalErrorUtils();
|
|
220
|
+
if (!errorUtils ||
|
|
221
|
+
typeof errorUtils.setGlobalHandler !== "function" ||
|
|
222
|
+
typeof errorUtils.getGlobalHandler !== "function") {
|
|
223
|
+
return NOOP_HANDLE;
|
|
224
|
+
}
|
|
225
|
+
const now = config.now ?? Date.now;
|
|
226
|
+
const sdkVersion = config.sdkVersion ?? EXPO_SDK_VERSION;
|
|
227
|
+
const maxDistinct = config.maxDistinct ?? DEFAULT_MAX_DISTINCT_AUTO;
|
|
228
|
+
const submitReportFn = config.deps?.submitReport ?? submitReport;
|
|
229
|
+
const readEnvironment = config.environment ?? (() => ({}));
|
|
230
|
+
const bucket = new TokenBucket({
|
|
231
|
+
capacity: config.burstCapacity ?? DEFAULT_BURST_CAPACITY,
|
|
232
|
+
refillIntervalMs: config.burstRefillMs ?? DEFAULT_BURST_REFILL_MS,
|
|
233
|
+
now,
|
|
234
|
+
});
|
|
235
|
+
const seen = new Map();
|
|
236
|
+
/** Distinct auto-Feedback dropped by the burst limiter or the session cap. */
|
|
237
|
+
let dropped = 0;
|
|
238
|
+
/** Build the `source: auto` content for a fingerprint at a given count. */
|
|
239
|
+
function buildContent(fp, occurrences, opts) {
|
|
240
|
+
let environment;
|
|
241
|
+
try {
|
|
242
|
+
environment = collectEnvironment(readEnvironment(), sdkVersion);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
environment = collectEnvironment({}, sdkVersion);
|
|
246
|
+
}
|
|
247
|
+
// No Kind rides along (ADR-0023): the server stamps a `source: auto`
|
|
248
|
+
// Issue `bug` deterministically.
|
|
249
|
+
const base = assembleContent({
|
|
250
|
+
url: config.currentUrl?.(),
|
|
251
|
+
environment,
|
|
252
|
+
trace: opts.trace ? config.buffer?.snapshot() : undefined,
|
|
253
|
+
reporterName: config.display?.name,
|
|
254
|
+
reporterEmail: config.display?.email,
|
|
255
|
+
});
|
|
256
|
+
return { ...base, source: "auto", errorSignature: fp, occurrences };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Assemble, scrub, and ship one auto-Feedback (never a screenshot — see
|
|
260
|
+
* {@link AutoCaptureDeps}). Sets the entry's `sentCount` optimistically so a
|
|
261
|
+
* concurrent flush never double-sends, and rolls it back on a failed send so
|
|
262
|
+
* the flush can retry. A `beforeSend` that returns `null` drops the report
|
|
263
|
+
* without transport.
|
|
264
|
+
*/
|
|
265
|
+
async function file(fp, occurrences, opts) {
|
|
266
|
+
const entry = seen.get(fp);
|
|
267
|
+
if (!entry)
|
|
268
|
+
return;
|
|
269
|
+
const previouslySent = entry.sentCount;
|
|
270
|
+
entry.sentCount = occurrences;
|
|
271
|
+
try {
|
|
272
|
+
const draft = buildContent(fp, occurrences, opts);
|
|
273
|
+
const content = runBeforeSend(draft, {
|
|
274
|
+
hook: config.beforeSend,
|
|
275
|
+
scrub: config.scrub,
|
|
276
|
+
});
|
|
277
|
+
if (!content)
|
|
278
|
+
return; // dropped at the client scrub choke point — no transport.
|
|
279
|
+
const result = await submitReportFn(config.apiUrl, {
|
|
280
|
+
key: config.key,
|
|
281
|
+
origin: config.origin,
|
|
282
|
+
identity: config.identity,
|
|
283
|
+
content,
|
|
284
|
+
screenshot: null,
|
|
285
|
+
});
|
|
286
|
+
if (!result.ok && entry.sentCount === occurrences) {
|
|
287
|
+
// Held under backpressure, refused, or unreachable — let a flush retry.
|
|
288
|
+
entry.sentCount = previouslySent;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
if (entry.sentCount === occurrences)
|
|
293
|
+
entry.sentCount = previouslySent;
|
|
294
|
+
// The error path must never throw into the host app.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Handle one distilled error: dedup → burst limiter → session cap → file. */
|
|
298
|
+
function handle(extracted) {
|
|
299
|
+
const fp = computeFingerprint(extracted.type, extracted.value, extracted.stack);
|
|
300
|
+
const existing = seen.get(fp);
|
|
301
|
+
if (existing) {
|
|
302
|
+
// Dedup: one Feedback per fingerprint per session; count locally.
|
|
303
|
+
existing.count += 1;
|
|
304
|
+
existing.lastAt = now();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!bucket.take()) {
|
|
308
|
+
dropped += 1; // burst limiter
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (seen.size >= maxDistinct) {
|
|
312
|
+
dropped += 1; // per-session cap — keep only the local dropped-count
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const at = now();
|
|
316
|
+
seen.set(fp, { count: 1, firstAt: at, lastAt: at, sentCount: 0 });
|
|
317
|
+
// The failing error joins the trace so the lead-up and the failure both
|
|
318
|
+
// show, carrying a causal pointer to the entries preceding the throw.
|
|
319
|
+
try {
|
|
320
|
+
const causedBy = precedingIds(config.buffer?.snapshot());
|
|
321
|
+
config.buffer?.add(errorCrumb(extracted.original, at, causedBy));
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
/* capture must never throw into the host app */
|
|
325
|
+
}
|
|
326
|
+
void file(fp, 1, { trace: true });
|
|
327
|
+
}
|
|
328
|
+
const previous = errorUtils.getGlobalHandler() ?? null;
|
|
329
|
+
let destroyed = false;
|
|
330
|
+
const wrapper = (error, isFatal) => {
|
|
331
|
+
if (!destroyed) {
|
|
332
|
+
try {
|
|
333
|
+
handle(extractGlobalError(error));
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* never throw into the host app */
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Always chain the previous handler — redbox in dev, crash reporters, etc.
|
|
340
|
+
if (previous)
|
|
341
|
+
previous(error, isFatal);
|
|
342
|
+
};
|
|
343
|
+
errorUtils.setGlobalHandler(wrapper);
|
|
344
|
+
/** Flush the final occurrence count of every fingerprint that grew. */
|
|
345
|
+
const flush = () => {
|
|
346
|
+
try {
|
|
347
|
+
for (const [fp, entry] of seen) {
|
|
348
|
+
if (entry.count > entry.sentCount) {
|
|
349
|
+
void file(fp, entry.count, { trace: false });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
/* best-effort on backgrounding */
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
let appStateSub = null;
|
|
358
|
+
if (config.appState) {
|
|
359
|
+
try {
|
|
360
|
+
appStateSub = config.appState.addEventListener("change", (state) => {
|
|
361
|
+
if (state === "background" || state === "inactive")
|
|
362
|
+
flush();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
appStateSub = null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
destroy: () => {
|
|
371
|
+
destroyed = true;
|
|
372
|
+
try {
|
|
373
|
+
// `setGlobalHandler` is a single mutable slot: restore the previous
|
|
374
|
+
// handler only while ours is still the installed one. If someone
|
|
375
|
+
// installed a later handler (chaining ours as their previous), swapping
|
|
376
|
+
// the snapshot back in would silently uninstall them — instead our
|
|
377
|
+
// wrapper stays in the chain, inert, and keeps forwarding.
|
|
378
|
+
if (errorUtils.getGlobalHandler?.() === wrapper) {
|
|
379
|
+
errorUtils.setGlobalHandler(previous);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
/* teardown is best-effort */
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
appStateSub?.remove();
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
/* teardown is best-effort */
|
|
390
|
+
}
|
|
391
|
+
appStateSub = null;
|
|
392
|
+
},
|
|
393
|
+
droppedCount: () => dropped,
|
|
394
|
+
};
|
|
395
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural types for the HTTP surface the SDK touches.
|
|
3
|
+
*
|
|
4
|
+
* The package typechecks without the DOM lib (React Native is not a browser), so
|
|
5
|
+
* rather than depending on ambient `fetch`/`FormData` declarations, the exact
|
|
6
|
+
* slices the transport uses are described here and the globals are read off
|
|
7
|
+
* `globalThis` defensively. React Native provides both at runtime; tests inject
|
|
8
|
+
* fakes through the same seams.
|
|
9
|
+
*/
|
|
10
|
+
/** The response slice `boot`/`submit` read: status, ok, headers, JSON body. */
|
|
11
|
+
export interface FetchResponseLike {
|
|
12
|
+
readonly ok: boolean;
|
|
13
|
+
readonly status: number;
|
|
14
|
+
readonly headers?: {
|
|
15
|
+
get(name: string): string | null;
|
|
16
|
+
} | null;
|
|
17
|
+
json(): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
/** The request init the SDK sends — method, plain headers, and a body. */
|
|
20
|
+
export interface FetchRequestInit {
|
|
21
|
+
readonly method: string;
|
|
22
|
+
readonly headers?: Record<string, string>;
|
|
23
|
+
readonly body?: unknown;
|
|
24
|
+
}
|
|
25
|
+
/** A `fetch`-shaped function, injectable for tests. */
|
|
26
|
+
export type FetchLike = (url: string, init: FetchRequestInit) => Promise<FetchResponseLike>;
|
|
27
|
+
/** React Native's global `fetch`, when present. Bound so `this` never matters. */
|
|
28
|
+
export declare function globalFetch(): FetchLike | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The `FormData` slice the submission uses. React Native's implementation
|
|
31
|
+
* accepts `{ uri, name, type }` objects as file parts — that is how the
|
|
32
|
+
* screenshot travels (there is no `Blob` screenshot on mobile).
|
|
33
|
+
*/
|
|
34
|
+
export interface FormDataLike {
|
|
35
|
+
append(name: string, value: unknown): void;
|
|
36
|
+
}
|
|
37
|
+
/** Creates a fresh multipart form; injectable for tests. */
|
|
38
|
+
export type FormDataFactory = () => FormDataLike;
|
|
39
|
+
/** React Native's global `FormData` constructor, when present. */
|
|
40
|
+
export declare function globalFormData(): FormDataFactory | undefined;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural types for the HTTP surface the SDK touches.
|
|
3
|
+
*
|
|
4
|
+
* The package typechecks without the DOM lib (React Native is not a browser), so
|
|
5
|
+
* rather than depending on ambient `fetch`/`FormData` declarations, the exact
|
|
6
|
+
* slices the transport uses are described here and the globals are read off
|
|
7
|
+
* `globalThis` defensively. React Native provides both at runtime; tests inject
|
|
8
|
+
* fakes through the same seams.
|
|
9
|
+
*/
|
|
10
|
+
/** React Native's global `fetch`, when present. Bound so `this` never matters. */
|
|
11
|
+
export function globalFetch() {
|
|
12
|
+
const g = globalThis;
|
|
13
|
+
return typeof g.fetch === "function" ? g.fetch.bind(globalThis) : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** React Native's global `FormData` constructor, when present. */
|
|
16
|
+
export function globalFormData() {
|
|
17
|
+
const g = globalThis;
|
|
18
|
+
const ctor = g.FormData;
|
|
19
|
+
return typeof ctor === "function" ? () => new ctor() : undefined;
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Public Reporter's stable, first-party anonymous id — the mobile
|
|
3
|
+
* counterpart of `packages/sdk/src/identity.ts`.
|
|
4
|
+
*
|
|
5
|
+
* When the host app does not supply its own identity, the SDK establishes a
|
|
6
|
+
* per-device id and persists it (AsyncStorage on React Native), so repeated
|
|
7
|
+
* boots from the same device are one Reporter. Persistence is best-effort: if
|
|
8
|
+
* storage is unavailable or failing, we fall back to an ephemeral id rather
|
|
9
|
+
* than throw — the SDK must never disturb the host app. The storage key is the
|
|
10
|
+
* web SDK's, so the shape stays recognisable across clients.
|
|
11
|
+
*/
|
|
12
|
+
/** The async storage slice the SDK uses — AsyncStorage-shaped, injectable. */
|
|
13
|
+
export interface StorageLike {
|
|
14
|
+
getItem(key: string): Promise<string | null>;
|
|
15
|
+
setItem(key: string, value: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare const ANONYMOUS_ID_STORAGE_KEY = "fixback.anonymousId";
|
|
18
|
+
/** A random id: a UUID where the platform offers one, else a compact fallback. */
|
|
19
|
+
export declare function generateAnonymousId(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Return this device's anonymous id, creating and persisting one on first use.
|
|
22
|
+
* Any storage failure — absent module, quota, corrupted value — yields an
|
|
23
|
+
* ephemeral id for this session instead of an error.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ensureAnonymousId(storage: StorageLike | null | undefined): Promise<string>;
|