@goliapkg/sentori-react-native 0.7.6 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/lib/bundle-info.d.ts +12 -0
  2. package/lib/bundle-info.d.ts.map +1 -0
  3. package/lib/bundle-info.js +73 -0
  4. package/lib/bundle-info.js.map +1 -0
  5. package/lib/capture.d.ts.map +1 -1
  6. package/lib/capture.js +6 -0
  7. package/lib/capture.js.map +1 -1
  8. package/lib/feature-flags.d.ts +9 -0
  9. package/lib/feature-flags.d.ts.map +1 -0
  10. package/lib/feature-flags.js +44 -0
  11. package/lib/feature-flags.js.map +1 -0
  12. package/lib/feedback-widget.d.ts +35 -0
  13. package/lib/feedback-widget.d.ts.map +1 -0
  14. package/lib/feedback-widget.js +186 -0
  15. package/lib/feedback-widget.js.map +1 -0
  16. package/lib/handlers/network.d.ts +9 -1
  17. package/lib/handlers/network.d.ts.map +1 -1
  18. package/lib/handlers/network.js +189 -18
  19. package/lib/handlers/network.js.map +1 -1
  20. package/lib/index.d.ts +17 -0
  21. package/lib/index.d.ts.map +1 -1
  22. package/lib/index.js +18 -0
  23. package/lib/index.js.map +1 -1
  24. package/lib/init.d.ts +16 -1
  25. package/lib/init.d.ts.map +1 -1
  26. package/lib/init.js +23 -2
  27. package/lib/init.js.map +1 -1
  28. package/lib/launch-crash-guard.d.ts +37 -0
  29. package/lib/launch-crash-guard.d.ts.map +1 -0
  30. package/lib/launch-crash-guard.js +163 -0
  31. package/lib/launch-crash-guard.js.map +1 -0
  32. package/lib/measure.d.ts +4 -0
  33. package/lib/measure.d.ts.map +1 -0
  34. package/lib/measure.js +25 -0
  35. package/lib/measure.js.map +1 -0
  36. package/lib/rage-tap-detector.d.ts +8 -0
  37. package/lib/rage-tap-detector.d.ts.map +1 -0
  38. package/lib/rage-tap-detector.js +21 -0
  39. package/lib/rage-tap-detector.js.map +1 -0
  40. package/lib/rage-tap.d.ts +6 -0
  41. package/lib/rage-tap.d.ts.map +1 -0
  42. package/lib/rage-tap.js +35 -0
  43. package/lib/rage-tap.js.map +1 -0
  44. package/package.json +15 -3
  45. package/src/__tests__/feature-flags.test.ts +55 -0
  46. package/src/__tests__/measure.test.ts +45 -0
  47. package/src/__tests__/network-graphql.test.ts +75 -0
  48. package/src/__tests__/rage-tap.test.ts +38 -0
  49. package/src/bundle-info.ts +95 -0
  50. package/src/capture.ts +6 -0
  51. package/src/feature-flags.ts +47 -0
  52. package/src/feedback-widget.tsx +309 -0
  53. package/src/handlers/network.ts +198 -18
  54. package/src/index.ts +28 -0
  55. package/src/init.ts +54 -2
  56. package/src/launch-crash-guard.ts +221 -0
  57. package/src/measure.ts +28 -0
  58. package/src/rage-tap-detector.ts +26 -0
  59. package/src/rage-tap.tsx +48 -0
@@ -0,0 +1,163 @@
1
+ // v0.9.0 #3 — launch-crash loop guard.
2
+ //
3
+ // On every init we write a "launch_marker" to AsyncStorage. On
4
+ // `markLaunchCompleted()` we write a sibling "launch_completed". On
5
+ // startup we look at the previous launch state: marker present but
6
+ // completed missing → previous launch did not finish → increment a
7
+ // consecutive-crash counter.
8
+ //
9
+ // When the counter crosses `threshold` (default 2), we invoke the
10
+ // host-supplied `onLaunchCrashDetected` callback with a 200 ms timeout
11
+ // (D3) and follow its action: rollback the OTA bundle, reset a list
12
+ // of AsyncStorage keys, or continue. Rollback / reset trigger an
13
+ // `expo-updates` reload when available.
14
+ //
15
+ // v0.9.0 scope: JS-only — catches everything that runs after the JS
16
+ // bridge is up (almost every OTA-induced launch crash). v0.9.1 will
17
+ // add a native marker for the small set of "crashed before bridge"
18
+ // cases.
19
+ const MARKER_KEY = '@sentori/launch_marker';
20
+ const COMPLETED_KEY = '@sentori/launch_completed';
21
+ const COUNT_KEY = '@sentori/launch_crash_count';
22
+ function loadAsyncStorage() {
23
+ try {
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const mod = require('@react-native-async-storage/async-storage');
26
+ return mod.default ?? mod;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** Returns `false` iff we triggered a bundle rollback / reset and
33
+ * expect the app to reload momentarily; the caller (init) should
34
+ * short-circuit further setup. */
35
+ export async function runLaunchCrashGuard(opts, release, currentBundleId) {
36
+ if (!opts.enabled)
37
+ return { shouldContinueInit: true };
38
+ const storage = loadAsyncStorage();
39
+ if (!storage)
40
+ return { shouldContinueInit: true };
41
+ try {
42
+ const marker = await storage.getItem(MARKER_KEY);
43
+ const completed = await storage.getItem(COMPLETED_KEY);
44
+ if (marker && !completed) {
45
+ const m = safeJsonParse(marker) ?? {};
46
+ const prevCount = parseInt((await storage.getItem(COUNT_KEY)) ?? '0', 10) || 0;
47
+ const consecutiveCount = prevCount + 1;
48
+ await storage.setItem(COUNT_KEY, String(consecutiveCount));
49
+ if (consecutiveCount >= (opts.threshold ?? 2) && opts.onLaunchCrashDetected) {
50
+ const info = {
51
+ consecutiveCount,
52
+ crashedBundle: m.bundleId ?? null,
53
+ lastSafeBundle: m.lastSafeBundle ?? null,
54
+ release,
55
+ };
56
+ const action = await raceWithTimeout(Promise.resolve(opts.onLaunchCrashDetected(info)), opts.timeoutMs ?? 200, { action: 'continue' });
57
+ const handled = await applyAction(action, storage);
58
+ if (!handled.shouldContinueInit) {
59
+ return { ...handled, info };
60
+ }
61
+ return { ...handled, info };
62
+ }
63
+ }
64
+ else {
65
+ // Previous launch completed; clean the counter.
66
+ await storage.setItem(COUNT_KEY, '0');
67
+ }
68
+ // Write the marker for THIS launch. lastSafeBundle = previous
69
+ // completed bundle id, so the user's callback can target it.
70
+ const lastSafeBundle = (completed && safeJsonParse(completed)?.bundleId) ?? null;
71
+ await storage.setItem(MARKER_KEY, JSON.stringify({
72
+ bundleId: currentBundleId,
73
+ lastSafeBundle,
74
+ release,
75
+ ts: Date.now(),
76
+ }));
77
+ await storage.removeItem(COMPLETED_KEY);
78
+ }
79
+ catch {
80
+ // AsyncStorage glitches must never block init.
81
+ }
82
+ return { shouldContinueInit: true };
83
+ }
84
+ export async function markLaunchCompleted(currentBundleId) {
85
+ const storage = loadAsyncStorage();
86
+ if (!storage)
87
+ return;
88
+ try {
89
+ await storage.setItem(COMPLETED_KEY, JSON.stringify({ bundleId: currentBundleId, ts: Date.now() }));
90
+ await storage.setItem(COUNT_KEY, '0');
91
+ }
92
+ catch {
93
+ // ignore
94
+ }
95
+ }
96
+ async function applyAction(action, storage) {
97
+ if (action.action === 'continue')
98
+ return { shouldContinueInit: true };
99
+ if (action.action === 'reset') {
100
+ if (storage.multiRemove && Array.isArray(action.clearKeys)) {
101
+ try {
102
+ await storage.multiRemove(action.clearKeys);
103
+ }
104
+ catch {
105
+ // ignore
106
+ }
107
+ }
108
+ await reloadOTAIfPossible();
109
+ return { shouldContinueInit: false };
110
+ }
111
+ if (action.action === 'rollback') {
112
+ await reloadOTAIfPossible();
113
+ return { shouldContinueInit: false };
114
+ }
115
+ return { shouldContinueInit: true };
116
+ }
117
+ async function reloadOTAIfPossible() {
118
+ try {
119
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
120
+ const Updates = require('expo-updates');
121
+ if (typeof Updates.reloadAsync === 'function') {
122
+ await Updates.reloadAsync();
123
+ }
124
+ }
125
+ catch {
126
+ // expo-updates not installed — caller will fall through and
127
+ // continue init; their callback returned `rollback` but we can't
128
+ // perform it without the OTA library. Document accordingly.
129
+ }
130
+ }
131
+ export function raceWithTimeout(p, ms, fallback) {
132
+ return new Promise((resolve) => {
133
+ let done = false;
134
+ const t = setTimeout(() => {
135
+ if (!done) {
136
+ done = true;
137
+ resolve(fallback);
138
+ }
139
+ }, ms);
140
+ p.then((v) => {
141
+ if (!done) {
142
+ done = true;
143
+ clearTimeout(t);
144
+ resolve(v);
145
+ }
146
+ }, () => {
147
+ if (!done) {
148
+ done = true;
149
+ clearTimeout(t);
150
+ resolve(fallback);
151
+ }
152
+ });
153
+ });
154
+ }
155
+ function safeJsonParse(s) {
156
+ try {
157
+ return JSON.parse(s);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ //# sourceMappingURL=launch-crash-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launch-crash-guard.js","sourceRoot":"","sources":["../src/launch-crash-guard.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export declare function measureFn<T>(name: string, fn: () => Promise<T> | T, opts?: {
2
+ tags?: Record<string, string>;
3
+ }): Promise<T>;
4
+ //# sourceMappingURL=measure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measure.d.ts","sourceRoot":"","sources":["../src/measure.ts"],"names":[],"mappings":"AASA,wBAAsB,SAAS,CAAC,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EACxB,IAAI,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACvC,OAAO,CAAC,CAAC,CAAC,CAcZ"}
package/lib/measure.js ADDED
@@ -0,0 +1,25 @@
1
+ // v0.9.0 #14 — `sentori.measureFn(name, fn)`. Profile-lite. Wrap an
2
+ // async (or sync) function call in a span so it shows on the issue
3
+ // detail trace waterfall without writing the boilerplate every time.
4
+ // The full Hermes-sampler profiler (#4) is the deep version of this
5
+ // idea; `measureFn` is the cheap version that doesn't need a native
6
+ // module.
7
+ import { startSpan } from '@goliapkg/sentori-core';
8
+ export async function measureFn(name, fn, opts) {
9
+ const span = startSpan('sentori.measureFn', {
10
+ name,
11
+ tags: opts?.tags ?? {},
12
+ });
13
+ try {
14
+ const result = await fn();
15
+ span.finish({ status: 'ok' });
16
+ return result;
17
+ }
18
+ catch (e) {
19
+ if (e instanceof Error)
20
+ span.setTag('error.message', e.message);
21
+ span.finish({ status: 'error' });
22
+ throw e;
23
+ }
24
+ }
25
+ //# sourceMappingURL=measure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measure.js","sourceRoot":"","sources":["../src/measure.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,mEAAmE;AACnE,qEAAqE;AACrE,oEAAoE;AACpE,oEAAoE;AACpE,UAAU;AAEV,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAEnD,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,IAAY,EACZ,EAAwB,EACxB,IAAwC;IAExC,MAAM,IAAI,GAAG,SAAS,CAAC,mBAAmB,EAAE;QAC1C,IAAI;QACJ,IAAI,EAAE,IAAI,EAAE,IAAI,IAAI,EAAE;KACvB,CAAC,CAAC;IACH,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,EAAE,EAAE,CAAC;QAC1B,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9B,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,IAAI,CAAC,YAAY,KAAK;YAAE,IAAI,CAAC,MAAM,CAAC,eAAe,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;QAChE,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;QACjC,MAAM,CAAC,CAAC;IACV,CAAC;AACH,CAAC"}
@@ -0,0 +1,8 @@
1
+ export declare const RAGE_WINDOW_MS = 800;
2
+ export declare const RAGE_THRESHOLD = 3;
3
+ /** Given the per-target recent-tap buckets, a target id, and `now`,
4
+ * return `true` iff this tap crosses the rage threshold. Side
5
+ * effect: writes/clears the bucket inside `map` so successive
6
+ * taps after a triggered rage event don't immediately re-trigger. */
7
+ export declare function recordTap(map: Map<number, number[]>, target: number, now: number): boolean;
8
+ //# sourceMappingURL=rage-tap-detector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rage-tap-detector.d.ts","sourceRoot":"","sources":["../src/rage-tap-detector.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,cAAc,MAAM,CAAC;AAClC,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;sEAGsE;AACtE,wBAAgB,SAAS,CACvB,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,EAC1B,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,GACV,OAAO,CAUT"}
@@ -0,0 +1,21 @@
1
+ // v0.9.0 #12 — pure rage-tap detection logic. Lives outside the .tsx
2
+ // component so unit tests can import it without dragging in
3
+ // `react-native` (whose flow syntax breaks bun:test parser).
4
+ export const RAGE_WINDOW_MS = 800;
5
+ export const RAGE_THRESHOLD = 3;
6
+ /** Given the per-target recent-tap buckets, a target id, and `now`,
7
+ * return `true` iff this tap crosses the rage threshold. Side
8
+ * effect: writes/clears the bucket inside `map` so successive
9
+ * taps after a triggered rage event don't immediately re-trigger. */
10
+ export function recordTap(map, target, now) {
11
+ const previous = map.get(target) ?? [];
12
+ const fresh = previous.filter((t) => now - t <= RAGE_WINDOW_MS);
13
+ fresh.push(now);
14
+ if (fresh.length >= RAGE_THRESHOLD) {
15
+ map.delete(target);
16
+ return true;
17
+ }
18
+ map.set(target, fresh);
19
+ return false;
20
+ }
21
+ //# sourceMappingURL=rage-tap-detector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rage-tap-detector.js","sourceRoot":"","sources":["../src/rage-tap-detector.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,4DAA4D;AAC5D,6DAA6D;AAE7D,MAAM,CAAC,MAAM,cAAc,GAAG,GAAG,CAAC;AAClC,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC;AAEhC;;;sEAGsE;AACtE,MAAM,UAAU,SAAS,CACvB,GAA0B,EAC1B,MAAc,EACd,GAAW;IAEX,MAAM,QAAQ,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;IACvC,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,IAAI,cAAc,CAAC,CAAC;IAChE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAChB,IAAI,KAAK,CAAC,MAAM,IAAI,cAAc,EAAE,CAAC;QACnC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACnB,OAAO,IAAI,CAAC;IACd,CAAC;IACD,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IACvB,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import { type ViewProps } from 'react-native';
3
+ export declare function RageTapCapture({ children, ...rest }: ViewProps & {
4
+ children?: React.ReactNode;
5
+ }): React.JSX.Element;
6
+ //# sourceMappingURL=rage-tap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rage-tap.d.ts","sourceRoot":"","sources":["../src/rage-tap.tsx"],"names":[],"mappings":"AAUA,OAAO,KAA8B,MAAM,OAAO,CAAC;AACnD,OAAO,EAAoC,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAShF,wBAAgB,cAAc,CAAC,EAC7B,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,SAAS,GAAG;IAAE,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAAE,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAwBhE"}
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // v0.9.0 #12 — rage-tap / multi-click detection.
3
+ //
4
+ // Wrap your app root (typically next to ErrorBoundary) with
5
+ // `<sentori.RageTapCapture>{children}</sentori.RageTapCapture>`.
6
+ // We listen to bubble-phase `onTouchEnd` and emit a `ui.multiClick`
7
+ // breadcrumb when the same native target receives ≥ 3 taps within
8
+ // 800 ms. Pure observation — no event capture, no gesture
9
+ // interference; existing Touchables / Pressables / GestureHandler
10
+ // continue to fire normally.
11
+ import { useCallback, useRef } from 'react';
12
+ import { View } from 'react-native';
13
+ import { addBreadcrumb } from './breadcrumbs';
14
+ import { RAGE_THRESHOLD, RAGE_WINDOW_MS, recordTap, } from './rage-tap-detector';
15
+ export function RageTapCapture({ children, ...rest }) {
16
+ const recent = useRef(new Map());
17
+ const onTouchEnd = useCallback((e) => {
18
+ const target = e.nativeEvent?.target;
19
+ if (typeof target !== 'number')
20
+ return;
21
+ if (recordTap(recent.current, target, Date.now())) {
22
+ addBreadcrumb({
23
+ type: 'user',
24
+ data: {
25
+ kind: 'ui.multiClick',
26
+ target: String(target),
27
+ taps: RAGE_THRESHOLD,
28
+ windowMs: RAGE_WINDOW_MS,
29
+ },
30
+ });
31
+ }
32
+ }, []);
33
+ return (_jsx(View, { ...rest, onTouchEnd: onTouchEnd, children: children }));
34
+ }
35
+ //# sourceMappingURL=rage-tap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rage-tap.js","sourceRoot":"","sources":["../src/rage-tap.tsx"],"names":[],"mappings":";AAAA,iDAAiD;AACjD,EAAE;AACF,4DAA4D;AAC5D,iEAAiE;AACjE,oEAAoE;AACpE,kEAAkE;AAClE,0DAA0D;AAC1D,kEAAkE;AAClE,6BAA6B;AAE7B,OAAc,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AACnD,OAAO,EAAE,IAAI,EAA8C,MAAM,cAAc,CAAC;AAEhF,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EACL,cAAc,EACd,cAAc,EACd,SAAS,GACV,MAAM,qBAAqB,CAAC;AAE7B,MAAM,UAAU,cAAc,CAAC,EAC7B,QAAQ,EACR,GAAG,IAAI,EACoC;IAC3C,MAAM,MAAM,GAAG,MAAM,CAAwB,IAAI,GAAG,EAAE,CAAC,CAAC;IAExD,MAAM,UAAU,GAAG,WAAW,CAAC,CAAC,CAAwB,EAAE,EAAE;QAC1D,MAAM,MAAM,GAAG,CAAC,CAAC,WAAW,EAAE,MAAM,CAAC;QACrC,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,OAAO;QACvC,IAAI,SAAS,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC;YAClD,aAAa,CAAC;gBACZ,IAAI,EAAE,MAAM;gBACZ,IAAI,EAAE;oBACJ,IAAI,EAAE,eAAe;oBACrB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC;oBACtB,IAAI,EAAE,cAAc;oBACpB,QAAQ,EAAE,cAAc;iBACzB;aACF,CAAC,CAAC;QACL,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,CACL,KAAC,IAAI,OAAK,IAAI,EAAE,UAAU,EAAE,UAAU,YACnC,QAAQ,GACJ,CACR,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-react-native",
3
- "version": "0.7.6",
3
+ "version": "0.8.1",
4
4
  "description": "Sentori SDK for React Native \u2014 JS-layer error capture, native crash handlers (iOS / Android), batched transport, fetch + react-navigation tracing.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://sentori.golia.jp",
@@ -52,12 +52,24 @@
52
52
  },
53
53
  "expo-modules-core": {
54
54
  "optional": true
55
+ },
56
+ "expo-sensors": {
57
+ "optional": true
58
+ },
59
+ "expo-updates": {
60
+ "optional": true
61
+ },
62
+ "react-native-code-push": {
63
+ "optional": true
55
64
  }
56
65
  },
57
66
  "optionalDependencies": {
58
67
  "@react-native-async-storage/async-storage": ">=1.23",
59
68
  "@react-native-community/netinfo": ">=11.0",
60
- "expo-modules-core": ">=2.0"
69
+ "expo-modules-core": ">=2.0",
70
+ "expo-sensors": ">=14.0",
71
+ "expo-updates": ">=0.27",
72
+ "react-native-code-push": ">=8.0"
61
73
  },
62
74
  "devDependencies": {
63
75
  "@types/bun": "latest",
@@ -68,6 +80,6 @@
68
80
  "access": "public"
69
81
  },
70
82
  "dependencies": {
71
- "@goliapkg/sentori-core": "0.7.1"
83
+ "@goliapkg/sentori-core": "0.8.0"
72
84
  }
73
85
  }
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ __resetFeatureFlagsForTests,
5
+ clearAllFeatureFlags,
6
+ clearFeatureFlag,
7
+ getFeatureFlagSnapshot,
8
+ getFeatureFlags,
9
+ setFeatureFlag,
10
+ } from '../feature-flags';
11
+
12
+ afterEach(() => {
13
+ __resetFeatureFlagsForTests();
14
+ });
15
+
16
+ describe('feature flags', () => {
17
+ test('set / get round-trip', () => {
18
+ setFeatureFlag('checkout-v2', 'variant-a');
19
+ setFeatureFlag('shipping', 'fast');
20
+ expect(getFeatureFlags()).toEqual({ 'checkout-v2': 'variant-a', shipping: 'fast' });
21
+ });
22
+
23
+ test('clear removes only the named flag', () => {
24
+ setFeatureFlag('a', '1');
25
+ setFeatureFlag('b', '2');
26
+ clearFeatureFlag('a');
27
+ expect(getFeatureFlags()).toEqual({ b: '2' });
28
+ });
29
+
30
+ test('clearAll empties the map', () => {
31
+ setFeatureFlag('a', '1');
32
+ clearAllFeatureFlags();
33
+ expect(getFeatureFlags()).toEqual({});
34
+ });
35
+
36
+ test('snapshot returns null when empty (so capture can elide the field)', () => {
37
+ expect(getFeatureFlagSnapshot()).toBeNull();
38
+ setFeatureFlag('x', 'y');
39
+ expect(getFeatureFlagSnapshot()).toEqual({ x: 'y' });
40
+ });
41
+
42
+ test('rejects oversize names / values silently', () => {
43
+ setFeatureFlag('x'.repeat(201), 'v');
44
+ setFeatureFlag('name', 'v'.repeat(201));
45
+ expect(getFeatureFlags()).toEqual({});
46
+ });
47
+
48
+ test('respects the 50-flag cap but updates existing flags freely', () => {
49
+ for (let i = 0; i < 50; i++) setFeatureFlag(`flag-${i}`, 'a');
50
+ setFeatureFlag('flag-overflow', 'a'); // rejected silently
51
+ expect(Object.keys(getFeatureFlags()).length).toBe(50);
52
+ setFeatureFlag('flag-0', 'b'); // update — allowed
53
+ expect(getFeatureFlags()['flag-0']).toBe('b');
54
+ });
55
+ });
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import { clearSpans, drainSpans } from '@goliapkg/sentori-core';
4
+
5
+ import { measureFn } from '../measure';
6
+
7
+ afterEach(() => {
8
+ clearSpans();
9
+ });
10
+
11
+ describe('measureFn', () => {
12
+ test('runs fn, returns result, emits an ok span', async () => {
13
+ const r = await measureFn('addToCart', async () => 42);
14
+ expect(r).toBe(42);
15
+ const spans = drainSpans();
16
+ expect(spans.length).toBe(1);
17
+ expect(spans[0]!.op).toBe('sentori.measureFn');
18
+ expect(spans[0]!.name).toBe('addToCart');
19
+ expect(spans[0]!.status).toBe('ok');
20
+ });
21
+
22
+ test('supports sync fn too (Promise.resolve hides the difference)', async () => {
23
+ const r = await measureFn('syncJob', () => 'hello');
24
+ expect(r).toBe('hello');
25
+ expect(drainSpans()[0]!.status).toBe('ok');
26
+ });
27
+
28
+ test('propagates thrown errors and marks span as error', async () => {
29
+ await expect(
30
+ measureFn('failing', async () => {
31
+ throw new Error('nope');
32
+ }),
33
+ ).rejects.toThrow('nope');
34
+ const spans = drainSpans();
35
+ expect(spans.length).toBe(1);
36
+ expect(spans[0]!.status).toBe('error');
37
+ expect(spans[0]!.tags['error.message']).toBe('nope');
38
+ });
39
+
40
+ test('passes through caller tags', async () => {
41
+ await measureFn('withTags', async () => 'ok', { tags: { region: 'jp' } });
42
+ const spans = drainSpans();
43
+ expect(spans[0]!.tags.region).toBe('jp');
44
+ });
45
+ });
@@ -0,0 +1,75 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import { parseGqlOpName } from '../handlers/network';
4
+
5
+ // v0.9.0 #11 — covers the GraphQL operation extraction logic. The
6
+ // patched fetch / XHR plumbing is exercised by manual smoke; this file
7
+ // nails the parser, which is the part with branchy logic.
8
+
9
+ afterEach(() => {
10
+ // nothing — parseGqlOpName is pure.
11
+ });
12
+
13
+ describe('parseGqlOpName', () => {
14
+ test('extracts operationName from a standard Apollo POST body', () => {
15
+ const body = JSON.stringify({
16
+ query: 'query UpdateCart($id:ID!){...}',
17
+ operationName: 'UpdateCart',
18
+ variables: { id: 'c-1' },
19
+ });
20
+ expect(parseGqlOpName(body)).toBe('UpdateCart');
21
+ });
22
+
23
+ test('extracts operationName from a batched array body (Apollo Link Batch)', () => {
24
+ const body = JSON.stringify([
25
+ { query: '...', operationName: 'FirstOp', variables: {} },
26
+ { query: '...', operationName: 'SecondOp', variables: {} },
27
+ ]);
28
+ expect(parseGqlOpName(body)).toBe('FirstOp');
29
+ });
30
+
31
+ test('falls back to sniffing the query string when operationName is absent', () => {
32
+ const body = JSON.stringify({
33
+ query: 'mutation CompleteCheckout($id:ID!){...}',
34
+ });
35
+ expect(parseGqlOpName(body)).toBe('CompleteCheckout');
36
+ });
37
+
38
+ test('sniffs application/graphql body (no JSON wrapper)', () => {
39
+ const body = 'query ListOrders {\n orders { id }\n}';
40
+ expect(parseGqlOpName(body)).toBe('ListOrders');
41
+ });
42
+
43
+ test('returns undefined for non-graphql JSON', () => {
44
+ const body = JSON.stringify({ hello: 'world' });
45
+ expect(parseGqlOpName(body)).toBeUndefined();
46
+ });
47
+
48
+ test('returns undefined for malformed JSON', () => {
49
+ expect(parseGqlOpName('{not json')).toBeUndefined();
50
+ });
51
+
52
+ test('returns undefined for an empty body', () => {
53
+ expect(parseGqlOpName('')).toBeUndefined();
54
+ });
55
+
56
+ test('rejects bodies larger than 8 KB', () => {
57
+ const big = JSON.stringify({
58
+ query: 'query Big {...}',
59
+ operationName: 'Big',
60
+ variables: { padding: 'x'.repeat(10_000) },
61
+ });
62
+ expect(parseGqlOpName(big)).toBeUndefined();
63
+ });
64
+
65
+ test('rejects an operationName that is too long (>200 chars)', () => {
66
+ const long = 'A'.repeat(201);
67
+ const body = JSON.stringify({ query: 'q', operationName: long });
68
+ expect(parseGqlOpName(body)).toBeUndefined();
69
+ });
70
+
71
+ test('handles leading comments in raw query body', () => {
72
+ const body = '# a comment\n# another comment\nsubscription LiveTicker {...}';
73
+ expect(parseGqlOpName(body)).toBe('LiveTicker');
74
+ });
75
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { recordTap } from '../rage-tap-detector';
4
+
5
+ describe('recordTap', () => {
6
+ test('two fast taps do not trip rage', () => {
7
+ const m = new Map<number, number[]>();
8
+ expect(recordTap(m, 7, 0)).toBe(false);
9
+ expect(recordTap(m, 7, 200)).toBe(false);
10
+ expect(m.get(7)?.length).toBe(2);
11
+ });
12
+
13
+ test('three fast taps on the same target trip rage', () => {
14
+ const m = new Map<number, number[]>();
15
+ recordTap(m, 7, 0);
16
+ recordTap(m, 7, 200);
17
+ expect(recordTap(m, 7, 400)).toBe(true);
18
+ // bucket cleared so the very next tap doesn't immediately re-trip
19
+ expect(m.get(7)).toBeUndefined();
20
+ });
21
+
22
+ test('taps spread over > 800 ms do not trip', () => {
23
+ const m = new Map<number, number[]>();
24
+ recordTap(m, 7, 0);
25
+ recordTap(m, 7, 500);
26
+ expect(recordTap(m, 7, 1500)).toBe(false);
27
+ // only the last (since it landed > 800ms after the previous two)
28
+ expect(m.get(7)?.length).toBe(1);
29
+ });
30
+
31
+ test('different targets do not pool', () => {
32
+ const m = new Map<number, number[]>();
33
+ recordTap(m, 1, 0);
34
+ recordTap(m, 2, 0);
35
+ recordTap(m, 3, 0);
36
+ expect(m.size).toBe(3);
37
+ });
38
+ });
@@ -0,0 +1,95 @@
1
+ // v0.9.0 #10 — EAS Update / CodePush awareness.
2
+ //
3
+ // At capture time we want to know which JS bundle the user is running:
4
+ // it's almost always an OTA update rather than the binary `release`,
5
+ // and crash spikes correlate to a specific bundle id rather than the
6
+ // app version. We try `expo-updates` first (EAS), then `react-native-
7
+ // code-push`, then nothing. All access is `require()`-shielded so the
8
+ // SDK still works when neither is installed.
9
+ //
10
+ // Cached at module load — bundle id doesn't change mid-session in
11
+ // either Expo or CodePush.
12
+
13
+ export type BundleInfo = {
14
+ /** Stable identifier — Expo `updateId` or CodePush `label`. */
15
+ id: string;
16
+ /** When the bundle was published. RFC 3339. */
17
+ deployedAt?: string;
18
+ /** Which OTA system reported it. */
19
+ source: 'codepush' | 'expo';
20
+ };
21
+
22
+ let _cached: BundleInfo | null | undefined;
23
+
24
+ export function getBundleInfo(): BundleInfo | null {
25
+ if (_cached !== undefined) return _cached;
26
+ _cached = detect();
27
+ return _cached;
28
+ }
29
+
30
+ function detect(): BundleInfo | null {
31
+ // Expo Updates first — most modern RN deployments are on EAS Update.
32
+ try {
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ const Updates = require('expo-updates') as {
35
+ commitTime?: Date | null;
36
+ isEmbeddedLaunch?: boolean;
37
+ manifest?: { id?: string; createdAt?: string };
38
+ updateId?: null | string;
39
+ };
40
+ const id = Updates.updateId ?? Updates.manifest?.id;
41
+ if (typeof id === 'string' && id.length > 0) {
42
+ const deployedAt = pickDeployedAt(Updates);
43
+ return { deployedAt, id, source: 'expo' };
44
+ }
45
+ } catch {
46
+ // expo-updates not installed
47
+ }
48
+ // CodePush fallback.
49
+ try {
50
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
51
+ const cp = require('react-native-code-push') as {
52
+ getCurrentPackage?: () => Promise<{
53
+ appVersion?: string;
54
+ label?: string;
55
+ packageHash?: string;
56
+ } | null>;
57
+ };
58
+ // `getCurrentPackage` is async; we don't block init. Schedule a
59
+ // background fetch + populate the cache. First-event-after-init
60
+ // may miss the bundle id; subsequent events will have it.
61
+ if (typeof cp.getCurrentPackage === 'function') {
62
+ void cp
63
+ .getCurrentPackage()
64
+ .then((pkg) => {
65
+ if (pkg && (pkg.label || pkg.packageHash)) {
66
+ _cached = {
67
+ id: pkg.label ?? pkg.packageHash!,
68
+ source: 'codepush',
69
+ };
70
+ }
71
+ })
72
+ .catch(() => {
73
+ // ignore
74
+ });
75
+ }
76
+ } catch {
77
+ // not installed
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function pickDeployedAt(u: {
83
+ commitTime?: Date | null;
84
+ manifest?: { createdAt?: string };
85
+ }): string | undefined {
86
+ if (u.commitTime instanceof Date) return u.commitTime.toISOString();
87
+ const ts = u.manifest?.createdAt;
88
+ if (typeof ts === 'string') return ts;
89
+ return undefined;
90
+ }
91
+
92
+ /** Test-only — reset cache. */
93
+ export function __resetBundleInfoForTests(): void {
94
+ _cached = undefined;
95
+ }