@goliapkg/sentori-react-native 2.1.0 → 3.0.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.
Files changed (50) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/AndroidManifest.xml +24 -1
  3. package/android/src/main/java/com/sentori/SentoriFirebaseMessagingService.kt +56 -0
  4. package/android/src/main/java/com/sentori/SentoriModule.kt +43 -0
  5. package/android/src/main/java/com/sentori/SentoriPushNotifications.kt +293 -0
  6. package/ios/SentoriModule.swift +39 -0
  7. package/ios/SentoriPushNotifications.swift +303 -0
  8. package/lib/capture.d.ts +6 -1
  9. package/lib/capture.d.ts.map +1 -1
  10. package/lib/capture.js +49 -2
  11. package/lib/capture.js.map +1 -1
  12. package/lib/compat/sentry.d.ts +14 -0
  13. package/lib/compat/sentry.d.ts.map +1 -1
  14. package/lib/compat/sentry.js +6 -0
  15. package/lib/compat/sentry.js.map +1 -1
  16. package/lib/config.d.ts +12 -18
  17. package/lib/config.d.ts.map +1 -1
  18. package/lib/config.js.map +1 -1
  19. package/lib/expo-compat.d.ts +270 -0
  20. package/lib/expo-compat.d.ts.map +1 -0
  21. package/lib/expo-compat.js +500 -0
  22. package/lib/expo-compat.js.map +1 -0
  23. package/lib/index.d.ts +17 -0
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +18 -0
  26. package/lib/index.js.map +1 -1
  27. package/lib/init.d.ts +7 -0
  28. package/lib/init.d.ts.map +1 -1
  29. package/lib/init.js +3 -0
  30. package/lib/init.js.map +1 -1
  31. package/lib/native.d.ts +17 -0
  32. package/lib/native.d.ts.map +1 -1
  33. package/lib/native.js +57 -0
  34. package/lib/native.js.map +1 -1
  35. package/lib/push.d.ts +58 -0
  36. package/lib/push.d.ts.map +1 -0
  37. package/lib/push.js +294 -0
  38. package/lib/push.js.map +1 -0
  39. package/package.json +9 -5
  40. package/src/__tests__/before-send.test.ts +72 -0
  41. package/src/__tests__/compat-sentry.test.ts +121 -0
  42. package/src/__tests__/push.test.ts +178 -0
  43. package/src/capture.ts +48 -2
  44. package/src/compat/sentry.ts +8 -0
  45. package/src/config.ts +12 -15
  46. package/src/expo-compat.ts +698 -0
  47. package/src/index.ts +40 -0
  48. package/src/init.ts +10 -0
  49. package/src/native.ts +102 -0
  50. package/src/push.ts +382 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * v2.3 W6.3 — Sentry-compat translation layer coverage.
3
+ *
4
+ * We exercise the pure-function pieces directly via the test hooks
5
+ * exported from the compat module (parseDsn, mapCategoryToType,
6
+ * mapLevel). The stateful pieces — init / captureException /
7
+ * setUser dispatching to the native init + scope — are covered by
8
+ * the upstream Sentori-native tests (sdk.test, before-send.test);
9
+ * the compat layer only re-shapes the call arguments before
10
+ * delegating, so testing the re-shape is enough.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+
14
+ import {
15
+ __mapCategoryToTypeForTests,
16
+ __mapLevelForTests,
17
+ __parseDsnForTests,
18
+ Severity,
19
+ } from '../compat/sentry';
20
+
21
+ describe('parseDsn', () => {
22
+ test('extracts token + ingestUrl from a Sentori DSN', () => {
23
+ const r = __parseDsnForTests(
24
+ 'https://st_pk_testabcdef@ingest.sentori.golia.jp/1',
25
+ );
26
+ expect(r.token).toBe('st_pk_testabcdef');
27
+ expect(r.ingestUrl).toBe('https://ingest.sentori.golia.jp');
28
+ });
29
+
30
+ test('honours custom port + host', () => {
31
+ const r = __parseDsnForTests('https://st_pk_x@self-hosted.example.com:8443/42');
32
+ expect(r.token).toBe('st_pk_x');
33
+ expect(r.ingestUrl).toBe('https://self-hosted.example.com:8443');
34
+ });
35
+
36
+ test('rejects non-Sentori token prefix with a clear pointer', () => {
37
+ expect(() =>
38
+ __parseDsnForTests('https://abcd1234@sentry.io/12345'),
39
+ ).toThrow(/st_pk_/);
40
+ });
41
+
42
+ test('rejects DSN without user-info (no token)', () => {
43
+ expect(() => __parseDsnForTests('https://sentry.io/12345')).toThrow();
44
+ });
45
+
46
+ test('rejects malformed URL', () => {
47
+ expect(() => __parseDsnForTests('not-a-url')).toThrow(/not a valid URL/);
48
+ });
49
+ });
50
+
51
+ describe('mapCategoryToType', () => {
52
+ test('user-interaction categories → user', () => {
53
+ for (const c of ['auth', 'click', 'gesture', 'input', 'touch', 'ui']) {
54
+ expect(__mapCategoryToTypeForTests(c)).toBe('user');
55
+ }
56
+ });
57
+
58
+ test('network categories → net', () => {
59
+ for (const c of ['fetch', 'http', 'xhr']) {
60
+ expect(__mapCategoryToTypeForTests(c)).toBe('net');
61
+ }
62
+ });
63
+
64
+ test('navigation categories → nav', () => {
65
+ for (const c of ['nav', 'navigation', 'route']) {
66
+ expect(__mapCategoryToTypeForTests(c)).toBe('nav');
67
+ }
68
+ });
69
+
70
+ test('log-shaped categories → log', () => {
71
+ for (const c of ['console', 'log', 'sentry']) {
72
+ expect(__mapCategoryToTypeForTests(c)).toBe('log');
73
+ }
74
+ });
75
+
76
+ test('unknown category → custom', () => {
77
+ expect(__mapCategoryToTypeForTests('whatever')).toBe('custom');
78
+ });
79
+
80
+ test('undefined → undefined (pass-through to native default)', () => {
81
+ expect(__mapCategoryToTypeForTests(undefined)).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe('mapLevel', () => {
86
+ test("Sentry's 'critical' maps to Sentori's 'fatal'", () => {
87
+ expect(__mapLevelForTests('critical')).toBe('fatal');
88
+ });
89
+
90
+ test("Sentry's 'log' maps to Sentori's 'info' (no separate Log level)", () => {
91
+ expect(__mapLevelForTests('log')).toBe('info');
92
+ });
93
+
94
+ test('5-level syslog names pass through unchanged', () => {
95
+ expect(__mapLevelForTests('fatal')).toBe('fatal');
96
+ expect(__mapLevelForTests('error')).toBe('error');
97
+ expect(__mapLevelForTests('warning')).toBe('warning');
98
+ expect(__mapLevelForTests('info')).toBe('info');
99
+ expect(__mapLevelForTests('debug')).toBe('debug');
100
+ });
101
+
102
+ test('undefined → undefined', () => {
103
+ expect(__mapLevelForTests(undefined)).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe('Severity export', () => {
108
+ test('Critical collapses onto fatal', () => {
109
+ expect(Severity.Critical).toBe('fatal');
110
+ });
111
+ test('Log collapses onto info', () => {
112
+ expect(Severity.Log).toBe('info');
113
+ });
114
+ test('standard levels are themselves', () => {
115
+ expect(Severity.Fatal).toBe('fatal');
116
+ expect(Severity.Error).toBe('error');
117
+ expect(Severity.Warning).toBe('warning');
118
+ expect(Severity.Info).toBe('info');
119
+ expect(Severity.Debug).toBe('debug');
120
+ });
121
+ });
@@ -0,0 +1,178 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+
3
+ // Mock the native bridge layer only — leaves expo-modules-core / react-native
4
+ // pristine so other test files in the same bun process aren't affected.
5
+
6
+ type DrainResult = {
7
+ token?: string
8
+ error?: string
9
+ notifications: Array<Record<string, unknown>>
10
+ taps: Array<Record<string, unknown>>
11
+ }
12
+
13
+ let _drainQueue: DrainResult[] = []
14
+ let _permissionAnswer: null | string = null
15
+ let _registerInvocations = 0
16
+ let _unregisterInvocations = 0
17
+
18
+ mock.module('../native', () => ({
19
+ pushGetStatus: async () => _permissionAnswer,
20
+ pushRequestPermission: async () => _permissionAnswer ?? 'notDetermined',
21
+ pushRegister: () => {
22
+ _registerInvocations++
23
+ },
24
+ pushUnregister: () => {
25
+ _unregisterInvocations++
26
+ },
27
+ pushDrainState: async () => {
28
+ if (_drainQueue.length === 0) {
29
+ return { notifications: [], taps: [] }
30
+ }
31
+ return _drainQueue.shift()!
32
+ },
33
+ }))
34
+
35
+ // Mock the config getter — push reads ingest URL + bearer from here.
36
+ // Stub ALL public exports so other test files that import named
37
+ // exports from '../config' don't get a "Export named X not found"
38
+ // error from the partial mock (bun mock.module replaces the whole
39
+ // module surface, not just the named exports listed).
40
+ let _mockConfig: { ingestUrl: string; token: string } | null = null
41
+ mock.module('../config', () => ({
42
+ getConfig: () => _mockConfig,
43
+ isInitialized: () => _mockConfig !== null,
44
+ setConfig: (c: typeof _mockConfig) => {
45
+ _mockConfig = c
46
+ },
47
+ __resetForTests: () => {
48
+ _mockConfig = null
49
+ },
50
+ }))
51
+
52
+ import { register, unregister, getCachedIpt, __setPlatformForTests } from '../push'
53
+
54
+ type FetchCall = { url: string; method: string; body?: string }
55
+ let _fetchCalls: FetchCall[] = []
56
+ let _fetchResponse: { status: number; body: unknown } = {
57
+ status: 200,
58
+ body: { id: 'ipt_deadbeef' },
59
+ }
60
+
61
+ const originalFetch = globalThis.fetch
62
+ beforeEach(() => {
63
+ _fetchCalls = []
64
+ _mockConfig = { ingestUrl: 'https://ingest.test', token: 'st_test' }
65
+ _drainQueue = []
66
+ _permissionAnswer = null
67
+ _registerInvocations = 0
68
+ _unregisterInvocations = 0
69
+ _fetchResponse = { status: 200, body: { id: 'ipt_deadbeef' } }
70
+ globalThis.fetch = (async (
71
+ input: RequestInfo | URL,
72
+ init?: RequestInit,
73
+ ): Promise<Response> => {
74
+ _fetchCalls.push({
75
+ url: typeof input === 'string' ? input : input.toString(),
76
+ method: init?.method ?? 'GET',
77
+ body: typeof init?.body === 'string' ? init.body : undefined,
78
+ })
79
+ return new Response(JSON.stringify(_fetchResponse.body), {
80
+ status: _fetchResponse.status,
81
+ })
82
+ }) as typeof fetch
83
+ })
84
+
85
+ afterEach(() => {
86
+ globalThis.fetch = originalFetch
87
+ })
88
+
89
+ describe('push.register', () => {
90
+ it('rejects cleanly when permission is denied', async () => {
91
+ _permissionAnswer = 'denied'
92
+ await expect(register()).rejects.toThrow(/permission/i)
93
+ expect(_fetchCalls).toHaveLength(0)
94
+ })
95
+
96
+ it('rejects when the native token times out', async () => {
97
+ _permissionAnswer = 'granted'
98
+ await expect(register({ tokenTimeoutMs: 50 })).rejects.toThrow(/not received/i)
99
+ expect(_fetchCalls).toHaveLength(0)
100
+ })
101
+
102
+ it('POSTs to /v1/push/tokens with the APNs hex token and resolves to ipt', async () => {
103
+ _permissionAnswer = 'granted'
104
+ _drainQueue = [
105
+ { notifications: [], taps: [] },
106
+ { token: '0123abcdef', notifications: [], taps: [] },
107
+ ]
108
+ const result = await register({ linkHash: 'h1' })
109
+ expect(result.ipt).toBe('ipt_deadbeef')
110
+ expect(getCachedIpt()).toBe('ipt_deadbeef')
111
+ expect(_fetchCalls).toHaveLength(1)
112
+ expect(_fetchCalls[0]?.url).toContain('/v1/push/tokens')
113
+ expect(_fetchCalls[0]?.method).toBe('POST')
114
+ const body = JSON.parse(_fetchCalls[0]?.body ?? '{}')
115
+ expect(body.provider).toBe('apns')
116
+ expect(body.nativeToken).toBe('0123abcdef')
117
+ expect(body.linkHash).toBe('h1')
118
+ expect(_registerInvocations).toBe(1)
119
+ })
120
+
121
+ it('surfaces server failures', async () => {
122
+ _permissionAnswer = 'granted'
123
+ _drainQueue = [{ token: '00ff', notifications: [], taps: [] }]
124
+ _fetchResponse = { status: 503, body: { error: 'dbNotConfigured' } }
125
+ await expect(register()).rejects.toThrow(/503/)
126
+ })
127
+
128
+ it('fires onMessage for buffered notifications surfaced during waitForToken', async () => {
129
+ _permissionAnswer = 'granted'
130
+ _drainQueue = [
131
+ {
132
+ notifications: [
133
+ { id: 'n1', title: 'Hi', body: 'hello', userInfo: { x: 1 } },
134
+ ],
135
+ taps: [],
136
+ },
137
+ { token: 'abcd', notifications: [], taps: [] },
138
+ ]
139
+ const seen: Array<{ title?: string; body?: string }> = []
140
+ await register({ onMessage: (m) => seen.push(m) })
141
+ expect(seen).toHaveLength(1)
142
+ expect(seen[0]?.title).toBe('Hi')
143
+ expect(seen[0]?.body).toBe('hello')
144
+ })
145
+ })
146
+
147
+ describe('push.register — Android (FCM) branch', () => {
148
+ beforeEach(() => __setPlatformForTests('android'))
149
+ afterEach(() => __setPlatformForTests(null))
150
+
151
+ it('POSTs with provider:"fcm" and omits env on Android', async () => {
152
+ _permissionAnswer = 'granted'
153
+ _drainQueue = [{ token: 'fcm-reg-token', notifications: [], taps: [] }]
154
+ await register()
155
+ expect(_fetchCalls).toHaveLength(1)
156
+ const body = JSON.parse(_fetchCalls[0]?.body ?? '{}') as Record<string, unknown>
157
+ expect(body.provider).toBe('fcm')
158
+ expect(body.nativeToken).toBe('fcm-reg-token')
159
+ expect('env' in body).toBe(false)
160
+ })
161
+ })
162
+
163
+ describe('push.unregister', () => {
164
+ it('DELETEs the cached ipt and clears the local state', async () => {
165
+ _permissionAnswer = 'granted'
166
+ _drainQueue = [{ token: 'feedface', notifications: [], taps: [] }]
167
+ await register()
168
+ expect(getCachedIpt()).toBe('ipt_deadbeef')
169
+ _fetchCalls = []
170
+
171
+ await unregister()
172
+ expect(_fetchCalls).toHaveLength(1)
173
+ expect(_fetchCalls[0]?.url).toContain('/v1/push/tokens/ipt_deadbeef')
174
+ expect(_fetchCalls[0]?.method).toBe('DELETE')
175
+ expect(getCachedIpt()).toBeNull()
176
+ expect(_unregisterInvocations).toBeGreaterThan(0)
177
+ })
178
+ })
package/src/capture.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type BeforeSendHook,
2
3
  type CaptureMessageOptions,
3
4
  hashIdentities,
4
5
  type LinkBy,
@@ -296,11 +297,49 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
296
297
  'kinds=', (event.attachments ?? []).map((a) => a.kind).join(',') || '(none)',
297
298
  'breadcrumbsAtEnqueue=', __peekBreadcrumbCount(),
298
299
  );
299
- enqueue(event);
300
+ // v2.3 — host beforeSend hook. Sync; drops on null, falls back
301
+ // to unmutated event on throw or non-event return.
302
+ const finalEvent = applyBeforeSend(event, config.beforeSend);
303
+ if (finalEvent === null) return;
304
+ enqueue(finalEvent);
300
305
  };
301
306
  void pipeline();
302
307
  };
303
308
 
309
+ /**
310
+ * v2.3 — invoke the host's `beforeSend` hook (if any) under the
311
+ * NEVER rule. Returns the (possibly mutated) event, or null to
312
+ * drop. A throw / non-event return is treated as a no-op
313
+ * (one-shot warn + send unmodified) so a buggy host hook can't
314
+ * stall captures.
315
+ */
316
+ let _beforeSendThrewWarned = false;
317
+ function applyBeforeSend(event: Event, hook: BeforeSendHook | undefined): Event | null {
318
+ if (!hook) return event;
319
+ try {
320
+ const result = hook(event);
321
+ if (result === null) {
322
+ logger.debug('capture', 'beforeSend dropped event', 'eventId=', event.id);
323
+ return null;
324
+ }
325
+ if (typeof result !== 'object' || !result || typeof (result as Event).id !== 'string') {
326
+ // Host returned a non-event shape; fall back.
327
+ if (!_beforeSendThrewWarned) {
328
+ _beforeSendThrewWarned = true;
329
+ logger.warn('capture', 'beforeSend returned non-event shape; falling back to unmodified event');
330
+ }
331
+ return event;
332
+ }
333
+ return result;
334
+ } catch (e) {
335
+ if (!_beforeSendThrewWarned) {
336
+ _beforeSendThrewWarned = true;
337
+ logger.warn('capture', 'beforeSend threw; falling back to unmodified event', e);
338
+ }
339
+ return event;
340
+ }
341
+ }
342
+
304
343
  /** v0.9.6 #2 — upload the wireframe replay ring as a `replay`
305
344
  * attachment. Plain NDJSON (one snapshot per line) — server may
306
345
  * gzip on storage; the network upload is base64.
@@ -452,7 +491,9 @@ export const captureMessage = safeFn(
452
491
  breadcrumbs: crumbs,
453
492
  };
454
493
 
455
- enqueue(event);
494
+ const finalEvent = applyBeforeSend(event, config.beforeSend);
495
+ if (finalEvent === null) return;
496
+ enqueue(finalEvent);
456
497
  },
457
498
  );
458
499
 
@@ -462,6 +503,11 @@ export const captureMessage = safeFn(
462
503
  * same code path captureException runs in production. */
463
504
  export const __captureAndAttachReplayForTests = captureAndAttachReplay;
464
505
 
506
+ /** v2.3 — test hook for the beforeSend dispatcher. The dispatcher
507
+ * is internal so the unit test reaches it directly without
508
+ * needing to stub the whole transport pipeline. */
509
+ export const __applyBeforeSendForTests = applyBeforeSend;
510
+
465
511
  /** Phase 42 sub-D.08: per-session screenshot quota gate. */
466
512
  function allowScreenshot(): boolean {
467
513
  const budget = screenshotBudget();
@@ -67,6 +67,10 @@ type SentryInitOpts = {
67
67
  [other: string]: unknown
68
68
  }
69
69
 
70
+ /** Exported for tests only — direct callers should use `Sentry.init`,
71
+ * which threads the parsed DSN through the rest of the init machinery. */
72
+ export const __parseDsnForTests = parseDsn
73
+
70
74
  function parseDsn(dsn: string): { token: string; ingestUrl: string } {
71
75
  // Sentry DSN shape: `https://<key>@<host>[:port][/<projectId>]`
72
76
  // Sentori cares about `<key>` (must be `st_pk_…`) and `<host>`.
@@ -343,6 +347,10 @@ type SentryBreadcrumb = {
343
347
 
344
348
  type SentoriBreadcrumbType = 'custom' | 'log' | 'nav' | 'net' | 'user'
345
349
 
350
+ /** Exported for tests only. */
351
+ export const __mapCategoryToTypeForTests = mapCategoryToType
352
+ export const __mapLevelForTests = mapLevel
353
+
346
354
  function mapCategoryToType(category: string | undefined): SentoriBreadcrumbType | undefined {
347
355
  if (!category) return undefined
348
356
  if (['auth', 'click', 'gesture', 'input', 'touch', 'ui'].includes(category)) return 'user'
package/src/config.ts CHANGED
@@ -1,21 +1,12 @@
1
- import type { LogLevel } from '@goliapkg/sentori-core';
1
+ import type { BeforeSendHook, LogLevel, ReadyInfo } from '@goliapkg/sentori-core';
2
2
 
3
3
  /**
4
- * Optional structured signal handed to `onReady` after init
5
- * completes. Host wires the callback if they want to know the SDK
6
- * is live (alternative to scanning console).
4
+ * v2.3 `ReadyInfo` is shared across SDKs via `@goliapkg/sentori-core`
5
+ * so a host that switches from web to RN reads the same shape. The RN
6
+ * SDK always populates `native` + `coldStartMs`; the core type marks
7
+ * both optional for the web SDK's benefit (web has no native module).
7
8
  */
8
- export type ReadyInfo = {
9
- /** npm version string of @goliapkg/sentori-react-native */
10
- sdkVersion: string;
11
- /** Milliseconds between RN cold-start signal and SDK init
12
- * completion. May be undefined if native module wasn't bound. */
13
- coldStartMs?: number;
14
- /** Native module status. `bound: false` means screenshot /
15
- * wireframe / native crash capture won't fire — useful for
16
- * host to know if e.g. they forgot to autolink. */
17
- native: { bound: boolean; methods: string[] };
18
- };
9
+ export type { ReadyInfo };
19
10
 
20
11
  export type Config = {
21
12
  token: string;
@@ -56,6 +47,12 @@ export type Config = {
56
47
  * the native-module bind status + cold-start timing. Host
57
48
  * wraps any host-side logging here. */
58
49
  onReady?: (info: ReadyInfo) => void;
50
+ /** v2.3 — host-side mutate-or-drop hook called once per event
51
+ * just before transport enqueue. Return the event to send it,
52
+ * `null` to drop. Sync only. If the hook throws or returns a
53
+ * non-event, SDK falls back to the un-mutated event and emits
54
+ * one one-shot warn. */
55
+ beforeSend?: BeforeSendHook;
59
56
  };
60
57
 
61
58
  let _config: Config | null = null;