@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.
- package/lib/bundle-info.d.ts +12 -0
- package/lib/bundle-info.d.ts.map +1 -0
- package/lib/bundle-info.js +73 -0
- package/lib/bundle-info.js.map +1 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +6 -0
- package/lib/capture.js.map +1 -1
- package/lib/feature-flags.d.ts +9 -0
- package/lib/feature-flags.d.ts.map +1 -0
- package/lib/feature-flags.js +44 -0
- package/lib/feature-flags.js.map +1 -0
- package/lib/feedback-widget.d.ts +35 -0
- package/lib/feedback-widget.d.ts.map +1 -0
- package/lib/feedback-widget.js +186 -0
- package/lib/feedback-widget.js.map +1 -0
- package/lib/handlers/network.d.ts +9 -1
- package/lib/handlers/network.d.ts.map +1 -1
- package/lib/handlers/network.js +189 -18
- package/lib/handlers/network.js.map +1 -1
- package/lib/index.d.ts +17 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +16 -1
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +23 -2
- package/lib/init.js.map +1 -1
- package/lib/launch-crash-guard.d.ts +37 -0
- package/lib/launch-crash-guard.d.ts.map +1 -0
- package/lib/launch-crash-guard.js +163 -0
- package/lib/launch-crash-guard.js.map +1 -0
- package/lib/measure.d.ts +4 -0
- package/lib/measure.d.ts.map +1 -0
- package/lib/measure.js +25 -0
- package/lib/measure.js.map +1 -0
- package/lib/rage-tap-detector.d.ts +8 -0
- package/lib/rage-tap-detector.d.ts.map +1 -0
- package/lib/rage-tap-detector.js +21 -0
- package/lib/rage-tap-detector.js.map +1 -0
- package/lib/rage-tap.d.ts +6 -0
- package/lib/rage-tap.d.ts.map +1 -0
- package/lib/rage-tap.js +35 -0
- package/lib/rage-tap.js.map +1 -0
- package/package.json +15 -3
- package/src/__tests__/feature-flags.test.ts +55 -0
- package/src/__tests__/measure.test.ts +45 -0
- package/src/__tests__/network-graphql.test.ts +75 -0
- package/src/__tests__/rage-tap.test.ts +38 -0
- package/src/bundle-info.ts +95 -0
- package/src/capture.ts +6 -0
- package/src/feature-flags.ts +47 -0
- package/src/feedback-widget.tsx +309 -0
- package/src/handlers/network.ts +198 -18
- package/src/index.ts +28 -0
- package/src/init.ts +54 -2
- package/src/launch-crash-guard.ts +221 -0
- package/src/measure.ts +28 -0
- package/src/rage-tap-detector.ts +26 -0
- package/src/rage-tap.tsx +48 -0
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
|
+
};
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// v0.9.1 #9 — Feedback Widget.
|
|
2
|
+
//
|
|
3
|
+
// `<FeedbackButton trigger="shake|manual|fab" />` — drop into the
|
|
4
|
+
// app root, opens a modal prompt that submits via the existing
|
|
5
|
+
// `sentori.sendUserFeedback` API. Shake detection is opt-in and
|
|
6
|
+
// requires `expo-sensors` (Accelerometer) — falls back to manual
|
|
7
|
+
// trigger if not installed. The button can also be controlled
|
|
8
|
+
// programmatically via a ref: `feedbackRef.current.open()`.
|
|
9
|
+
//
|
|
10
|
+
// Aesthetic: very plain Modal + Text + TextInput so it adopts the
|
|
11
|
+
// host app's color scheme without forcing a sentori theme on it.
|
|
12
|
+
|
|
13
|
+
import React, {
|
|
14
|
+
forwardRef,
|
|
15
|
+
useCallback,
|
|
16
|
+
useEffect,
|
|
17
|
+
useImperativeHandle,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
} from 'react';
|
|
21
|
+
import {
|
|
22
|
+
Modal,
|
|
23
|
+
Pressable,
|
|
24
|
+
StyleSheet,
|
|
25
|
+
Text,
|
|
26
|
+
TextInput,
|
|
27
|
+
View,
|
|
28
|
+
} from 'react-native';
|
|
29
|
+
|
|
30
|
+
import { sendUserFeedback } from './capture';
|
|
31
|
+
|
|
32
|
+
type Trigger = 'fab' | 'manual' | 'shake';
|
|
33
|
+
|
|
34
|
+
export type FeedbackButtonHandle = {
|
|
35
|
+
/** Open the feedback modal. Returns immediately. */
|
|
36
|
+
open: (defaults?: { body?: string; eventId?: string; title?: string }) => void;
|
|
37
|
+
/** Close the modal if open. */
|
|
38
|
+
close: () => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type FeedbackButtonProps = {
|
|
42
|
+
/** When to surface the prompt. Default `'fab'`. */
|
|
43
|
+
trigger?: Trigger;
|
|
44
|
+
/** Pass the eventId from the last `captureException` to tie the
|
|
45
|
+
* report to that crash. Optional. */
|
|
46
|
+
eventId?: string;
|
|
47
|
+
/** Localized strings. Defaults are English. */
|
|
48
|
+
labels?: {
|
|
49
|
+
title?: string;
|
|
50
|
+
bodyPlaceholder?: string;
|
|
51
|
+
emailPlaceholder?: string;
|
|
52
|
+
submit?: string;
|
|
53
|
+
cancel?: string;
|
|
54
|
+
sent?: string;
|
|
55
|
+
};
|
|
56
|
+
/** Shake sensitivity in m/s² above gravity. Default 18 (≈ a normal
|
|
57
|
+
* intentional shake; lower triggers more easily). Only used when
|
|
58
|
+
* `trigger="shake"`. */
|
|
59
|
+
shakeThreshold?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const FeedbackButton = forwardRef<FeedbackButtonHandle, FeedbackButtonProps>(
|
|
63
|
+
function FeedbackButton(props, ref) {
|
|
64
|
+
const trigger: Trigger = props.trigger ?? 'fab';
|
|
65
|
+
const [open, setOpen] = useState(false);
|
|
66
|
+
const [title, setTitle] = useState('');
|
|
67
|
+
const [body, setBody] = useState('');
|
|
68
|
+
const [email, setEmail] = useState('');
|
|
69
|
+
const [submitting, setSubmitting] = useState(false);
|
|
70
|
+
const [sent, setSent] = useState(false);
|
|
71
|
+
const eventIdRef = useRef<string | undefined>(props.eventId);
|
|
72
|
+
|
|
73
|
+
useImperativeHandle(
|
|
74
|
+
ref,
|
|
75
|
+
() => ({
|
|
76
|
+
open: (defaults) => {
|
|
77
|
+
setTitle(defaults?.title ?? '');
|
|
78
|
+
setBody(defaults?.body ?? '');
|
|
79
|
+
eventIdRef.current = defaults?.eventId ?? props.eventId;
|
|
80
|
+
setSent(false);
|
|
81
|
+
setOpen(true);
|
|
82
|
+
},
|
|
83
|
+
close: () => setOpen(false),
|
|
84
|
+
}),
|
|
85
|
+
[props.eventId],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Shake detection — opt-in. We load expo-sensors lazily so apps
|
|
89
|
+
// that don't install it never pay the bundle cost.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (trigger !== 'shake') return;
|
|
92
|
+
let sub: { remove: () => void } | null = null;
|
|
93
|
+
try {
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
95
|
+
const mod = require('expo-sensors') as {
|
|
96
|
+
Accelerometer?: {
|
|
97
|
+
addListener: (cb: (d: { x: number; y: number; z: number }) => void) => {
|
|
98
|
+
remove: () => void;
|
|
99
|
+
};
|
|
100
|
+
setUpdateInterval: (ms: number) => void;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const A = mod.Accelerometer;
|
|
104
|
+
if (!A) return;
|
|
105
|
+
A.setUpdateInterval(100);
|
|
106
|
+
const thr = props.shakeThreshold ?? 18;
|
|
107
|
+
let lastTriggerAt = 0;
|
|
108
|
+
sub = A.addListener(({ x, y, z }) => {
|
|
109
|
+
const mag = Math.sqrt(x * x + y * y + z * z) * 9.81; // g → m/s²
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
if (mag > thr && now - lastTriggerAt > 1500) {
|
|
112
|
+
lastTriggerAt = now;
|
|
113
|
+
setSent(false);
|
|
114
|
+
setOpen(true);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
// expo-sensors not installed → silently fall back to manual
|
|
119
|
+
}
|
|
120
|
+
return () => {
|
|
121
|
+
if (sub) sub.remove();
|
|
122
|
+
};
|
|
123
|
+
}, [trigger, props.shakeThreshold]);
|
|
124
|
+
|
|
125
|
+
const onSubmit = useCallback(async () => {
|
|
126
|
+
if (!title.trim() || !body.trim()) return;
|
|
127
|
+
setSubmitting(true);
|
|
128
|
+
try {
|
|
129
|
+
await sendUserFeedback({
|
|
130
|
+
title: title.trim().slice(0, 200),
|
|
131
|
+
body: body.trim().slice(0, 8000),
|
|
132
|
+
email: email.trim() || undefined,
|
|
133
|
+
eventId: eventIdRef.current,
|
|
134
|
+
});
|
|
135
|
+
setSent(true);
|
|
136
|
+
setTimeout(() => setOpen(false), 1200);
|
|
137
|
+
} finally {
|
|
138
|
+
setSubmitting(false);
|
|
139
|
+
}
|
|
140
|
+
}, [title, body, email]);
|
|
141
|
+
|
|
142
|
+
const L = {
|
|
143
|
+
bodyPlaceholder: 'What happened?',
|
|
144
|
+
cancel: 'Cancel',
|
|
145
|
+
emailPlaceholder: 'email (optional)',
|
|
146
|
+
sent: 'Thanks — report sent.',
|
|
147
|
+
submit: 'Send',
|
|
148
|
+
title: 'Report a problem',
|
|
149
|
+
...props.labels,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
{trigger === 'fab' && (
|
|
155
|
+
<Pressable
|
|
156
|
+
accessibilityLabel="Open feedback"
|
|
157
|
+
onPress={() => {
|
|
158
|
+
setSent(false);
|
|
159
|
+
setOpen(true);
|
|
160
|
+
}}
|
|
161
|
+
style={styles.fab}
|
|
162
|
+
>
|
|
163
|
+
<Text style={styles.fabText}>?</Text>
|
|
164
|
+
</Pressable>
|
|
165
|
+
)}
|
|
166
|
+
<Modal animationType="fade" transparent visible={open} onRequestClose={() => setOpen(false)}>
|
|
167
|
+
<View style={styles.backdrop}>
|
|
168
|
+
<View style={styles.card}>
|
|
169
|
+
{sent ? (
|
|
170
|
+
<Text style={styles.sentMessage}>{L.sent}</Text>
|
|
171
|
+
) : (
|
|
172
|
+
<>
|
|
173
|
+
<Text style={styles.heading}>{L.title}</Text>
|
|
174
|
+
<TextInput
|
|
175
|
+
autoCapitalize="sentences"
|
|
176
|
+
onChangeText={setTitle}
|
|
177
|
+
placeholder="Subject"
|
|
178
|
+
placeholderTextColor="#888"
|
|
179
|
+
style={styles.titleInput}
|
|
180
|
+
value={title}
|
|
181
|
+
/>
|
|
182
|
+
<TextInput
|
|
183
|
+
multiline
|
|
184
|
+
onChangeText={setBody}
|
|
185
|
+
placeholder={L.bodyPlaceholder}
|
|
186
|
+
placeholderTextColor="#888"
|
|
187
|
+
style={styles.bodyInput}
|
|
188
|
+
textAlignVertical="top"
|
|
189
|
+
value={body}
|
|
190
|
+
/>
|
|
191
|
+
<TextInput
|
|
192
|
+
autoCapitalize="none"
|
|
193
|
+
keyboardType="email-address"
|
|
194
|
+
onChangeText={setEmail}
|
|
195
|
+
placeholder={L.emailPlaceholder}
|
|
196
|
+
placeholderTextColor="#888"
|
|
197
|
+
style={styles.emailInput}
|
|
198
|
+
value={email}
|
|
199
|
+
/>
|
|
200
|
+
<View style={styles.actions}>
|
|
201
|
+
<Pressable onPress={() => setOpen(false)} style={styles.cancelBtn}>
|
|
202
|
+
<Text style={styles.cancelText}>{L.cancel}</Text>
|
|
203
|
+
</Pressable>
|
|
204
|
+
<Pressable
|
|
205
|
+
disabled={submitting || !title.trim() || !body.trim()}
|
|
206
|
+
onPress={onSubmit}
|
|
207
|
+
style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
|
|
208
|
+
>
|
|
209
|
+
<Text style={styles.submitText}>
|
|
210
|
+
{submitting ? '…' : L.submit}
|
|
211
|
+
</Text>
|
|
212
|
+
</Pressable>
|
|
213
|
+
</View>
|
|
214
|
+
</>
|
|
215
|
+
)}
|
|
216
|
+
</View>
|
|
217
|
+
</View>
|
|
218
|
+
</Modal>
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const styles = StyleSheet.create({
|
|
225
|
+
actions: {
|
|
226
|
+
flexDirection: 'row',
|
|
227
|
+
gap: 8,
|
|
228
|
+
justifyContent: 'flex-end',
|
|
229
|
+
},
|
|
230
|
+
backdrop: {
|
|
231
|
+
alignItems: 'center',
|
|
232
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
233
|
+
flex: 1,
|
|
234
|
+
justifyContent: 'center',
|
|
235
|
+
paddingHorizontal: 24,
|
|
236
|
+
},
|
|
237
|
+
bodyInput: {
|
|
238
|
+
backgroundColor: '#f7f7f8',
|
|
239
|
+
borderColor: '#e5e5e8',
|
|
240
|
+
borderRadius: 6,
|
|
241
|
+
borderWidth: 1,
|
|
242
|
+
color: '#111',
|
|
243
|
+
fontSize: 14,
|
|
244
|
+
marginBottom: 8,
|
|
245
|
+
minHeight: 100,
|
|
246
|
+
paddingHorizontal: 12,
|
|
247
|
+
paddingVertical: 10,
|
|
248
|
+
},
|
|
249
|
+
cancelBtn: {
|
|
250
|
+
paddingHorizontal: 14,
|
|
251
|
+
paddingVertical: 8,
|
|
252
|
+
},
|
|
253
|
+
cancelText: { color: '#666', fontSize: 14 },
|
|
254
|
+
card: {
|
|
255
|
+
backgroundColor: '#fff',
|
|
256
|
+
borderRadius: 10,
|
|
257
|
+
padding: 16,
|
|
258
|
+
width: '100%',
|
|
259
|
+
},
|
|
260
|
+
emailInput: {
|
|
261
|
+
backgroundColor: '#f7f7f8',
|
|
262
|
+
borderColor: '#e5e5e8',
|
|
263
|
+
borderRadius: 6,
|
|
264
|
+
borderWidth: 1,
|
|
265
|
+
color: '#111',
|
|
266
|
+
fontSize: 14,
|
|
267
|
+
marginBottom: 14,
|
|
268
|
+
paddingHorizontal: 12,
|
|
269
|
+
paddingVertical: 8,
|
|
270
|
+
},
|
|
271
|
+
fab: {
|
|
272
|
+
alignItems: 'center',
|
|
273
|
+
backgroundColor: '#111',
|
|
274
|
+
borderRadius: 24,
|
|
275
|
+
bottom: 28,
|
|
276
|
+
elevation: 4,
|
|
277
|
+
height: 48,
|
|
278
|
+
justifyContent: 'center',
|
|
279
|
+
position: 'absolute',
|
|
280
|
+
right: 18,
|
|
281
|
+
shadowColor: '#000',
|
|
282
|
+
shadowOffset: { height: 2, width: 0 },
|
|
283
|
+
shadowOpacity: 0.18,
|
|
284
|
+
shadowRadius: 4,
|
|
285
|
+
width: 48,
|
|
286
|
+
},
|
|
287
|
+
fabText: { color: '#fff', fontSize: 22, fontWeight: '500' },
|
|
288
|
+
heading: { color: '#111', fontSize: 18, fontWeight: '500', marginBottom: 12 },
|
|
289
|
+
sentMessage: { color: '#111', fontSize: 14, paddingVertical: 18, textAlign: 'center' },
|
|
290
|
+
submitBtn: {
|
|
291
|
+
backgroundColor: '#111',
|
|
292
|
+
borderRadius: 6,
|
|
293
|
+
paddingHorizontal: 16,
|
|
294
|
+
paddingVertical: 8,
|
|
295
|
+
},
|
|
296
|
+
submitBtnDisabled: { opacity: 0.5 },
|
|
297
|
+
submitText: { color: '#fff', fontSize: 14, fontWeight: '500' },
|
|
298
|
+
titleInput: {
|
|
299
|
+
backgroundColor: '#f7f7f8',
|
|
300
|
+
borderColor: '#e5e5e8',
|
|
301
|
+
borderRadius: 6,
|
|
302
|
+
borderWidth: 1,
|
|
303
|
+
color: '#111',
|
|
304
|
+
fontSize: 14,
|
|
305
|
+
marginBottom: 8,
|
|
306
|
+
paddingHorizontal: 12,
|
|
307
|
+
paddingVertical: 8,
|
|
308
|
+
},
|
|
309
|
+
});
|
package/src/handlers/network.ts
CHANGED
|
@@ -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:
|
|
46
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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: {
|
|
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
|
+
}
|