@grafana/faro-react-native-tracing 1.3.0 → 2.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.
- package/README.md +33 -0
- package/dist/cjs/exporters/faroTraceExporter.js +2 -1
- package/dist/cjs/exporters/faroTraceExporter.js.map +1 -1
- package/dist/cjs/exporters/faroTraceExporter.utils.js +33 -0
- package/dist/cjs/exporters/faroTraceExporter.utils.js.map +1 -1
- package/dist/cjs/instrumentation.js +259 -44
- package/dist/cjs/instrumentation.js.map +1 -1
- package/dist/cjs/instrumentations/getDefaultOTELInstrumentations.js +28 -20
- package/dist/cjs/instrumentations/getDefaultOTELInstrumentations.js.map +1 -1
- package/dist/cjs/instrumentations/instrumentationUtils.js +15 -0
- package/dist/cjs/instrumentations/instrumentationUtils.js.map +1 -1
- package/dist/cjs/processors/httpRequestMonitorSpanProcessor.js +27 -13
- package/dist/cjs/processors/httpRequestMonitorSpanProcessor.js.map +1 -1
- package/dist/cjs/types.js.map +1 -1
- package/dist/esm/exporters/faroTraceExporter.js +2 -1
- package/dist/esm/exporters/faroTraceExporter.js.map +1 -1
- package/dist/esm/exporters/faroTraceExporter.utils.js +32 -0
- package/dist/esm/exporters/faroTraceExporter.utils.js.map +1 -1
- package/dist/esm/instrumentation.js +237 -43
- package/dist/esm/instrumentation.js.map +1 -1
- package/dist/esm/instrumentations/getDefaultOTELInstrumentations.js +27 -21
- package/dist/esm/instrumentations/getDefaultOTELInstrumentations.js.map +1 -1
- package/dist/esm/instrumentations/instrumentationUtils.js +14 -0
- package/dist/esm/instrumentations/instrumentationUtils.js.map +1 -1
- package/dist/esm/processors/httpRequestMonitorSpanProcessor.js +27 -13
- package/dist/esm/processors/httpRequestMonitorSpanProcessor.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/types/instrumentation.d.ts +12 -4
- package/dist/types/instrumentations/getDefaultOTELInstrumentations.d.ts +2 -0
- package/dist/types/instrumentations/instrumentationUtils.d.ts +2 -0
- package/dist/types/types.d.ts +4 -0
- package/package.json +13 -13
- package/src/exporters/faroTraceExporter.events.test.ts +90 -0
- package/src/exporters/faroTraceExporter.ts +2 -1
- package/src/exporters/faroTraceExporter.utils.ts +32 -0
- package/src/httpSemantics.test.ts +300 -0
- package/src/instrumentation.test.ts +740 -0
- package/src/instrumentation.ts +299 -67
- package/src/instrumentations/getDefaultOTELInstrumentations.test.ts +28 -1
- package/src/instrumentations/getDefaultOTELInstrumentations.ts +41 -32
- package/src/instrumentations/instrumentationUtils.ts +13 -0
- package/src/processors/httpRequestMonitorSpanProcessor.ts +27 -15
- package/src/types.ts +4 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { context, propagation, SpanKind, trace } from '@opentelemetry/api';
|
|
2
|
+
|
|
3
|
+
import { initializeFaro, TransportItemType, UserActionState } from '@grafana/faro-core';
|
|
4
|
+
import type { EventEvent, Faro, TraceEvent, UserActionInternalInterface } from '@grafana/faro-core';
|
|
5
|
+
import { mockConfig, MockTransport } from '@grafana/faro-test-utils';
|
|
6
|
+
|
|
7
|
+
import { UserActionInstrumentation } from '../../react-native/src/instrumentations/userActions';
|
|
8
|
+
import { monitorHttpRequests } from '../../react-native/src/instrumentations/userActions/httpRequestMonitor';
|
|
9
|
+
import type { HttpRequestMessage } from '../../react-native/src/instrumentations/userActions/httpRequestMonitor';
|
|
10
|
+
|
|
11
|
+
import { sendFaroEvents } from './exporters/faroTraceExporter.utils';
|
|
12
|
+
import { TracingInstrumentation } from './instrumentation';
|
|
13
|
+
|
|
14
|
+
// Use the real RN monitor without loading native modules in jsdom.
|
|
15
|
+
jest.mock('@grafana/faro-react-native', () =>
|
|
16
|
+
jest.requireActual('../../react-native/src/instrumentations/userActions/httpRequestMonitor')
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const url = 'https://api.example.com:8443/orders';
|
|
20
|
+
|
|
21
|
+
describe('stable fetch semantics', () => {
|
|
22
|
+
let faro: Faro;
|
|
23
|
+
let transport: MockTransport;
|
|
24
|
+
let instrumentation: TracingInstrumentation;
|
|
25
|
+
let originalFetch: typeof fetch;
|
|
26
|
+
let resolveResponse: (response: Response) => void;
|
|
27
|
+
let messages: HttpRequestMessage[];
|
|
28
|
+
let unsubscribe: () => void;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.useFakeTimers();
|
|
32
|
+
originalFetch = globalThis.fetch;
|
|
33
|
+
globalThis.fetch = jest.fn(
|
|
34
|
+
() =>
|
|
35
|
+
new Promise<Response>((resolve) => {
|
|
36
|
+
resolveResponse = resolve;
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
globalThis.__FARO_HTTP_MONITOR__ = undefined;
|
|
40
|
+
messages = [];
|
|
41
|
+
const subscription = monitorHttpRequests().subscribe((message) => messages.push(message));
|
|
42
|
+
unsubscribe = () => subscription.unsubscribe();
|
|
43
|
+
transport = new MockTransport();
|
|
44
|
+
transport.getIgnoreUrls = () => [/ignored/];
|
|
45
|
+
faro = initializeFaro(mockConfig({ transports: [transport], dedupe: false }))!;
|
|
46
|
+
faro.api.setSession({ id: 'http-session', attributes: { isSampled: 'true' } });
|
|
47
|
+
instrumentation = new TracingInstrumentation({
|
|
48
|
+
instrumentationOptions: {
|
|
49
|
+
propagateTraceHeaderCorsUrls: [/.*/],
|
|
50
|
+
fetchInstrumentationOptions: {
|
|
51
|
+
ignoreNetworkEvents: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
faro.instrumentations.add(new UserActionInstrumentation(), instrumentation);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
60
|
+
await instrumentation.shutdown();
|
|
61
|
+
faro.instrumentations.remove(...faro.instrumentations.instrumentations);
|
|
62
|
+
unsubscribe();
|
|
63
|
+
globalThis.fetch = originalFetch;
|
|
64
|
+
globalThis.__FARO_HTTP_MONITOR__ = undefined;
|
|
65
|
+
trace.disable();
|
|
66
|
+
context.disable();
|
|
67
|
+
propagation.disable();
|
|
68
|
+
jest.useRealTimers();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function respond(status: number) {
|
|
72
|
+
resolveResponse(
|
|
73
|
+
Object.assign(new Response(), { url, status, statusText: status === 200 ? 'OK' : 'Request failed' })
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function exported() {
|
|
78
|
+
const spans = transport.items
|
|
79
|
+
.filter((item) => item.type === TransportItemType.TRACE)
|
|
80
|
+
.flatMap((item) => (item.payload as TraceEvent).resourceSpans ?? [])
|
|
81
|
+
.flatMap((resource) => resource.scopeSpans)
|
|
82
|
+
.flatMap((scope) => scope.spans ?? []);
|
|
83
|
+
const events = transport.items
|
|
84
|
+
.filter((item) => item.type === TransportItemType.EVENT)
|
|
85
|
+
.map((item) => item.payload as EventEvent)
|
|
86
|
+
.filter((event) => event.name === 'faro.tracing.fetch');
|
|
87
|
+
expect(spans).toHaveLength(1);
|
|
88
|
+
expect(events).toHaveLength(1);
|
|
89
|
+
return { span: spans[0]!, event: events[0]! };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
['GET', 200],
|
|
94
|
+
['POST', 200],
|
|
95
|
+
['POST', 401],
|
|
96
|
+
['POST', 500],
|
|
97
|
+
] as const)('exports compatible spans and events for %s %i', async (method, status) => {
|
|
98
|
+
const pending = globalThis.fetch(url, { method });
|
|
99
|
+
await jest.advanceTimersByTimeAsync(250);
|
|
100
|
+
respond(status);
|
|
101
|
+
await pending;
|
|
102
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
103
|
+
const { span, event } = exported();
|
|
104
|
+
expect(span.name).toBe(method);
|
|
105
|
+
expect(span.status?.code).toBe(status >= 400 ? 2 : 0);
|
|
106
|
+
const attributes = Object.fromEntries(span.attributes.map(({ key, value }) => [key, value]));
|
|
107
|
+
expect(attributes).toMatchObject({
|
|
108
|
+
'http.request.method': { stringValue: method },
|
|
109
|
+
'url.full': { stringValue: url },
|
|
110
|
+
'server.address': { stringValue: 'api.example.com' },
|
|
111
|
+
'server.port': { intValue: 8443 },
|
|
112
|
+
'http.response.status_code': { intValue: status },
|
|
113
|
+
});
|
|
114
|
+
expect(attributes['error.type']).toEqual(status >= 400 ? { stringValue: String(status) } : undefined);
|
|
115
|
+
expect(span.status?.message).toBeUndefined();
|
|
116
|
+
expect(attributes['http.method']).toBeUndefined();
|
|
117
|
+
expect(attributes['http.url']).toBeUndefined();
|
|
118
|
+
expect(attributes['http.status_code']).toBeUndefined();
|
|
119
|
+
expect(event.attributes).toMatchObject({
|
|
120
|
+
'http.method': method,
|
|
121
|
+
'http.url': url,
|
|
122
|
+
'http.status_code': String(status),
|
|
123
|
+
'http.host': 'api.example.com:8443',
|
|
124
|
+
'http.scheme': 'https',
|
|
125
|
+
'session.id': 'http-session',
|
|
126
|
+
});
|
|
127
|
+
expect(event.attributes!['url.full']).toBeUndefined();
|
|
128
|
+
expect(event.attributes!['http.request.method']).toBeUndefined();
|
|
129
|
+
expect(event.attributes!['http.response.status_code']).toBeUndefined();
|
|
130
|
+
expect(event.attributes!['server.address']).toBeUndefined();
|
|
131
|
+
expect(event.attributes!['server.port']).toBeUndefined();
|
|
132
|
+
expect(Object.values(event.attributes!).every((value) => typeof value === 'string')).toBe(true);
|
|
133
|
+
expect(Number(event.attributes!['duration_ns'])).toBeGreaterThan(0);
|
|
134
|
+
expect(event.trace).toEqual({ trace_id: span.traceId, span_id: span.spanId });
|
|
135
|
+
expect(messages).toHaveLength(2);
|
|
136
|
+
expect(messages[0]!.request).toMatchObject({ requestId: span.spanId, url, method });
|
|
137
|
+
expect(messages[1]!.request).toMatchObject({ requestId: span.spanId, url, method, status });
|
|
138
|
+
expect(messages[1]!.request.startTime).toBe(messages[0]!.request.startTime);
|
|
139
|
+
expect(messages[1]!.request.endTime! - messages[1]!.request.startTime).toBeCloseTo(250, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not classify a non-HTTP URL span as a request', async () => {
|
|
143
|
+
const span = trace.getTracer('custom').startSpan('read-file', {
|
|
144
|
+
kind: SpanKind.CLIENT,
|
|
145
|
+
attributes: { 'url.full': 'file:///example.txt' },
|
|
146
|
+
});
|
|
147
|
+
span.end();
|
|
148
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
149
|
+
expect(messages).toEqual([]);
|
|
150
|
+
const events = transport.items
|
|
151
|
+
.filter((item) => item.type === TransportItemType.EVENT)
|
|
152
|
+
.map((item) => item.payload as EventEvent);
|
|
153
|
+
expect(events).toEqual(
|
|
154
|
+
expect.arrayContaining([
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
name: 'faro.tracing.custom',
|
|
157
|
+
attributes: expect.objectContaining({ 'url.full': 'file:///example.txt' }),
|
|
158
|
+
}),
|
|
159
|
+
])
|
|
160
|
+
);
|
|
161
|
+
expect(events.some((event) => event.name === 'faro.tracing.fetch')).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function pairs() {
|
|
165
|
+
const spans = transport.items
|
|
166
|
+
.filter((item) => item.type === TransportItemType.TRACE)
|
|
167
|
+
.flatMap((item) => (item.payload as TraceEvent).resourceSpans ?? [])
|
|
168
|
+
.flatMap((resource) => resource.scopeSpans)
|
|
169
|
+
.flatMap((scope) => scope.spans ?? []);
|
|
170
|
+
const events = transport.items
|
|
171
|
+
.filter((item) => item.type === TransportItemType.EVENT)
|
|
172
|
+
.map((item) => item.payload as EventEvent)
|
|
173
|
+
.filter((event) => event.name === 'faro.tracing.fetch');
|
|
174
|
+
return spans.map((span) => ({ span, event: events.find((event) => event.trace?.span_id === span.spanId)! }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function expectAssociation(pair: ReturnType<typeof pairs>[number], action?: UserActionInternalInterface) {
|
|
178
|
+
const attributes = Object.fromEntries(pair.span.attributes.map(({ key, value }) => [key, value]));
|
|
179
|
+
expect(attributes['faro.action.user.name']).toEqual(action ? { stringValue: action.name } : undefined);
|
|
180
|
+
expect(attributes['faro.action.user.parentId']).toEqual(action ? { stringValue: action.parentId } : undefined);
|
|
181
|
+
expect(pair.event).toBeDefined();
|
|
182
|
+
expect(pair.event.action).toEqual(action ? { name: action.name, parentId: action.parentId } : undefined);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it.each(['none', 'ended', 'cancelled', 'halted'] as const)(
|
|
186
|
+
'does not associate a request with an action that is %s',
|
|
187
|
+
async (state) => {
|
|
188
|
+
if (state !== 'none') {
|
|
189
|
+
const action = faro.api.startUserAction('previous-action') as UserActionInternalInterface;
|
|
190
|
+
if (state === 'ended') action.end();
|
|
191
|
+
else if (state === 'cancelled') action.cancel();
|
|
192
|
+
else action.halt();
|
|
193
|
+
}
|
|
194
|
+
const pending = globalThis.fetch(url);
|
|
195
|
+
respond(200);
|
|
196
|
+
await pending;
|
|
197
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
198
|
+
expectAssociation(exported());
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
it('does not track excluded requests or make the action wait for them', async () => {
|
|
203
|
+
const action = faro.api.startUserAction('ignored-request') as UserActionInternalInterface;
|
|
204
|
+
const pending = globalThis.fetch('https://api.example.com/ignored');
|
|
205
|
+
await jest.advanceTimersByTimeAsync(250);
|
|
206
|
+
expect(action.getState()).toBe(UserActionState.Ended);
|
|
207
|
+
expect(messages).toEqual([]);
|
|
208
|
+
respond(200);
|
|
209
|
+
await pending;
|
|
210
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
211
|
+
expect(pairs()).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it.each(['paused', 'beforeSend'] as const)('respects transport filtering when %s', async (filter) => {
|
|
215
|
+
if (filter === 'paused') faro.transports.pause();
|
|
216
|
+
else faro.transports.addBeforeSendHooks((item) => (item.type === TransportItemType.EVENT ? null : item));
|
|
217
|
+
const pending = globalThis.fetch(url);
|
|
218
|
+
respond(200);
|
|
219
|
+
await pending;
|
|
220
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
221
|
+
expect(transport.items.some((item) => item.type === TransportItemType.EVENT)).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('respects fetch event deduplication and the configured event domain', async () => {
|
|
225
|
+
faro.config.eventDomain = 'http-tests';
|
|
226
|
+
const pending = globalThis.fetch(url);
|
|
227
|
+
respond(200);
|
|
228
|
+
await pending;
|
|
229
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
230
|
+
expect(exported().event.domain).toBe('http-tests');
|
|
231
|
+
const resourceSpans = (transport.items.find((item) => item.type === TransportItemType.TRACE)!.payload as TraceEvent)
|
|
232
|
+
.resourceSpans;
|
|
233
|
+
faro.config.dedupe = true;
|
|
234
|
+
sendFaroEvents(resourceSpans);
|
|
235
|
+
expect(transport.items.filter((item) => item.type === TransportItemType.EVENT)).toHaveLength(1);
|
|
236
|
+
faro.config.dedupe = false;
|
|
237
|
+
sendFaroEvents(resourceSpans);
|
|
238
|
+
expect(transport.items.filter((item) => item.type === TransportItemType.EVENT)).toHaveLength(2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('does not attach a new request to an action already waiting for another request', async () => {
|
|
242
|
+
const action = faro.api.startUserAction('first-request') as UserActionInternalInterface;
|
|
243
|
+
const first = globalThis.fetch(url);
|
|
244
|
+
const resolveFirst = resolveResponse;
|
|
245
|
+
expect(action.getState()).toBe(UserActionState.Halted);
|
|
246
|
+
const second = globalThis.fetch(url);
|
|
247
|
+
const resolveSecond = resolveResponse;
|
|
248
|
+
resolveFirst(Object.assign(new Response(), { url, status: 200 }));
|
|
249
|
+
await first;
|
|
250
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
251
|
+
expect(action.getState()).toBe(UserActionState.Ended);
|
|
252
|
+
resolveSecond(Object.assign(new Response(), { url, status: 200 }));
|
|
253
|
+
await second;
|
|
254
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
255
|
+
const results = pairs();
|
|
256
|
+
expect(results).toHaveLength(2);
|
|
257
|
+
expectAssociation(results.find(({ span }) => span.spanId === messages[0]!.request.requestId)!, action);
|
|
258
|
+
expectAssociation(results.find(({ span }) => span.spanId === messages[1]!.request.requestId)!);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('retains request ownership when a later action starts before the earlier response arrives', async () => {
|
|
262
|
+
const firstAction = faro.api.startUserAction('first-action') as UserActionInternalInterface;
|
|
263
|
+
const first = globalThis.fetch(url);
|
|
264
|
+
const resolveFirst = resolveResponse;
|
|
265
|
+
firstAction.end();
|
|
266
|
+
const secondAction = faro.api.startUserAction('second-action') as UserActionInternalInterface;
|
|
267
|
+
const second = globalThis.fetch(url);
|
|
268
|
+
const resolveSecond = resolveResponse;
|
|
269
|
+
resolveFirst(Object.assign(new Response(), { url, status: 200 }));
|
|
270
|
+
await first;
|
|
271
|
+
await jest.advanceTimersByTimeAsync(500);
|
|
272
|
+
expect(secondAction.getState()).toBe(UserActionState.Halted);
|
|
273
|
+
resolveSecond(Object.assign(new Response(), { url, status: 200 }));
|
|
274
|
+
await second;
|
|
275
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
276
|
+
const results = pairs();
|
|
277
|
+
expect(results).toHaveLength(2);
|
|
278
|
+
expectAssociation(results.find(({ span }) => span.spanId === messages[0]!.request.requestId)!, firstAction);
|
|
279
|
+
expectAssociation(results.find(({ span }) => span.spanId === messages[1]!.request.requestId)!, secondAction);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('keeps a slow request associated with its action until it ends', async () => {
|
|
283
|
+
const action = faro.api.startUserAction('load-orders') as UserActionInternalInterface;
|
|
284
|
+
const pending = globalThis.fetch(url, { method: 'GET' });
|
|
285
|
+
await jest.advanceTimersByTimeAsync(250);
|
|
286
|
+
expect(action.getState()).toBe(UserActionState.Halted);
|
|
287
|
+
respond(200);
|
|
288
|
+
await pending;
|
|
289
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
290
|
+
expect(action.getState()).toBe(UserActionState.Ended);
|
|
291
|
+
const { span, event } = exported();
|
|
292
|
+
expect(span.attributes).toEqual(
|
|
293
|
+
expect.arrayContaining([
|
|
294
|
+
{ key: 'faro.action.user.name', value: { stringValue: 'load-orders' } },
|
|
295
|
+
{ key: 'faro.action.user.parentId', value: { stringValue: action.parentId } },
|
|
296
|
+
])
|
|
297
|
+
);
|
|
298
|
+
expect(event.action).toEqual({ name: 'load-orders', parentId: action.parentId });
|
|
299
|
+
});
|
|
300
|
+
});
|