@goliapkg/sentori-react-native 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/README.md +5 -0
- package/SentoriReactNative.podspec +21 -0
- package/android/build.gradle +38 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +213 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +39 -0
- package/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt +60 -0
- package/expo-module.config.json +9 -0
- package/ios/SentoriCrashHandler.swift +160 -0
- package/ios/SentoriModule.swift +43 -0
- package/ios/Tests/SentoriCrashHandlerTests.swift +59 -0
- package/lib/breadcrumbs.d.ts +11 -0
- package/lib/breadcrumbs.d.ts.map +1 -0
- package/lib/breadcrumbs.js +21 -0
- package/lib/breadcrumbs.js.map +1 -0
- package/lib/capture.d.ts +23 -0
- package/lib/capture.d.ts.map +1 -0
- package/lib/capture.js +91 -0
- package/lib/capture.js.map +1 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +10 -0
- package/lib/config.js.map +1 -0
- package/lib/error-boundary.d.ts +17 -0
- package/lib/error-boundary.d.ts.map +1 -0
- package/lib/error-boundary.js +26 -0
- package/lib/error-boundary.js.map +1 -0
- package/lib/handlers/global.d.ts +2 -0
- package/lib/handlers/global.d.ts.map +1 -0
- package/lib/handlers/global.js +29 -0
- package/lib/handlers/global.js.map +1 -0
- package/lib/handlers/network.d.ts +2 -0
- package/lib/handlers/network.d.ts.map +1 -0
- package/lib/handlers/network.js +69 -0
- package/lib/handlers/network.js.map +1 -0
- package/lib/handlers/promise.d.ts +2 -0
- package/lib/handlers/promise.d.ts.map +1 -0
- package/lib/handlers/promise.js +27 -0
- package/lib/handlers/promise.js.map +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +20 -0
- package/lib/index.js.map +1 -0
- package/lib/init.d.ts +18 -0
- package/lib/init.d.ts.map +1 -0
- package/lib/init.js +56 -0
- package/lib/init.js.map +1 -0
- package/lib/native.d.ts +23 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +56 -0
- package/lib/native.js.map +1 -0
- package/lib/stack.d.ts +3 -0
- package/lib/stack.d.ts.map +1 -0
- package/lib/stack.js +69 -0
- package/lib/stack.js.map +1 -0
- package/lib/transport.d.ts +8 -0
- package/lib/transport.d.ts.map +1 -0
- package/lib/transport.js +143 -0
- package/lib/transport.js.map +1 -0
- package/lib/types.d.ts +62 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/uuid.d.ts +11 -0
- package/lib/uuid.d.ts.map +1 -0
- package/lib/uuid.js +46 -0
- package/lib/uuid.js.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/breadcrumbs.test.ts +44 -0
- package/src/__tests__/stack.test.ts +43 -0
- package/src/__tests__/transport.test.ts +112 -0
- package/src/__tests__/uuid.test.ts +41 -0
- package/src/breadcrumbs.ts +33 -0
- package/src/capture.ts +108 -0
- package/src/config.ts +21 -0
- package/src/error-boundary.tsx +38 -0
- package/src/handlers/global.ts +36 -0
- package/src/handlers/network.ts +70 -0
- package/src/handlers/promise.ts +38 -0
- package/src/index.ts +37 -0
- package/src/init.ts +80 -0
- package/src/native.ts +71 -0
- package/src/stack.ts +72 -0
- package/src/transport.ts +164 -0
- package/src/types.ts +63 -0
- package/src/uuid.ts +56 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { uuidV7 } from '../uuid';
|
|
4
|
+
|
|
5
|
+
describe('uuidV7', () => {
|
|
6
|
+
it('produces a 36-char hyphenated UUID with version 7', () => {
|
|
7
|
+
const u = uuidV7();
|
|
8
|
+
expect(u).toMatch(
|
|
9
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('encodes the current ms timestamp in the leading 48 bits', () => {
|
|
14
|
+
const before = Date.now();
|
|
15
|
+
const u = uuidV7();
|
|
16
|
+
const after = Date.now();
|
|
17
|
+
const tsHex = u.replace(/-/g, '').slice(0, 12);
|
|
18
|
+
const ts = parseInt(tsHex, 16);
|
|
19
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
20
|
+
expect(ts).toBeLessThanOrEqual(after);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('produces unique values across rapid calls', () => {
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
for (let i = 0; i < 1000; i++) seen.add(uuidV7());
|
|
26
|
+
expect(seen.size).toBe(1000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('always sets version 7 nibble', () => {
|
|
30
|
+
for (let i = 0; i < 100; i++) {
|
|
31
|
+
expect(uuidV7().charAt(14)).toBe('7');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('always sets variant to 10xx', () => {
|
|
36
|
+
for (let i = 0; i < 100; i++) {
|
|
37
|
+
const ch = uuidV7().charAt(19).toLowerCase();
|
|
38
|
+
expect('89ab'.includes(ch)).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Breadcrumb, BreadcrumbType } from './types';
|
|
2
|
+
|
|
3
|
+
const MAX_BREADCRUMBS = 100;
|
|
4
|
+
|
|
5
|
+
let _buffer: Breadcrumb[] = [];
|
|
6
|
+
|
|
7
|
+
export type AddBreadcrumbInput = {
|
|
8
|
+
type: BreadcrumbType;
|
|
9
|
+
data: Record<string, unknown>;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const addBreadcrumb = (input: AddBreadcrumbInput): void => {
|
|
14
|
+
const crumb: Breadcrumb = {
|
|
15
|
+
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
16
|
+
type: input.type,
|
|
17
|
+
data: input.data,
|
|
18
|
+
};
|
|
19
|
+
_buffer.push(crumb);
|
|
20
|
+
if (_buffer.length > MAX_BREADCRUMBS) {
|
|
21
|
+
_buffer.shift();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const getBreadcrumbs = (): Breadcrumb[] => [..._buffer];
|
|
26
|
+
|
|
27
|
+
export const clearBreadcrumbs = (): void => {
|
|
28
|
+
_buffer = [];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const __resetForTests = (): void => {
|
|
32
|
+
_buffer = [];
|
|
33
|
+
};
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { getConfig, isInitialized } from './config';
|
|
2
|
+
import { getBreadcrumbs } from './breadcrumbs';
|
|
3
|
+
import { parseStack } from './stack';
|
|
4
|
+
import { enqueue } from './transport';
|
|
5
|
+
import { uuidV7 } from './uuid';
|
|
6
|
+
import type { App, Device, Event, SentoriError, Tags, User } from './types';
|
|
7
|
+
|
|
8
|
+
let _user: User | null = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attach a stable user identifier to events captured after this call.
|
|
12
|
+
*
|
|
13
|
+
* PII policy (Phase 16 sub-D): the User shape is intentionally limited
|
|
14
|
+
* to `{ id?, anonymous? }` — no email, name, IP, or other identifying
|
|
15
|
+
* fields. Use a hashed / pseudonymous id (e.g. uuid v4 stored in
|
|
16
|
+
* AsyncStorage on first launch). The server schema enforces the same
|
|
17
|
+
* shape, so any extra fields you tack on at the JS layer would be
|
|
18
|
+
* rejected with `validationFailed` and never persisted.
|
|
19
|
+
*
|
|
20
|
+
* Pass `null` to clear (e.g. on sign-out).
|
|
21
|
+
*/
|
|
22
|
+
export const setUser = (user: User | null): void => {
|
|
23
|
+
_user = user;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const getUser = (): User | null => _user;
|
|
27
|
+
|
|
28
|
+
export type CaptureExtras = {
|
|
29
|
+
tags?: Tags;
|
|
30
|
+
user?: User;
|
|
31
|
+
fingerprint?: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
35
|
+
if (!isInitialized()) return;
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
if (!config) return;
|
|
38
|
+
|
|
39
|
+
const event: Event = {
|
|
40
|
+
id: uuidV7(),
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
kind: 'error',
|
|
43
|
+
platform: 'javascript',
|
|
44
|
+
release: config.release,
|
|
45
|
+
environment: config.environment,
|
|
46
|
+
device: collectDevice(),
|
|
47
|
+
app: collectApp(config.release),
|
|
48
|
+
user: extras?.user ?? _user,
|
|
49
|
+
tags: extras?.tags,
|
|
50
|
+
breadcrumbs: getBreadcrumbs(),
|
|
51
|
+
error: errorToObject(error),
|
|
52
|
+
fingerprint: extras?.fingerprint,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
enqueue(event);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const captureException = captureError;
|
|
59
|
+
|
|
60
|
+
const errorToObject = (error: Error): SentoriError => {
|
|
61
|
+
const causeRaw = (error as { cause?: unknown }).cause;
|
|
62
|
+
let cause: SentoriError | null = null;
|
|
63
|
+
if (causeRaw instanceof Error) {
|
|
64
|
+
cause = errorToObject(causeRaw);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
type: error.name || 'Error',
|
|
69
|
+
message: error.message,
|
|
70
|
+
stack: parseStack(error.stack),
|
|
71
|
+
cause,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const collectDevice = (): Device => {
|
|
76
|
+
let os: Device['os'] = 'other';
|
|
77
|
+
let osVersion = '0';
|
|
78
|
+
try {
|
|
79
|
+
const RN = require('react-native') as {
|
|
80
|
+
Platform: { OS: string; Version: string | number };
|
|
81
|
+
};
|
|
82
|
+
const rnOS = RN.Platform.OS;
|
|
83
|
+
os = rnOS === 'ios' || rnOS === 'android' || rnOS === 'web' ? rnOS : 'other';
|
|
84
|
+
osVersion = String(RN.Platform.Version);
|
|
85
|
+
} catch {
|
|
86
|
+
// not in RN runtime (jest, bun test)
|
|
87
|
+
}
|
|
88
|
+
return { os, osVersion };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const collectApp = (release: string): App => {
|
|
92
|
+
const m = /^(?:[^@]+@)?([^+]+)(?:\+(.+))?$/.exec(release);
|
|
93
|
+
const version = m?.[1] ?? '0.0.0';
|
|
94
|
+
const build = m?.[2];
|
|
95
|
+
|
|
96
|
+
let rnVersion = 'unknown';
|
|
97
|
+
try {
|
|
98
|
+
rnVersion = (require('react-native/package.json') as { version: string }).version;
|
|
99
|
+
} catch {
|
|
100
|
+
// not in RN runtime
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
version,
|
|
105
|
+
build,
|
|
106
|
+
framework: { name: 'react-native', version: rnVersion },
|
|
107
|
+
};
|
|
108
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Config = {
|
|
2
|
+
token: string;
|
|
3
|
+
release: string;
|
|
4
|
+
environment: string;
|
|
5
|
+
ingestUrl: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let _config: Config | null = null;
|
|
10
|
+
|
|
11
|
+
export const setConfig = (config: Config): void => {
|
|
12
|
+
_config = config;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const getConfig = (): Config | null => _config;
|
|
16
|
+
|
|
17
|
+
export const isInitialized = (): boolean => _config !== null;
|
|
18
|
+
|
|
19
|
+
export const __resetForTests = (): void => {
|
|
20
|
+
_config = null;
|
|
21
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { captureError } from './capture';
|
|
4
|
+
|
|
5
|
+
export type ErrorBoundaryProps = {
|
|
6
|
+
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type State = { error: Error | null };
|
|
11
|
+
|
|
12
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
|
|
13
|
+
state: State = { error: null };
|
|
14
|
+
|
|
15
|
+
static getDerivedStateFromError(error: Error): State {
|
|
16
|
+
return { error };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
componentDidCatch(error: Error, _info: ErrorInfo): void {
|
|
20
|
+
captureError(error);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
reset = (): void => {
|
|
24
|
+
this.setState({ error: null });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
render(): ReactNode {
|
|
28
|
+
const { error } = this.state;
|
|
29
|
+
const { fallback, children } = this.props;
|
|
30
|
+
if (error) {
|
|
31
|
+
if (typeof fallback === 'function') {
|
|
32
|
+
return fallback(error, this.reset);
|
|
33
|
+
}
|
|
34
|
+
return fallback ?? null;
|
|
35
|
+
}
|
|
36
|
+
return children;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { captureError } from '../capture';
|
|
2
|
+
|
|
3
|
+
type ErrorUtilsHandler = (error: Error, isFatal?: boolean) => void;
|
|
4
|
+
|
|
5
|
+
type ErrorUtilsLike = {
|
|
6
|
+
setGlobalHandler: (handler: ErrorUtilsHandler) => void;
|
|
7
|
+
getGlobalHandler: () => ErrorUtilsHandler;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
let _previous: ErrorUtilsHandler | undefined;
|
|
11
|
+
let _installed = false;
|
|
12
|
+
|
|
13
|
+
export const installGlobalHandler = (): void => {
|
|
14
|
+
if (_installed) return;
|
|
15
|
+
|
|
16
|
+
const utils = (globalThis as { ErrorUtils?: ErrorUtilsLike }).ErrorUtils;
|
|
17
|
+
if (!utils || typeof utils.setGlobalHandler !== 'function') return;
|
|
18
|
+
|
|
19
|
+
_installed = true;
|
|
20
|
+
_previous = utils.getGlobalHandler();
|
|
21
|
+
|
|
22
|
+
utils.setGlobalHandler((error, isFatal) => {
|
|
23
|
+
try {
|
|
24
|
+
captureError(error);
|
|
25
|
+
} catch {
|
|
26
|
+
// never throw from the global handler
|
|
27
|
+
}
|
|
28
|
+
if (_previous) {
|
|
29
|
+
try {
|
|
30
|
+
_previous(error, isFatal);
|
|
31
|
+
} catch {
|
|
32
|
+
// ignore previous handler error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { addBreadcrumb } from '../breadcrumbs';
|
|
2
|
+
|
|
3
|
+
let _installed = false;
|
|
4
|
+
|
|
5
|
+
const AUTH_PARAMS = ['token', 'key', 'password', 'secret', 'access_token'];
|
|
6
|
+
|
|
7
|
+
export const installNetworkHandler = (): void => {
|
|
8
|
+
if (_installed) return;
|
|
9
|
+
if (typeof globalThis.fetch !== 'function') return;
|
|
10
|
+
_installed = true;
|
|
11
|
+
|
|
12
|
+
const original = globalThis.fetch;
|
|
13
|
+
|
|
14
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
const url = extractUrl(input);
|
|
17
|
+
const method = (init?.method ??
|
|
18
|
+
(typeof input !== 'string' && 'method' in (input as Request)
|
|
19
|
+
? (input as Request).method
|
|
20
|
+
: 'GET')) as string;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const resp = await original(input, init);
|
|
24
|
+
addBreadcrumb({
|
|
25
|
+
type: 'net',
|
|
26
|
+
data: {
|
|
27
|
+
method,
|
|
28
|
+
url: scrubUrl(url),
|
|
29
|
+
status: resp.status,
|
|
30
|
+
durationMs: Date.now() - start,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
return resp;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
addBreadcrumb({
|
|
36
|
+
type: 'net',
|
|
37
|
+
data: {
|
|
38
|
+
method,
|
|
39
|
+
url: scrubUrl(url),
|
|
40
|
+
status: 0,
|
|
41
|
+
durationMs: Date.now() - start,
|
|
42
|
+
error: String(e),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}) as typeof fetch;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const extractUrl = (input: RequestInfo | URL): string => {
|
|
51
|
+
if (typeof input === 'string') return input;
|
|
52
|
+
if (input instanceof URL) return input.href;
|
|
53
|
+
return (input as Request).url;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const scrubUrl = (url: string): string => {
|
|
57
|
+
try {
|
|
58
|
+
const u = new URL(url);
|
|
59
|
+
let modified = false;
|
|
60
|
+
for (const p of AUTH_PARAMS) {
|
|
61
|
+
if (u.searchParams.has(p)) {
|
|
62
|
+
u.searchParams.set(p, '[redacted]');
|
|
63
|
+
modified = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return modified ? u.toString() : url;
|
|
67
|
+
} catch {
|
|
68
|
+
return url;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { captureError } from '../capture';
|
|
2
|
+
|
|
3
|
+
type RejectionTracker = (opts: {
|
|
4
|
+
allRejections: boolean;
|
|
5
|
+
onUnhandled: (id: number, rejection: unknown) => void;
|
|
6
|
+
}) => void;
|
|
7
|
+
|
|
8
|
+
type HermesInternalLike = {
|
|
9
|
+
enablePromiseRejectionTracker?: RejectionTracker;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let _installed = false;
|
|
13
|
+
|
|
14
|
+
export const installPromiseHandler = (): void => {
|
|
15
|
+
if (_installed) return;
|
|
16
|
+
|
|
17
|
+
const hermes = (globalThis as { HermesInternal?: HermesInternalLike })
|
|
18
|
+
.HermesInternal;
|
|
19
|
+
if (hermes?.enablePromiseRejectionTracker) {
|
|
20
|
+
_installed = true;
|
|
21
|
+
hermes.enablePromiseRejectionTracker({
|
|
22
|
+
allRejections: true,
|
|
23
|
+
onUnhandled: (_id, rejection) => {
|
|
24
|
+
try {
|
|
25
|
+
const err =
|
|
26
|
+
rejection instanceof Error ? rejection : new Error(String(rejection));
|
|
27
|
+
captureError(err);
|
|
28
|
+
} catch {
|
|
29
|
+
// never throw
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// No-op fallback: on JSC or older Hermes the SDK can't track rejections
|
|
37
|
+
// without a polyfill. Users targeting these can call `captureError(err)` manually.
|
|
38
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { init } from './init';
|
|
2
|
+
import { addBreadcrumb } from './breadcrumbs';
|
|
3
|
+
import { setUser, getUser, captureError, captureException } from './capture';
|
|
4
|
+
import { ErrorBoundary } from './error-boundary';
|
|
5
|
+
|
|
6
|
+
export const sentori = {
|
|
7
|
+
init,
|
|
8
|
+
addBreadcrumb,
|
|
9
|
+
setUser,
|
|
10
|
+
getUser,
|
|
11
|
+
captureError,
|
|
12
|
+
captureException,
|
|
13
|
+
ErrorBoundary,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default sentori;
|
|
17
|
+
|
|
18
|
+
export { init } from './init';
|
|
19
|
+
export { addBreadcrumb } from './breadcrumbs';
|
|
20
|
+
export { setUser, getUser, captureError, captureException } from './capture';
|
|
21
|
+
export { ErrorBoundary } from './error-boundary';
|
|
22
|
+
export { triggerNativeCrash } from './native';
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
Event,
|
|
26
|
+
SentoriError,
|
|
27
|
+
Frame,
|
|
28
|
+
Breadcrumb,
|
|
29
|
+
BreadcrumbType,
|
|
30
|
+
Device,
|
|
31
|
+
DeviceOS,
|
|
32
|
+
App,
|
|
33
|
+
User,
|
|
34
|
+
Tags,
|
|
35
|
+
EventKind,
|
|
36
|
+
Platform,
|
|
37
|
+
} from './types';
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { setConfig } from './config';
|
|
2
|
+
import { installGlobalHandler } from './handlers/global';
|
|
3
|
+
import { installPromiseHandler } from './handlers/promise';
|
|
4
|
+
import { installNetworkHandler } from './handlers/network';
|
|
5
|
+
import { drainNativePending, setNativeConfig } from './native';
|
|
6
|
+
import { drainOfflineQueue, enqueue, startTransport } from './transport';
|
|
7
|
+
import type { Event } from './types';
|
|
8
|
+
|
|
9
|
+
declare const __DEV__: boolean | undefined;
|
|
10
|
+
|
|
11
|
+
export type InitOptions = {
|
|
12
|
+
/** Project token starting with `st_pk_`. Required. */
|
|
13
|
+
token: string;
|
|
14
|
+
/** Release identifier, e.g. `myapp@1.2.3+456`. Required. */
|
|
15
|
+
release: string;
|
|
16
|
+
/** Environment label. Defaults to `dev` if `__DEV__`, else `prod`. */
|
|
17
|
+
environment?: string;
|
|
18
|
+
/** Override ingestion URL (self-hosted). Default: https://ingest.sentori.golia.jp */
|
|
19
|
+
ingestUrl?: string;
|
|
20
|
+
/** Toggle individual capture sources. All enabled by default. */
|
|
21
|
+
capture?: {
|
|
22
|
+
globalErrors?: boolean;
|
|
23
|
+
promiseRejections?: boolean;
|
|
24
|
+
network?: boolean;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEFAULT_INGEST_URL = 'https://ingest.sentori.golia.jp';
|
|
29
|
+
|
|
30
|
+
export const init = (options: InitOptions): void => {
|
|
31
|
+
if (!options.token || !options.token.startsWith('st_pk_')) {
|
|
32
|
+
throw new Error("Sentori: token is required and must start with 'st_pk_'");
|
|
33
|
+
}
|
|
34
|
+
if (!options.release) {
|
|
35
|
+
throw new Error('Sentori: release is required');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const env =
|
|
39
|
+
options.environment ??
|
|
40
|
+
(typeof __DEV__ !== 'undefined' && __DEV__ ? 'dev' : 'prod');
|
|
41
|
+
|
|
42
|
+
setConfig({
|
|
43
|
+
token: options.token,
|
|
44
|
+
release: options.release,
|
|
45
|
+
environment: env,
|
|
46
|
+
ingestUrl: options.ingestUrl ?? DEFAULT_INGEST_URL,
|
|
47
|
+
enabled: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Tell the native crash handler about the config so the JSON it writes
|
|
51
|
+
// on the next NSException / Java uncaught carries release + env.
|
|
52
|
+
setNativeConfig({
|
|
53
|
+
token: options.token,
|
|
54
|
+
release: options.release,
|
|
55
|
+
environment: env,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
startTransport();
|
|
59
|
+
|
|
60
|
+
const capture = options.capture ?? {};
|
|
61
|
+
if (capture.globalErrors !== false) installGlobalHandler();
|
|
62
|
+
if (capture.promiseRejections !== false) installPromiseHandler();
|
|
63
|
+
if (capture.network !== false) installNetworkHandler();
|
|
64
|
+
|
|
65
|
+
// Drain events persisted from previous session (best-effort):
|
|
66
|
+
// - native crashes from <Documents>/sentori/pending/*.json
|
|
67
|
+
// - JS transport offline queue from AsyncStorage
|
|
68
|
+
drainNativePending()
|
|
69
|
+
.then((items) => {
|
|
70
|
+
for (const json of items) {
|
|
71
|
+
try {
|
|
72
|
+
enqueue(JSON.parse(json) as Event);
|
|
73
|
+
} catch {
|
|
74
|
+
// skip malformed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.catch(() => {});
|
|
79
|
+
drainOfflineQueue().catch(() => {});
|
|
80
|
+
};
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge to the native (iOS / Android) Sentori module.
|
|
3
|
+
* No-op when not running in an Expo runtime that has the module installed —
|
|
4
|
+
* this keeps the SDK usable in pure-JS environments (jest, bun test, web).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type SentoriNativeModule = {
|
|
8
|
+
drainPending: () => Promise<string[]>
|
|
9
|
+
setConfig: (config: {
|
|
10
|
+
environment: string
|
|
11
|
+
release: string
|
|
12
|
+
token: string
|
|
13
|
+
}) => void
|
|
14
|
+
/** Dev-only — example app uses this to verify the crash flow. */
|
|
15
|
+
triggerTestNativeCrash?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let _native: SentoriNativeModule | null | undefined
|
|
19
|
+
|
|
20
|
+
function native(): SentoriNativeModule | null {
|
|
21
|
+
if (_native !== undefined) return _native
|
|
22
|
+
try {
|
|
23
|
+
const core = require('expo-modules-core') as {
|
|
24
|
+
requireNativeModule: <T>(name: string) => T
|
|
25
|
+
}
|
|
26
|
+
_native = core.requireNativeModule<SentoriNativeModule>('Sentori')
|
|
27
|
+
} catch {
|
|
28
|
+
_native = null
|
|
29
|
+
}
|
|
30
|
+
return _native
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function setNativeConfig(config: {
|
|
34
|
+
environment: string
|
|
35
|
+
release: string
|
|
36
|
+
token: string
|
|
37
|
+
}): void {
|
|
38
|
+
try {
|
|
39
|
+
native()?.setConfig(config)
|
|
40
|
+
} catch {
|
|
41
|
+
// never throw on init
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function drainNativePending(): Promise<string[]> {
|
|
46
|
+
const n = native()
|
|
47
|
+
if (!n) return []
|
|
48
|
+
try {
|
|
49
|
+
return await n.drainPending()
|
|
50
|
+
} catch {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Dev-only helper. Triggers a real NSException (iOS) or RuntimeException
|
|
57
|
+
* (Android) after a short delay so the host app crashes for real and the
|
|
58
|
+
* native crash handler exercises the full write-to-disk path.
|
|
59
|
+
*
|
|
60
|
+
* Usage: tap a button in the example app, watch the app close, restart it,
|
|
61
|
+
* verify the server received the event.
|
|
62
|
+
*
|
|
63
|
+
* No-op when the native module isn't installed (jest, bun test, web).
|
|
64
|
+
*/
|
|
65
|
+
export function triggerNativeCrash(): void {
|
|
66
|
+
try {
|
|
67
|
+
native()?.triggerTestNativeCrash?.()
|
|
68
|
+
} catch {
|
|
69
|
+
// never throw from a debugging helper
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/stack.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Frame } from './types';
|
|
2
|
+
|
|
3
|
+
// V8 / Hermes (RN 0.71+):
|
|
4
|
+
// " at functionName (file:line:col)"
|
|
5
|
+
// " at file:line:col"
|
|
6
|
+
const V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?)(?::(\d+))?(?::(\d+))?\)?\s*$/;
|
|
7
|
+
|
|
8
|
+
// SpiderMonkey / older Hermes:
|
|
9
|
+
// "functionName@file:line:col"
|
|
10
|
+
const AT_FRAME = /^(.+?)@(.+?)(?::(\d+))?(?::(\d+))?$/;
|
|
11
|
+
|
|
12
|
+
export const parseStack = (stack: string | undefined): Frame[] => {
|
|
13
|
+
if (!stack || typeof stack !== 'string') return [];
|
|
14
|
+
const lines = stack.split('\n');
|
|
15
|
+
const frames: Frame[] = [];
|
|
16
|
+
|
|
17
|
+
for (const raw of lines) {
|
|
18
|
+
const line = raw.trim();
|
|
19
|
+
if (!line) continue;
|
|
20
|
+
// Skip the "ErrorType: message" header line.
|
|
21
|
+
if (!line.startsWith('at ') && !line.includes('@')) continue;
|
|
22
|
+
|
|
23
|
+
const frame = parseV8(line) ?? parseAt(line);
|
|
24
|
+
if (frame) frames.push(frame);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return frames;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const parseV8 = (line: string): Frame | null => {
|
|
31
|
+
if (!line.startsWith('at ')) return null;
|
|
32
|
+
const m = V8_FRAME.exec(line);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
|
|
35
|
+
const fn = m[1] ? m[1].trim() : undefined;
|
|
36
|
+
const file = m[2] ? m[2].trim() : '<anonymous>';
|
|
37
|
+
const lineNo = m[3] ? parseInt(m[3], 10) : 0;
|
|
38
|
+
const col = m[4] ? parseInt(m[4], 10) : undefined;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
function: fn,
|
|
42
|
+
file,
|
|
43
|
+
line: lineNo,
|
|
44
|
+
column: col,
|
|
45
|
+
inApp: isInApp(file),
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const parseAt = (line: string): Frame | null => {
|
|
50
|
+
const m = AT_FRAME.exec(line);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
|
|
53
|
+
const fn = m[1] ? m[1].trim() : undefined;
|
|
54
|
+
const file = m[2] ? m[2].trim() : '<anonymous>';
|
|
55
|
+
const lineNo = m[3] ? parseInt(m[3], 10) : 0;
|
|
56
|
+
const col = m[4] ? parseInt(m[4], 10) : undefined;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
function: fn,
|
|
60
|
+
file,
|
|
61
|
+
line: lineNo,
|
|
62
|
+
column: col,
|
|
63
|
+
inApp: isInApp(file),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isInApp = (file: string): boolean => {
|
|
68
|
+
if (!file || file === '<anonymous>') return false;
|
|
69
|
+
if (file.includes('node_modules/')) return false;
|
|
70
|
+
if (/^https?:\/\//.test(file)) return false;
|
|
71
|
+
return true;
|
|
72
|
+
};
|