@goliapkg/sentori-react-native 0.7.6 → 0.8.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 (54) 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/handlers/network.d.ts +9 -1
  13. package/lib/handlers/network.d.ts.map +1 -1
  14. package/lib/handlers/network.js +189 -18
  15. package/lib/handlers/network.js.map +1 -1
  16. package/lib/index.d.ts +14 -0
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +15 -0
  19. package/lib/index.js.map +1 -1
  20. package/lib/init.d.ts +16 -1
  21. package/lib/init.d.ts.map +1 -1
  22. package/lib/init.js +23 -2
  23. package/lib/init.js.map +1 -1
  24. package/lib/launch-crash-guard.d.ts +37 -0
  25. package/lib/launch-crash-guard.d.ts.map +1 -0
  26. package/lib/launch-crash-guard.js +163 -0
  27. package/lib/launch-crash-guard.js.map +1 -0
  28. package/lib/measure.d.ts +4 -0
  29. package/lib/measure.d.ts.map +1 -0
  30. package/lib/measure.js +25 -0
  31. package/lib/measure.js.map +1 -0
  32. package/lib/rage-tap-detector.d.ts +8 -0
  33. package/lib/rage-tap-detector.d.ts.map +1 -0
  34. package/lib/rage-tap-detector.js +21 -0
  35. package/lib/rage-tap-detector.js.map +1 -0
  36. package/lib/rage-tap.d.ts +6 -0
  37. package/lib/rage-tap.d.ts.map +1 -0
  38. package/lib/rage-tap.js +35 -0
  39. package/lib/rage-tap.js.map +1 -0
  40. package/package.json +11 -3
  41. package/src/__tests__/feature-flags.test.ts +55 -0
  42. package/src/__tests__/measure.test.ts +45 -0
  43. package/src/__tests__/network-graphql.test.ts +75 -0
  44. package/src/__tests__/rage-tap.test.ts +38 -0
  45. package/src/bundle-info.ts +95 -0
  46. package/src/capture.ts +6 -0
  47. package/src/feature-flags.ts +47 -0
  48. package/src/handlers/network.ts +198 -18
  49. package/src/index.ts +25 -0
  50. package/src/init.ts +54 -2
  51. package/src/launch-crash-guard.ts +221 -0
  52. package/src/measure.ts +28 -0
  53. package/src/rage-tap-detector.ts +26 -0
  54. package/src/rage-tap.tsx +48 -0
@@ -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
+ }
package/src/capture.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { sealTrail, shouldSample } from '@goliapkg/sentori-core';
2
2
 
3
3
  import { addBreadcrumb, getBreadcrumbs } from './breadcrumbs';
4
+ import { getBundleInfo } from './bundle-info';
4
5
  import { getConfig, isInitialized } from './config';
6
+ import { getFeatureFlagSnapshot } from './feature-flags';
5
7
  import { symbolicateErrorViaMetro } from './handlers/dev-symbolicate';
6
8
  import { captureScreenshot } from './handlers/screenshot';
7
9
  import { markSessionErrored } from './session-tracker';
@@ -97,6 +99,8 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
97
99
  return;
98
100
  }
99
101
 
102
+ const flags = getFeatureFlagSnapshot();
103
+ const bundle = getBundleInfo();
100
104
  const event: Event = {
101
105
  id: uuidV7(),
102
106
  timestamp: new Date().toISOString(),
@@ -108,6 +112,8 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
108
112
  app: collectApp(config.release),
109
113
  user: extras?.user ?? _user,
110
114
  tags: extras?.tags,
115
+ ...(flags ? { flags } : {}),
116
+ ...(bundle ? { bundle } : {}),
111
117
  breadcrumbs: getBreadcrumbs(),
112
118
  error: errorToObject(error),
113
119
  fingerprint: extras?.fingerprint,
@@ -0,0 +1,47 @@
1
+ // v0.9.0 #13 — feature-flag dimensionality.
2
+ //
3
+ // `sentori.setFeatureFlag(name, value)` is the dual of `setTag()` for
4
+ // experiment / flag state: distinct dashboard dimension, dense small
5
+ // strings, designed to be filtered/faceted on. Bugsnag's analog has
6
+ // proven surprisingly load-bearing. Implementation is a tiny in-memory
7
+ // map; every capture rides along the current snapshot.
8
+ //
9
+ // Constraints (silent — never throw):
10
+ // • name and value are strings, length 1..200
11
+ // • cap at 50 distinct flags to bound payload
12
+ // • already-set name can update (no cap check)
13
+
14
+ const MAX_FLAGS = 50;
15
+ const MAX_LEN = 200;
16
+
17
+ let _flags = new Map<string, string>();
18
+
19
+ export const setFeatureFlag = (name: string, value: string): void => {
20
+ if (typeof name !== 'string' || name.length === 0 || name.length > MAX_LEN) return;
21
+ if (typeof value !== 'string' || value.length > MAX_LEN) return;
22
+ if (_flags.size >= MAX_FLAGS && !_flags.has(name)) return;
23
+ _flags.set(name, value);
24
+ };
25
+
26
+ export const clearFeatureFlag = (name: string): void => {
27
+ _flags.delete(name);
28
+ };
29
+
30
+ export const clearAllFeatureFlags = (): void => {
31
+ _flags.clear();
32
+ };
33
+
34
+ export const getFeatureFlags = (): Record<string, string> => {
35
+ return Object.fromEntries(_flags);
36
+ };
37
+
38
+ /** Internal — capture.ts pulls a snapshot per event. Empty object
39
+ * collapses out of the payload via `Object.keys.length === 0` check. */
40
+ export const getFeatureFlagSnapshot = (): null | Record<string, string> => {
41
+ if (_flags.size === 0) return null;
42
+ return Object.fromEntries(_flags);
43
+ };
44
+
45
+ export const __resetFeatureFlagsForTests = (): void => {
46
+ _flags.clear();
47
+ };
@@ -4,9 +4,15 @@ import { addBreadcrumb } from '../breadcrumbs';
4
4
  import { getConfig } from '../config';
5
5
 
6
6
  let _installed = false;
7
+ let _graphqlEnabled = true;
7
8
 
8
9
  const AUTH_PARAMS = ['token', 'key', 'password', 'secret', 'access_token'];
9
10
 
11
+ // v0.9.0 #11 — cap on body size we'll parse for `operationName`.
12
+ // 8 KB is generous for any sensible GraphQL request and keeps the
13
+ // hot-path JSON.parse bounded.
14
+ const GQL_BODY_MAX_BYTES = 8 * 1024;
15
+
10
16
  // Requests to our own ingest endpoint shouldn't be traced — otherwise
11
17
  // every span upload spawns another http.client span, and so on.
12
18
  const isIngestUrl = (url: string): boolean => {
@@ -14,13 +20,20 @@ const isIngestUrl = (url: string): boolean => {
14
20
  return !!base && url.startsWith(base);
15
21
  };
16
22
 
17
- export const installNetworkHandler = (): void => {
23
+ export const installNetworkHandler = (opts?: { graphql?: boolean }): void => {
18
24
  if (_installed) return;
19
25
  _installed = true;
26
+ _graphqlEnabled = opts?.graphql !== false;
20
27
  patchFetch();
21
28
  patchXhr();
22
29
  };
23
30
 
31
+ /** Test-only — reset module state between runs. */
32
+ export const __resetNetworkHandlerForTests = (): void => {
33
+ _installed = false;
34
+ _graphqlEnabled = true;
35
+ };
36
+
24
37
  // ── fetch ──────────────────────────────────────────────────────────
25
38
 
26
39
  function patchFetch(): void {
@@ -37,13 +50,32 @@ function patchFetch(): void {
37
50
  ? (input as Request).method
38
51
  : 'GET')) as string;
39
52
 
53
+ // v0.9.0 #11 — GraphQL operation auto-naming. Inspect the request
54
+ // body cheaply (string only, < 8 KB) when the request looks like
55
+ // GraphQL (URL contains /graphql or content-type hints it). On
56
+ // success we override the span name to `graphql/<operationName>`
57
+ // and ride along `gql.operation` on the breadcrumb so the dashboard
58
+ // can group + filter by operation instead of by the useless
59
+ // `POST /graphql` line.
60
+ const gqlOp = _graphqlEnabled
61
+ ? extractGraphqlOpFromInit(init, input, url)
62
+ : undefined;
63
+
40
64
  // Phase 35 sub-C: also open an http.client span so the request
41
65
  // shows up in the trace waterfall. Breadcrumbs stay — they're
42
66
  // attached to error events at capture time and serve a different
43
67
  // surface (the "last 100 things" timeline on the issue page).
44
68
  const span = startSpan('http.client', {
45
- name: `${method.toUpperCase()} ${normalizeUrl(scrubbed)}`,
46
- tags: { 'http.method': method.toUpperCase(), 'http.url': scrubbed },
69
+ name: gqlOp
70
+ ? `graphql/${gqlOp}`
71
+ : `${method.toUpperCase()} ${normalizeUrl(scrubbed)}`,
72
+ tags: gqlOp
73
+ ? {
74
+ 'http.method': method.toUpperCase(),
75
+ 'http.url': scrubbed,
76
+ 'gql.operation': gqlOp,
77
+ }
78
+ : { 'http.method': method.toUpperCase(), 'http.url': scrubbed },
47
79
  });
48
80
 
49
81
  // Inject traceparent header on outbound requests.
@@ -58,12 +90,20 @@ function patchFetch(): void {
58
90
  span.finish({ status: resp.status >= 400 ? 'error' : 'ok' });
59
91
  addBreadcrumb({
60
92
  type: 'net',
61
- data: {
62
- method,
63
- url: scrubbed,
64
- status: resp.status,
65
- durationMs: Date.now() - start,
66
- },
93
+ data: gqlOp
94
+ ? {
95
+ method,
96
+ url: scrubbed,
97
+ status: resp.status,
98
+ durationMs: Date.now() - start,
99
+ 'gql.operation': gqlOp,
100
+ }
101
+ : {
102
+ method,
103
+ url: scrubbed,
104
+ status: resp.status,
105
+ durationMs: Date.now() - start,
106
+ },
67
107
  });
68
108
  return resp;
69
109
  } catch (e) {
@@ -78,6 +118,7 @@ function patchFetch(): void {
78
118
  status: 0,
79
119
  durationMs: Date.now() - start,
80
120
  error: String(e),
121
+ ...(gqlOp ? { 'gql.operation': gqlOp } : {}),
81
122
  },
82
123
  });
83
124
  throw e;
@@ -98,6 +139,7 @@ type TracedXhr = XMLHttpRequest & {
98
139
  __sentoriUrl?: string;
99
140
  __sentoriSpan?: ReturnType<typeof startSpan>;
100
141
  __sentoriStart?: number;
142
+ __sentoriGqlOp?: string;
101
143
  };
102
144
 
103
145
  function patchXhr(): void {
@@ -129,9 +171,20 @@ function patchXhr(): void {
129
171
  if (isIngestUrl(this.__sentoriUrl ?? '')) return originalSend.call(this, body);
130
172
  const method = this.__sentoriMethod ?? 'GET';
131
173
  const url = scrubUrl(this.__sentoriUrl ?? '');
174
+ // v0.9.0 #11 — GraphQL operation auto-naming on XHR.
175
+ const gqlOp = _graphqlEnabled
176
+ ? extractGraphqlOpFromXhr(body, this.__sentoriUrl ?? '')
177
+ : undefined;
178
+ this.__sentoriGqlOp = gqlOp;
132
179
  const span = startSpan('http.client', {
133
- name: `${method} ${normalizeUrl(url)}`,
134
- tags: { 'http.method': method, 'http.url': url },
180
+ name: gqlOp ? `graphql/${gqlOp}` : `${method} ${normalizeUrl(url)}`,
181
+ tags: gqlOp
182
+ ? {
183
+ 'http.method': method,
184
+ 'http.url': url,
185
+ 'gql.operation': gqlOp,
186
+ }
187
+ : { 'http.method': method, 'http.url': url },
135
188
  });
136
189
  this.__sentoriSpan = span;
137
190
  this.__sentoriStart = Date.now();
@@ -154,14 +207,23 @@ function patchXhr(): void {
154
207
  // status 0 means network error / aborted / CORS block — treat
155
208
  // as error. The `abort` event handler below downgrades aborts.
156
209
  s.finish({ status: status === 0 || status >= 400 ? 'error' : 'ok' });
210
+ const op = this.__sentoriGqlOp;
157
211
  addBreadcrumb({
158
212
  type: 'net',
159
- data: {
160
- method,
161
- url,
162
- status,
163
- durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
164
- },
213
+ data: op
214
+ ? {
215
+ method,
216
+ url,
217
+ status,
218
+ durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
219
+ 'gql.operation': op,
220
+ }
221
+ : {
222
+ method,
223
+ url,
224
+ status,
225
+ durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
226
+ },
165
227
  });
166
228
  };
167
229
 
@@ -171,9 +233,17 @@ function patchXhr(): void {
171
233
  if (!s) return;
172
234
  this.__sentoriSpan = undefined;
173
235
  s.finish({ status: 'cancelled' });
236
+ const op = this.__sentoriGqlOp;
174
237
  addBreadcrumb({
175
238
  type: 'net',
176
- data: { method, url, status: 0, durationMs: Date.now() - (this.__sentoriStart ?? Date.now()), error: 'aborted' },
239
+ data: {
240
+ method,
241
+ url,
242
+ status: 0,
243
+ durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
244
+ error: 'aborted',
245
+ ...(op ? { 'gql.operation': op } : {}),
246
+ },
177
247
  });
178
248
  });
179
249
 
@@ -224,3 +294,113 @@ const scrubUrl = (url: string): string => {
224
294
  return url;
225
295
  }
226
296
  };
297
+
298
+ // ── v0.9.0 #11 — GraphQL operation extraction ─────────────────────
299
+ //
300
+ // Cheap, sync, never throws. Two callers (fetch + xhr) feed in
301
+ // whatever they have on hand; both end up calling `parseGqlOpName`.
302
+
303
+ function lookGraphqlish(url: string, contentType?: string): boolean {
304
+ if (contentType) {
305
+ if (contentType.includes('graphql')) return true;
306
+ // application/json is too generic to gate on alone, but combined
307
+ // with a `/graphql` path it's a strong hint.
308
+ }
309
+ if (url.includes('/graphql')) return true;
310
+ return false;
311
+ }
312
+
313
+ /** Pull `operationName` out of a JSON body or a raw query body. Returns
314
+ * `undefined` on any failure mode. Cap at GQL_BODY_MAX_BYTES so a
315
+ * hostile / oversize body never lands in `JSON.parse`. */
316
+ export function parseGqlOpName(body: string): string | undefined {
317
+ if (typeof body !== 'string' || body.length === 0) return undefined;
318
+ if (body.length > GQL_BODY_MAX_BYTES) return undefined;
319
+ // First char `{` or `[` → JSON path. Most clients (Apollo, urql,
320
+ // Relay) send `{"query":"…","operationName":"…","variables":{…}}`
321
+ // or an array of such objects (batched).
322
+ const first = body.charCodeAt(0);
323
+ if (first === 0x7b /* { */ || first === 0x5b /* [ */) {
324
+ try {
325
+ const parsed = JSON.parse(body) as unknown;
326
+ const candidate = Array.isArray(parsed) ? parsed[0] : parsed;
327
+ if (candidate && typeof candidate === 'object') {
328
+ const name = (candidate as { operationName?: unknown }).operationName;
329
+ if (typeof name === 'string' && name.length > 0 && name.length <= 200) {
330
+ return name;
331
+ }
332
+ // No operationName — try to sniff the `query` string for
333
+ // `query Foo {…}` / `mutation Bar {…}` / `subscription Baz {…}`.
334
+ const q = (candidate as { query?: unknown }).query;
335
+ if (typeof q === 'string') return parseQueryStringOpName(q);
336
+ }
337
+ } catch {
338
+ return undefined;
339
+ }
340
+ return undefined;
341
+ }
342
+ // `application/graphql` body is the bare query string — no JSON wrapper.
343
+ return parseQueryStringOpName(body);
344
+ }
345
+
346
+ function parseQueryStringOpName(query: string): string | undefined {
347
+ // Skip leading whitespace + comments. We only need the first non-trivial
348
+ // top-level operation keyword to extract a name; nested operations are
349
+ // a non-issue because GraphQL forbids them.
350
+ const m = /^\s*(?:#[^\n]*\n\s*)*(query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(
351
+ query,
352
+ );
353
+ return m?.[2];
354
+ }
355
+
356
+ function extractGraphqlOpFromInit(
357
+ init: RequestInit | undefined,
358
+ input: RequestInfo | URL,
359
+ url: string,
360
+ ): string | undefined {
361
+ const method = (init?.method ??
362
+ (typeof input !== 'string' && 'method' in (input as Request)
363
+ ? (input as Request).method
364
+ : 'GET')) as string;
365
+ if (method.toUpperCase() !== 'POST') return undefined;
366
+ const contentType = headerValue(init, input, 'content-type');
367
+ if (!lookGraphqlish(url, contentType)) return undefined;
368
+ const body = init?.body;
369
+ if (typeof body !== 'string') return undefined;
370
+ return parseGqlOpName(body);
371
+ }
372
+
373
+ function extractGraphqlOpFromXhr(
374
+ body: Document | XMLHttpRequestBodyInit | null | undefined,
375
+ url: string,
376
+ ): string | undefined {
377
+ if (typeof body !== 'string') return undefined;
378
+ if (!lookGraphqlish(url)) return undefined;
379
+ return parseGqlOpName(body);
380
+ }
381
+
382
+ function headerValue(
383
+ init: RequestInit | undefined,
384
+ input: RequestInfo | URL,
385
+ name: string,
386
+ ): string | undefined {
387
+ const target = name.toLowerCase();
388
+ if (init?.headers) {
389
+ try {
390
+ const h = new Headers(init.headers);
391
+ const v = h.get(target);
392
+ if (v) return v;
393
+ } catch {
394
+ // ignore — bad header shape, treat as absent
395
+ }
396
+ }
397
+ if (typeof input !== 'string' && !(input instanceof URL)) {
398
+ try {
399
+ const v = (input as Request).headers.get(target);
400
+ if (v) return v;
401
+ } catch {
402
+ // ignore
403
+ }
404
+ }
405
+ return undefined;
406
+ }
package/src/index.ts CHANGED
@@ -9,8 +9,17 @@ import {
9
9
  setUser,
10
10
  } from './capture';
11
11
  import { ErrorBoundary } from './error-boundary';
12
+ import {
13
+ clearAllFeatureFlags,
14
+ clearFeatureFlag,
15
+ getFeatureFlags,
16
+ setFeatureFlag,
17
+ } from './feature-flags';
12
18
  import { clearMaskQuery, registerMaskQuery } from './mask';
19
+ import { measureFn } from './measure';
20
+ import { startMoment } from '@goliapkg/sentori-core';
13
21
  import { flushMetrics, recordMetric } from './metrics';
22
+ import { RageTapCapture } from './rage-tap';
14
23
  import {
15
24
  endSession,
16
25
  markSessionCrashed,
@@ -28,7 +37,14 @@ export const sentori = {
28
37
  sendUserFeedback,
29
38
  recordMetric,
30
39
  flushMetrics,
40
+ measureFn,
41
+ startMoment,
42
+ setFeatureFlag,
43
+ clearFeatureFlag,
44
+ clearAllFeatureFlags,
45
+ getFeatureFlags,
31
46
  ErrorBoundary,
47
+ RageTapCapture,
32
48
  registerMaskQuery,
33
49
  clearMaskQuery,
34
50
  startSession,
@@ -49,8 +65,17 @@ export {
49
65
  setUser,
50
66
  } from './capture';
51
67
  export { ErrorBoundary } from './error-boundary';
68
+ export {
69
+ clearAllFeatureFlags,
70
+ clearFeatureFlag,
71
+ getFeatureFlags,
72
+ setFeatureFlag,
73
+ } from './feature-flags';
52
74
  export { clearMaskQuery, registerMaskQuery } from './mask';
53
75
  export { flushMetrics, recordMetric } from './metrics';
76
+ export { measureFn } from './measure';
77
+ export { MomentHandle, type MomentProperties, startMoment } from '@goliapkg/sentori-core';
78
+ export { RageTapCapture } from './rage-tap';
54
79
  export {
55
80
  startAnrWatchdog,
56
81
  stopAnrWatchdog,