@goliapkg/sentori-react-native 1.1.0 → 1.3.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/lib/breadcrumbs.d.ts.map +1 -1
- package/lib/breadcrumbs.js +2 -5
- package/lib/breadcrumbs.js.map +1 -1
- package/lib/capture.d.ts +31 -2
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +36 -49
- package/lib/capture.js.map +1 -1
- package/lib/compat/sentry.d.ts +112 -0
- package/lib/compat/sentry.d.ts.map +1 -0
- package/lib/compat/sentry.js +326 -0
- package/lib/compat/sentry.js.map +1 -0
- package/lib/config.d.ts +35 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/heartbeat.d.ts.map +1 -1
- package/lib/heartbeat.js +2 -4
- package/lib/heartbeat.js.map +1 -1
- package/lib/index.d.ts +3 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/init.d.ts +21 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +46 -3
- package/lib/init.js.map +1 -1
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +10 -29
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +12 -33
- package/lib/replay.js.map +1 -1
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +4 -13
- package/lib/transport.js.map +1 -1
- package/package.json +13 -2
- package/src/breadcrumbs.ts +2 -5
- package/src/capture.ts +67 -43
- package/src/compat/sentry.ts +482 -0
- package/src/config.ts +34 -0
- package/src/heartbeat.ts +3 -4
- package/src/init.ts +69 -4
- package/src/native.ts +11 -35
- package/src/replay.ts +25 -44
- package/src/transport.ts +4 -16
package/src/capture.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type CaptureMessageOptions,
|
|
3
|
+
hashIdentities,
|
|
4
|
+
type LinkBy,
|
|
5
|
+
logger,
|
|
3
6
|
type MessageLevel,
|
|
4
7
|
safeFn,
|
|
5
8
|
sealTrail,
|
|
@@ -96,8 +99,61 @@ export const __resetScreenshotBudgetForTests = (): void => {
|
|
|
96
99
|
*
|
|
97
100
|
* Pass `null` to clear (e.g. on sign-out).
|
|
98
101
|
*/
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Public user identity API.
|
|
104
|
+
*
|
|
105
|
+
* sentori.setUser({ id: 'usr_123', name: 'Lihao', linkBy: {
|
|
106
|
+
* email: 'lihao@example.com',
|
|
107
|
+
* googleSub: '108293…',
|
|
108
|
+
* } })
|
|
109
|
+
*
|
|
110
|
+
* The `linkBy` map is for **cross-project lookup** (see v2.3 design,
|
|
111
|
+
* §5 in docs/design/sdk-v2.3-redesign.md). Each value gets hashed
|
|
112
|
+
* client-side via `crypto.subtle.digest('SHA-256', …)` BEFORE
|
|
113
|
+
* leaving the device. Raw email / phone / sub **never** reach
|
|
114
|
+
* Sentori; the server can't recover them.
|
|
115
|
+
*
|
|
116
|
+
* Hashing is async (WebCrypto is async). `setUser` itself returns
|
|
117
|
+
* void synchronously so host code stays one-line; the hash work
|
|
118
|
+
* runs in the background and is committed to scope when ready. If
|
|
119
|
+
* a `captureException` fires BEFORE the hash settles, the event
|
|
120
|
+
* carries `id` + `name` only (no linkHashes that cycle) — the next
|
|
121
|
+
* event picks them up. This is fine in practice because the host
|
|
122
|
+
* sets the user once near startup, well before any captures.
|
|
123
|
+
*
|
|
124
|
+
* If `crypto.subtle` is unavailable in the runtime, the hash
|
|
125
|
+
* promise rejects and `linkBy` is silently dropped (NEVER rule —
|
|
126
|
+
* SDK failure must not propagate to host code).
|
|
127
|
+
*/
|
|
128
|
+
type SetUserInput = (User & { linkBy?: LinkBy }) | null;
|
|
129
|
+
|
|
130
|
+
export const setUser = (input: SetUserInput): void => {
|
|
131
|
+
if (input == null) {
|
|
132
|
+
// `null` (explicit clear) or `undefined` (callers occasionally
|
|
133
|
+
// pass through optional state). Both clear the scope user.
|
|
134
|
+
_user = null;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Stage 1: commit id + name + anonymous immediately so the next
|
|
138
|
+
// captured event picks them up even if hashing is still in flight.
|
|
139
|
+
const { linkBy: rawLinkBy, ...stable } = input;
|
|
140
|
+
_user = { ...stable };
|
|
141
|
+
|
|
142
|
+
// Stage 2: hash any linkBy values async; commit to scope when done.
|
|
143
|
+
if (rawLinkBy && Object.keys(rawLinkBy).length > 0) {
|
|
144
|
+
void hashIdentities(rawLinkBy)
|
|
145
|
+
.then((linkHashes) => {
|
|
146
|
+
// Re-merge to guard against setUser being called again
|
|
147
|
+
// between stage 1 and stage 2 — if the user changed, drop
|
|
148
|
+
// the now-stale hash.
|
|
149
|
+
if (_user && _user.id === stable.id) {
|
|
150
|
+
_user = { ..._user, linkHashes };
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
.catch((e) => {
|
|
154
|
+
logger.warn('identity', 'linkBy hash failed; identities dropped:', e);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
101
157
|
};
|
|
102
158
|
|
|
103
159
|
export const getUser = (): User | null => _user;
|
|
@@ -178,17 +234,13 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
178
234
|
// makes it visible in Metro that the snapshot at captureException
|
|
179
235
|
// time really is empty (no breadcrumb events fired yet) vs. having
|
|
180
236
|
// been silently dropped on the wire. Production builds gate out.
|
|
181
|
-
|
|
182
|
-
// eslint-disable-next-line no-console
|
|
183
|
-
console.warn(
|
|
184
|
-
'[sentori] captureException',
|
|
237
|
+
logger.debug('capture', 'captureException',
|
|
185
238
|
'eventId=', event.id,
|
|
186
239
|
'breadcrumbs=', crumbs.length,
|
|
187
240
|
'wantScreenshot=', config.screenshotsEnabled && extras?.screenshot !== false,
|
|
188
241
|
'wantSessionTrail=', config.sessionTrailEnabled,
|
|
189
242
|
'wantReplay=', isReplayRunning(),
|
|
190
243
|
);
|
|
191
|
-
}
|
|
192
244
|
|
|
193
245
|
// Phase 26 sub-B: a captured error promotes the current session to
|
|
194
246
|
// `errored` so the next AppState=background ping reports unhealthy.
|
|
@@ -234,22 +286,16 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
234
286
|
// indistinguishable from `replay: off` even though the ticks
|
|
235
287
|
// were healthy upstream. Insight 2026-05-18 verify shape made
|
|
236
288
|
// this gap painful to triage.
|
|
237
|
-
|
|
238
|
-
console.warn(
|
|
239
|
-
'[sentori] replay drain empty (no frames buffered at captureException)',
|
|
289
|
+
logger.debug('capture', 'replay drain empty (no frames buffered at captureException)',
|
|
240
290
|
'eventId=', event.id,
|
|
241
291
|
);
|
|
242
292
|
}
|
|
243
|
-
|
|
244
|
-
// eslint-disable-next-line no-console
|
|
245
|
-
console.warn(
|
|
246
|
-
'[sentori] enqueue',
|
|
293
|
+
logger.debug('capture', 'enqueue',
|
|
247
294
|
'eventId=', event.id,
|
|
248
295
|
'attachments=', event.attachments?.length ?? 0,
|
|
249
296
|
'kinds=', (event.attachments ?? []).map((a) => a.kind).join(',') || '(none)',
|
|
250
297
|
'breadcrumbsAtEnqueue=', __peekBreadcrumbCount(),
|
|
251
298
|
);
|
|
252
|
-
}
|
|
253
299
|
enqueue(event);
|
|
254
300
|
};
|
|
255
301
|
void pipeline();
|
|
@@ -278,28 +324,20 @@ async function captureAndAttachReplay(event: Event, ndjson: string): Promise<voi
|
|
|
278
324
|
{ source: 'js' },
|
|
279
325
|
);
|
|
280
326
|
if (!meta) {
|
|
281
|
-
|
|
282
|
-
// eslint-disable-next-line no-console
|
|
283
|
-
console.warn(
|
|
284
|
-
'[sentori] replay upload returned null',
|
|
327
|
+
logger.debug('capture', 'replay upload returned null',
|
|
285
328
|
'eventId=', event.id,
|
|
286
329
|
'ndjsonBytes=', ndjson.length,
|
|
287
330
|
);
|
|
288
|
-
}
|
|
289
331
|
return;
|
|
290
332
|
}
|
|
291
333
|
if (!event.attachments) event.attachments = [];
|
|
292
334
|
event.attachments.push(meta);
|
|
293
335
|
} catch (e) {
|
|
294
|
-
|
|
295
|
-
// eslint-disable-next-line no-console
|
|
296
|
-
console.warn(
|
|
297
|
-
'[sentori] replay attachment threw',
|
|
336
|
+
logger.debug('capture', 'replay attachment threw',
|
|
298
337
|
'eventId=', event.id,
|
|
299
338
|
'ndjsonBytes=', ndjson.length,
|
|
300
339
|
e,
|
|
301
340
|
);
|
|
302
|
-
}
|
|
303
341
|
}
|
|
304
342
|
}
|
|
305
343
|
|
|
@@ -444,31 +482,20 @@ async function captureAndAttachScreenshot(event: Event): Promise<void> {
|
|
|
444
482
|
try {
|
|
445
483
|
blob = await captureScreenshot();
|
|
446
484
|
} catch (e) {
|
|
447
|
-
|
|
448
|
-
// eslint-disable-next-line no-console
|
|
449
|
-
console.warn('[sentori] screenshot capture threw', e);
|
|
450
|
-
}
|
|
485
|
+
logger.debug('capture', 'screenshot capture threw', e);
|
|
451
486
|
}
|
|
452
487
|
if (!blob) {
|
|
453
|
-
|
|
454
|
-
// eslint-disable-next-line no-console
|
|
455
|
-
console.warn(
|
|
456
|
-
'[sentori] screenshot blob null — native module missing or capture returned null',
|
|
488
|
+
logger.debug('capture', 'screenshot blob null — native module missing or capture returned null',
|
|
457
489
|
'eventId=', event.id,
|
|
458
490
|
);
|
|
459
|
-
}
|
|
460
491
|
addBreadcrumb({ type: 'custom', data: { reason: 'screenshot-capture-failed' } });
|
|
461
492
|
return;
|
|
462
493
|
}
|
|
463
|
-
|
|
464
|
-
// eslint-disable-next-line no-console
|
|
465
|
-
console.warn(
|
|
466
|
-
'[sentori] screenshot blob ok, uploading',
|
|
494
|
+
logger.debug('capture', 'screenshot blob ok, uploading',
|
|
467
495
|
'eventId=', event.id,
|
|
468
496
|
'mediaType=', blob.mediaType,
|
|
469
497
|
'base64Bytes=', blob.base64.length,
|
|
470
498
|
);
|
|
471
|
-
}
|
|
472
499
|
const attachment: AttachmentMeta | null = await uploadAttachment(
|
|
473
500
|
event.id,
|
|
474
501
|
'screenshot',
|
|
@@ -476,10 +503,7 @@ async function captureAndAttachScreenshot(event: Event): Promise<void> {
|
|
|
476
503
|
{ source: 'js' },
|
|
477
504
|
);
|
|
478
505
|
if (!attachment) {
|
|
479
|
-
|
|
480
|
-
// eslint-disable-next-line no-console
|
|
481
|
-
console.warn('[sentori] screenshot upload returned null', 'eventId=', event.id);
|
|
482
|
-
}
|
|
506
|
+
logger.debug('capture', 'screenshot upload returned null', 'eventId=', event.id);
|
|
483
507
|
addBreadcrumb({ type: 'custom', data: { reason: 'screenshot-upload-failed' } });
|
|
484
508
|
return;
|
|
485
509
|
}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.3 W6.3 — Sentry-compatible API surface.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in for code (or LLM-generated code) written against
|
|
5
|
+
* `@sentry/react-native`. Every Sentry call maps to exactly one
|
|
6
|
+
* Sentori-native call internally. Translation differences (e.g.
|
|
7
|
+
* `Sentry.setUser({ ip_address })` — Sentori never stores IP) fire
|
|
8
|
+
* a one-shot console hint at `info` level, deduplicated per
|
|
9
|
+
* (api, dropped_field).
|
|
10
|
+
*
|
|
11
|
+
* Why this exists: LLMs have seen a LOT of Sentry code; letting
|
|
12
|
+
* them write the same syntax against Sentori is one less thing
|
|
13
|
+
* Sentori asks the host to think about. Combined with the v2.3
|
|
14
|
+
* "free bonus" stance — host adds Sentori without unlearning
|
|
15
|
+
* anything.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
*
|
|
19
|
+
* import * as Sentry from '@goliapkg/sentori-react-native/compat'
|
|
20
|
+
*
|
|
21
|
+
* Sentry.init({ dsn: 'https://<token>@<host>/<projectId>', ... })
|
|
22
|
+
* Sentry.captureException(err)
|
|
23
|
+
* Sentry.setUser({ id, email }) // email → linkBy.email (hashed)
|
|
24
|
+
*
|
|
25
|
+
* The compat layer holds NO state of its own — it's a thin shim
|
|
26
|
+
* over the same Sentori internals. Mixing `Sentry.*` and
|
|
27
|
+
* `sentori.*` calls in the same app works fine.
|
|
28
|
+
*
|
|
29
|
+
* See `docs/design/sdk-v2.3-redesign.md` §4 for the full
|
|
30
|
+
* translation table + design rationale.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { logger } from '@goliapkg/sentori-core'
|
|
34
|
+
|
|
35
|
+
import { addBreadcrumb as nativeAddBreadcrumb } from '../breadcrumbs'
|
|
36
|
+
import {
|
|
37
|
+
captureException as nativeCaptureException,
|
|
38
|
+
captureMessage as nativeCaptureMessage,
|
|
39
|
+
setTag as nativeSetTag,
|
|
40
|
+
setTags as nativeSetTags,
|
|
41
|
+
setUser as nativeSetUser,
|
|
42
|
+
} from '../capture'
|
|
43
|
+
import { type InitOptions, init as nativeInit } from '../init'
|
|
44
|
+
|
|
45
|
+
// ── one-shot warn dedup ───────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const _warnedOnce = new Set<string>()
|
|
48
|
+
function warnOnce(key: string, msg: string): void {
|
|
49
|
+
if (_warnedOnce.has(key)) return
|
|
50
|
+
_warnedOnce.add(key)
|
|
51
|
+
logger.info('compat', msg)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── DSN parsing ───────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
type SentryInitOpts = {
|
|
57
|
+
dsn: string
|
|
58
|
+
environment?: string
|
|
59
|
+
release?: string
|
|
60
|
+
tracesSampleRate?: number
|
|
61
|
+
sampleRate?: number
|
|
62
|
+
attachStacktrace?: boolean
|
|
63
|
+
autoSessionTracking?: boolean
|
|
64
|
+
/** Sentry's `debug: true` ≈ Sentori's `logLevel: 'debug'`. */
|
|
65
|
+
debug?: boolean
|
|
66
|
+
/** Catch-all for fields Sentori either ignores or doesn't map yet. */
|
|
67
|
+
[other: string]: unknown
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseDsn(dsn: string): { token: string; ingestUrl: string } {
|
|
71
|
+
// Sentry DSN shape: `https://<key>@<host>[:port][/<projectId>]`
|
|
72
|
+
// Sentori cares about `<key>` (must be `st_pk_…`) and `<host>`.
|
|
73
|
+
let url: URL
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(dsn)
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(`Sentory compat: dsn is not a valid URL: ${dsn}`)
|
|
78
|
+
}
|
|
79
|
+
const key = url.username
|
|
80
|
+
if (!key) {
|
|
81
|
+
throw new Error(`Sentory compat: dsn missing token in user-info component`)
|
|
82
|
+
}
|
|
83
|
+
if (!key.startsWith('st_pk_')) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Sentory compat: dsn token must start with 'st_pk_' (got prefix '${key.slice(0, 8)}…'). ` +
|
|
86
|
+
`Sentori does not parse Sentry-issued tokens — generate a Sentori project token via the dashboard.`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
// strip user-info to reconstruct the ingest origin
|
|
90
|
+
const ingestUrl = `${url.protocol}//${url.host}`
|
|
91
|
+
return { token: key, ingestUrl }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Sentry.init ───────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function init(opts: SentryInitOpts): void {
|
|
97
|
+
const { token, ingestUrl } = parseDsn(opts.dsn)
|
|
98
|
+
|
|
99
|
+
const sentoriOpts: InitOptions = {
|
|
100
|
+
token,
|
|
101
|
+
release: opts.release ?? '',
|
|
102
|
+
ingestUrl,
|
|
103
|
+
...(opts.environment ? { environment: opts.environment } : {}),
|
|
104
|
+
sample: {
|
|
105
|
+
...(opts.tracesSampleRate !== undefined ? { traces: opts.tracesSampleRate } : {}),
|
|
106
|
+
...(opts.sampleRate !== undefined ? { errors: opts.sampleRate } : {}),
|
|
107
|
+
},
|
|
108
|
+
...(opts.debug ? { logLevel: 'debug' as const } : {}),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Empty release is the most common Sentry-init mistake; warn but
|
|
112
|
+
// continue.
|
|
113
|
+
if (!sentoriOpts.release) {
|
|
114
|
+
warnOnce(
|
|
115
|
+
'init:no-release',
|
|
116
|
+
'Sentry.init() with no `release` — Sentori requires release for grouping + drop-down menus to make sense. Set `release: "myapp@1.2.3"` for production cuts.',
|
|
117
|
+
)
|
|
118
|
+
// Sentori's init throws when release is empty; provide a
|
|
119
|
+
// reasonable fallback so the rest of the code path works in dev.
|
|
120
|
+
sentoriOpts.release = `unspecified@${Date.now()}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Pass-through informational hints for ignored fields.
|
|
124
|
+
for (const ignored of [
|
|
125
|
+
'attachStacktrace',
|
|
126
|
+
'autoSessionTracking',
|
|
127
|
+
'integrations',
|
|
128
|
+
'beforeSend',
|
|
129
|
+
'beforeBreadcrumb',
|
|
130
|
+
'maxBreadcrumbs',
|
|
131
|
+
]) {
|
|
132
|
+
if (ignored in opts) {
|
|
133
|
+
warnOnce(
|
|
134
|
+
`init:ignored:${ignored}`,
|
|
135
|
+
`Sentry.init({ ${ignored} }) ignored. ` +
|
|
136
|
+
`${getIgnoredHint(ignored)}`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
nativeInit(sentoriOpts)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getIgnoredHint(field: string): string {
|
|
145
|
+
switch (field) {
|
|
146
|
+
case 'attachStacktrace':
|
|
147
|
+
return 'Sentori always sends stack traces — no toggle.'
|
|
148
|
+
case 'autoSessionTracking':
|
|
149
|
+
return "Sentori sessions are on by default; toggle via `init({ capture: { sessions: true|false } })`."
|
|
150
|
+
case 'integrations':
|
|
151
|
+
return 'Sentori uses `init({ capture: {...} })` toggles instead of Integration classes — see the docs.'
|
|
152
|
+
case 'beforeSend':
|
|
153
|
+
case 'beforeBreadcrumb':
|
|
154
|
+
return 'Sentori does not support an arbitrary beforeSend hook today. Server-side PII scrubbing is automatic.'
|
|
155
|
+
case 'maxBreadcrumbs':
|
|
156
|
+
return 'Sentori uses a fixed 100-slot ring buffer.'
|
|
157
|
+
default:
|
|
158
|
+
return ''
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Severity / level enum ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** Sentry's severity values, surfaced as strings (Sentori only
|
|
165
|
+
* uses 5 levels). `Log` and `Critical` collapse to `'info'` and
|
|
166
|
+
* `'fatal'` respectively. */
|
|
167
|
+
export const Severity = {
|
|
168
|
+
Fatal: 'fatal' as const,
|
|
169
|
+
Critical: 'fatal' as const,
|
|
170
|
+
Error: 'error' as const,
|
|
171
|
+
Warning: 'warning' as const,
|
|
172
|
+
Log: 'info' as const,
|
|
173
|
+
Info: 'info' as const,
|
|
174
|
+
Debug: 'debug' as const,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type SentryLevelString =
|
|
178
|
+
| 'critical'
|
|
179
|
+
| 'debug'
|
|
180
|
+
| 'error'
|
|
181
|
+
| 'fatal'
|
|
182
|
+
| 'info'
|
|
183
|
+
| 'log'
|
|
184
|
+
| 'warning'
|
|
185
|
+
|
|
186
|
+
type SentoriLevel = 'debug' | 'error' | 'fatal' | 'info' | 'warning'
|
|
187
|
+
|
|
188
|
+
function mapLevel(level: SentryLevelString | undefined): SentoriLevel | undefined {
|
|
189
|
+
if (!level) return undefined
|
|
190
|
+
switch (level) {
|
|
191
|
+
case 'critical':
|
|
192
|
+
warnOnce(
|
|
193
|
+
'severity:critical',
|
|
194
|
+
"Sentry.Severity.Critical → mapped to 'fatal' (Sentori's 5-level syslog-style scale).",
|
|
195
|
+
)
|
|
196
|
+
return 'fatal'
|
|
197
|
+
case 'log':
|
|
198
|
+
warnOnce(
|
|
199
|
+
'severity:log',
|
|
200
|
+
"Sentry.Severity.Log → mapped to 'info' (Sentori's 5-level scale has no separate Log).",
|
|
201
|
+
)
|
|
202
|
+
return 'info'
|
|
203
|
+
default:
|
|
204
|
+
return level as SentoriLevel
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── captureException ──────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
type SentryCaptureContext = {
|
|
211
|
+
tags?: Record<string, string>
|
|
212
|
+
extra?: Record<string, unknown>
|
|
213
|
+
level?: SentryLevelString
|
|
214
|
+
fingerprint?: string[]
|
|
215
|
+
user?: SentrySetUserInput
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function captureException(
|
|
219
|
+
err: unknown,
|
|
220
|
+
hint?: { captureContext?: SentryCaptureContext } | SentryCaptureContext,
|
|
221
|
+
): void {
|
|
222
|
+
// Sentry v8+ takes the context inline (Hint); earlier versions
|
|
223
|
+
// wrapped it in `{ captureContext: {...} }`. Accept both.
|
|
224
|
+
const ctx: SentryCaptureContext | undefined = (() => {
|
|
225
|
+
if (!hint) return undefined
|
|
226
|
+
if ('captureContext' in (hint as { captureContext?: unknown })) {
|
|
227
|
+
return (hint as { captureContext?: SentryCaptureContext }).captureContext
|
|
228
|
+
}
|
|
229
|
+
return hint as SentryCaptureContext
|
|
230
|
+
})()
|
|
231
|
+
|
|
232
|
+
if (ctx?.extra) {
|
|
233
|
+
warnOnce(
|
|
234
|
+
'captureException:extra',
|
|
235
|
+
'Sentry.captureException(err, { extra }) → `extra` mapped to `tags` (Sentori does not have a separate extra namespace).',
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
if (ctx?.user) {
|
|
239
|
+
// Apply the per-call user via setUser (Sentori takes the
|
|
240
|
+
// current scope user automatically).
|
|
241
|
+
setUser(ctx.user)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const mergedTags = {
|
|
245
|
+
...(ctx?.tags ?? {}),
|
|
246
|
+
...(ctx?.extra
|
|
247
|
+
? Object.fromEntries(
|
|
248
|
+
Object.entries(ctx.extra).map(([k, v]) => [k, String(v)]),
|
|
249
|
+
)
|
|
250
|
+
: {}),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
nativeCaptureException(err as Error, {
|
|
254
|
+
...(Object.keys(mergedTags).length > 0 ? { tags: mergedTags } : {}),
|
|
255
|
+
...(ctx?.fingerprint ? { fingerprint: ctx.fingerprint } : {}),
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── captureMessage ────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
export function captureMessage(
|
|
262
|
+
msg: string,
|
|
263
|
+
levelOrCtx?: SentryCaptureContext | SentryLevelString,
|
|
264
|
+
): void {
|
|
265
|
+
let level: SentoriLevel | undefined
|
|
266
|
+
let tags: Record<string, string> | undefined
|
|
267
|
+
if (typeof levelOrCtx === 'string') {
|
|
268
|
+
level = mapLevel(levelOrCtx)
|
|
269
|
+
} else if (levelOrCtx) {
|
|
270
|
+
level = mapLevel(levelOrCtx.level)
|
|
271
|
+
tags = levelOrCtx.tags
|
|
272
|
+
}
|
|
273
|
+
nativeCaptureMessage(msg, {
|
|
274
|
+
...(level ? { level } : {}),
|
|
275
|
+
...(tags ? { tags } : {}),
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── setUser ───────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
type SentrySetUserInput = {
|
|
282
|
+
id?: string
|
|
283
|
+
email?: string
|
|
284
|
+
username?: string
|
|
285
|
+
ip_address?: string
|
|
286
|
+
segment?: string
|
|
287
|
+
[other: string]: unknown
|
|
288
|
+
} | null
|
|
289
|
+
|
|
290
|
+
export function setUser(user: SentrySetUserInput): void {
|
|
291
|
+
if (user == null) {
|
|
292
|
+
nativeSetUser(null)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
const { id, email, username, ip_address, segment, ...rest } = user
|
|
296
|
+
|
|
297
|
+
if (ip_address !== undefined) {
|
|
298
|
+
warnOnce(
|
|
299
|
+
'setUser:ip_address',
|
|
300
|
+
'Sentry.setUser({ ip_address }) → dropped. Sentori never stores IP (privacy by design).',
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
if (segment !== undefined) {
|
|
304
|
+
warnOnce(
|
|
305
|
+
'setUser:segment',
|
|
306
|
+
'Sentry.setUser({ segment }) → mapped to tag `user.segment`. Set via setTag for clarity.',
|
|
307
|
+
)
|
|
308
|
+
if (typeof segment === 'string') nativeSetTag('user.segment', segment)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const linkBy: Record<string, string> = {}
|
|
312
|
+
if (email) linkBy.email = email
|
|
313
|
+
if (username) linkBy.username = username
|
|
314
|
+
|
|
315
|
+
// Surface any other fields the host bolted on (Sentry historically
|
|
316
|
+
// accepted arbitrary keys) — they pass through as tags.
|
|
317
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
318
|
+
if (v !== undefined && v !== null) nativeSetTag(`user.${k}`, String(v))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
nativeSetUser({
|
|
322
|
+
...(id ? { id } : {}),
|
|
323
|
+
...(Object.keys(linkBy).length > 0 ? { linkBy } : {}),
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── setTag / setTags ──────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
// Re-export Sentori-native semantics; identical signatures.
|
|
330
|
+
export const setTag = nativeSetTag
|
|
331
|
+
export const setTags = nativeSetTags
|
|
332
|
+
|
|
333
|
+
// ── addBreadcrumb ─────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
type SentryBreadcrumb = {
|
|
336
|
+
category?: string
|
|
337
|
+
message?: string
|
|
338
|
+
level?: SentryLevelString
|
|
339
|
+
type?: 'default' | 'error' | 'http' | 'info' | 'navigation' | 'query' | 'user' | string
|
|
340
|
+
data?: Record<string, unknown>
|
|
341
|
+
timestamp?: number
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
type SentoriBreadcrumbType = 'custom' | 'log' | 'nav' | 'net' | 'user'
|
|
345
|
+
|
|
346
|
+
function mapCategoryToType(category: string | undefined): SentoriBreadcrumbType | undefined {
|
|
347
|
+
if (!category) return undefined
|
|
348
|
+
if (['auth', 'click', 'gesture', 'input', 'touch', 'ui'].includes(category)) return 'user'
|
|
349
|
+
if (['fetch', 'http', 'xhr'].includes(category)) return 'net'
|
|
350
|
+
if (['nav', 'navigation', 'route'].includes(category)) return 'nav'
|
|
351
|
+
if (['console', 'log', 'sentry'].includes(category)) return 'log'
|
|
352
|
+
return 'custom'
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function mapSentryType(t: string | undefined): SentoriBreadcrumbType | undefined {
|
|
356
|
+
if (!t) return undefined
|
|
357
|
+
switch (t) {
|
|
358
|
+
case 'http':
|
|
359
|
+
return 'net'
|
|
360
|
+
case 'navigation':
|
|
361
|
+
return 'nav'
|
|
362
|
+
case 'user':
|
|
363
|
+
case 'log':
|
|
364
|
+
case 'custom':
|
|
365
|
+
return t
|
|
366
|
+
default:
|
|
367
|
+
return 'custom'
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function addBreadcrumb(crumb: SentryBreadcrumb): void {
|
|
372
|
+
if (!crumb.message) {
|
|
373
|
+
crumb.message = crumb.category ?? crumb.type ?? '(no message)'
|
|
374
|
+
}
|
|
375
|
+
if (crumb.category && !crumb.type) {
|
|
376
|
+
warnOnce(
|
|
377
|
+
'breadcrumb:category',
|
|
378
|
+
'Sentry.addBreadcrumb({ category }) → mapped to `type` via well-known table; the category string itself is preserved under `data.category`.',
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
// RN SDK shape: { type, data }. No top-level `message` —
|
|
382
|
+
// Sentry's message goes into data.message.
|
|
383
|
+
nativeAddBreadcrumb({
|
|
384
|
+
type: mapSentryType(crumb.type) ?? mapCategoryToType(crumb.category) ?? 'custom',
|
|
385
|
+
data: {
|
|
386
|
+
message: crumb.message,
|
|
387
|
+
...(crumb.data ?? {}),
|
|
388
|
+
...(crumb.category ? { category: crumb.category } : {}),
|
|
389
|
+
...(crumb.level ? { level: mapLevel(crumb.level) } : {}),
|
|
390
|
+
},
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── flush / close ─────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
// Re-export Sentori native flush + close — same signatures.
|
|
397
|
+
export { close, flush } from '../lifecycle'
|
|
398
|
+
|
|
399
|
+
// ── startTransaction / startSpan / withScope ──────────────────────────────
|
|
400
|
+
|
|
401
|
+
// Trace mapping is non-trivial (Sentry's transaction object exposes
|
|
402
|
+
// startChild, etc.). v2.3 ships a minimum-viable Sentry trace
|
|
403
|
+
// surface that supports startTransaction returning a Sentori Span
|
|
404
|
+
// object with a partial Sentry-style API (.startChild, .finish).
|
|
405
|
+
// Anything beyond that throws a clear error directing to the native
|
|
406
|
+
// `sentori.startSpan` / `sentori.withScopedSpan`.
|
|
407
|
+
|
|
408
|
+
import { startSpan } from '@goliapkg/sentori-core'
|
|
409
|
+
|
|
410
|
+
type SentrySpanOpts = {
|
|
411
|
+
op?: string
|
|
412
|
+
name?: string
|
|
413
|
+
description?: string
|
|
414
|
+
tags?: Record<string, string>
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function startTransaction(opts: SentrySpanOpts): {
|
|
418
|
+
finish: (status?: 'cancelled' | 'error' | 'ok') => void
|
|
419
|
+
setStatus: (status: 'cancelled' | 'error' | 'ok') => void
|
|
420
|
+
setTag: (k: string, v: string) => void
|
|
421
|
+
startChild: (childOpts: SentrySpanOpts) => unknown
|
|
422
|
+
} {
|
|
423
|
+
warnOnce(
|
|
424
|
+
'startTransaction',
|
|
425
|
+
'Sentry.startTransaction() → mapped to sentori.startSpan() with op as name. Native equivalent: sentori.startTrace(name) or sentori.startSpan({ name }).',
|
|
426
|
+
)
|
|
427
|
+
const name = opts.name ?? opts.op ?? 'transaction'
|
|
428
|
+
const span = startSpan(name, {
|
|
429
|
+
parent: null,
|
|
430
|
+
tags: opts.tags,
|
|
431
|
+
})
|
|
432
|
+
return {
|
|
433
|
+
finish: (status) => span.finish({ status: status === 'ok' ? 'ok' : 'error' }),
|
|
434
|
+
setStatus: (status) => { span.setTag('status', status) },
|
|
435
|
+
setTag: (k, v) => { span.setTag(k, v) },
|
|
436
|
+
startChild: (childOpts) => {
|
|
437
|
+
return startSpan(childOpts.name ?? childOpts.op ?? 'child', {
|
|
438
|
+
tags: childOpts.tags,
|
|
439
|
+
})
|
|
440
|
+
},
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── withScope / configureScope (no-op scoping; same state as native) ─────
|
|
445
|
+
|
|
446
|
+
type ScopeProxy = {
|
|
447
|
+
setTag: (k: string, v: string) => void
|
|
448
|
+
setTags: (rec: Record<string, string>) => void
|
|
449
|
+
setUser: (u: SentrySetUserInput) => void
|
|
450
|
+
setExtra: (k: string, v: unknown) => void
|
|
451
|
+
setLevel: (l: SentryLevelString) => void
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function scopeProxy(): ScopeProxy {
|
|
455
|
+
return {
|
|
456
|
+
setTag: nativeSetTag,
|
|
457
|
+
setTags: nativeSetTags,
|
|
458
|
+
setUser,
|
|
459
|
+
setExtra: (k, v) => nativeSetTag(`extra.${k}`, String(v)),
|
|
460
|
+
setLevel: () => {
|
|
461
|
+
warnOnce(
|
|
462
|
+
'scope:setLevel',
|
|
463
|
+
'Sentry.withScope(s => s.setLevel(…)) → not supported. Sentori levels travel on capture call, not on scope.',
|
|
464
|
+
)
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function withScope<T>(fn: (scope: ScopeProxy) => T): T {
|
|
470
|
+
// Sentori has no Hub; tags set inside `fn` persist (best-effort
|
|
471
|
+
// approximation of Sentry semantics). For most callers this is
|
|
472
|
+
// fine; tighter isolation needs an actual Hub which we don't ship.
|
|
473
|
+
warnOnce(
|
|
474
|
+
'withScope',
|
|
475
|
+
'Sentry.withScope() → tag mutations are NOT auto-reverted on scope exit. Use sentori.setTag/clearTags explicitly for tight isolation.',
|
|
476
|
+
)
|
|
477
|
+
return fn(scopeProxy())
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function configureScope(fn: (scope: ScopeProxy) => void): void {
|
|
481
|
+
fn(scopeProxy())
|
|
482
|
+
}
|