Skip to content

Commit c6cd82b

Browse files
authored
feat(PoC): track user actions (#1033)
1 parent a077e31 commit c6cd82b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+2566
-212
lines changed

demo/src/client/faro/initialize.ts

+8
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function initializeFaro(): Faro {
1515
const faro = coreInit({
1616
url: `http://localhost:${env.faro.portAppReceiver}/collect`,
1717
apiKey: env.faro.apiKey,
18+
1819
trackWebVitalsAttribution: true,
1920
instrumentations: [
2021
...getWebInstrumentations({
@@ -41,6 +42,13 @@ export function initializeFaro(): Faro {
4142
version: env.package.version,
4243
environment: env.mode.name,
4344
},
45+
trackResources: true,
46+
47+
batching: {
48+
itemLimit: 100,
49+
},
50+
51+
trackUserActions: true,
4452
});
4553

4654
faro.api.pushLog(['Faro was initialized']);

demo/src/client/pages/Features/Counter.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ export function CounterComponent({ description, title, value, onChange }: Counte
1515
<h4 className="mt-3">{title}</h4>
1616
<p>{description}</p>
1717
<p>
18-
Counter: {value} <Button onClick={() => onChange(value + 1)}>Increment</Button>
18+
Counter: {value}{' '}
19+
<Button onClick={() => onChange(value + 1)} data-faro-user-action-name="counter-increment">
20+
Increment
21+
</Button>
1922
</p>
2023
</>
2124
);

demo/src/client/pages/Features/ErrorInstrumentation.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export function ErrorInstrumentation() {
1616
fetch('http://localhost:64999', {
1717
method: 'POST',
1818
});
19+
20+
setTimeout(() => {
21+
faro.api.pushLog(['Fetch error log']);
22+
faro.api.pushError(new Error('TEST - This is a fetch error'));
23+
}, 80);
1924
};
2025

2126
const xhrError = () => {
@@ -47,10 +52,10 @@ export function ErrorInstrumentation() {
4752
<Button data-cy="btn-call-undefined" onClick={callUndefined}>
4853
Call Undefined Method
4954
</Button>
50-
<Button data-cy="btn-fetch-error" onClick={fetchError}>
55+
<Button data-cy="btn-fetch-error" onClick={fetchError} data-faro-user-action-name="fetch-error">
5156
Fetch Error
5257
</Button>
53-
<Button data-cy="btn-xhr-error" onClick={xhrError}>
58+
<Button data-cy="btn-xhr-error" onClick={xhrError} data-faro-user-action-name="xhr-error">
5459
XHR Error (promise)
5560
</Button>
5661
<Button data-cy="btn-promise-reject" onClick={promiseReject}>

demo/src/client/pages/Features/TracingInstrumentation.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ export function TracingInstrumentation() {
3232
<>
3333
<h3>Tracing Instrumentation</h3>
3434
<ButtonGroup>
35-
<Button data-cy="btn-fetch-success" onClick={fetchSuccess}>
35+
<Button data-cy="btn-fetch-success" onClick={fetchSuccess} data-faro-user-action-name="fetch-success">
3636
Fetch Success
3737
</Button>
38-
<Button data-cy="btn-xhr-success" onClick={xhrSuccess}>
38+
<Button data-cy="btn-xhr-success" onClick={xhrSuccess} data-faro-user-action-name="xhr-success">
3939
XHR Success
4040
</Button>
41-
<Button data-cy="btn-trace-with-log" onClick={traceWithLog}>
41+
<Button data-cy="btn-trace-with-log" onClick={traceWithLog} data-faro-user-action-name="trace-with-log">
4242
Trace with Log
4343
</Button>
4444
</ButtonGroup>

demo/src/client/pages/Login/LoginForm.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function LoginForm() {
5555
<Form.Control type="password" autoComplete="current-password" {...registerField('password')} />
5656
</Form.Group>
5757

58-
<Button variant="primary" type="submit">
58+
<Button variant="primary" type="submit" data-faro-user-action-name="login">
5959
Login
6060
</Button>
6161
</Form>

demo/src/client/pages/Register/RegisterForm.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function RegisterForm() {
6161
<Form.Control type="password" autoComplete="current-password" {...registerField('password')} />
6262
</Form.Group>
6363

64-
<Button variant="primary" type="submit">
64+
<Button variant="primary" type="submit" data-faro-user-action-name="register">
6565
Register
6666
</Button>
6767
</Form>

demo/src/client/pages/Seed/Seed.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function Seed() {
3333
instrumentation coming together.
3434
</p>
3535

36-
<Button onClick={handleSeed} disabled={seedResult.isLoading} className="mb-3">
36+
<Button onClick={handleSeed} disabled={seedResult.isLoading} className="mb-3" data-faro-user-action-name="seed">
3737
Seed
3838
</Button>
3939

docs/sources/tutorials/quick-start-browser.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,13 @@ const resource = Resource.default().merge(
284284

285285
const provider = new WebTracerProvider({ resource });
286286

287-
provider.addSpanProcessor(new FaroSessionSpanProcessor(new BatchSpanProcessor(new FaroTraceExporter({ ...faro }))));
287+
provider.addSpanProcessor(
288+
new FaroUserActionSpanProcessor(
289+
new FaroMetaAttributesSpanProcessor(
290+
new FaroSessionSpanProcessor(new BatchSpanProcessor(new FaroTraceExporter({ ...faro })))
291+
)
292+
)
293+
);
288294

289295
provider.register({
290296
propagator: new W3CTraceContextPropagator(),

packages/core/src/api/ItemBuffer.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { isFunction } from '../utils/is';
2+
3+
export class ItemBuffer<T> {
4+
private buffer: T[];
5+
6+
constructor() {
7+
this.buffer = [];
8+
}
9+
10+
addItem(item: T) {
11+
this.buffer.push(item);
12+
}
13+
14+
flushBuffer(cb?: (item: T) => void) {
15+
if (isFunction(cb)) {
16+
for (const item of this.buffer) {
17+
cb(item);
18+
}
19+
}
20+
21+
this.buffer.length = 0;
22+
}
23+
24+
size() {
25+
return this.buffer.length;
26+
}
27+
}
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Transports } from '../transports';
2+
3+
import type { TracesAPI } from './traces/types';
4+
5+
export const mockMetas = {
6+
add: jest.fn(),
7+
remove: jest.fn(),
8+
addListener: jest.fn(),
9+
removeListener: jest.fn(),
10+
value: {},
11+
};
12+
13+
export const mockTransports: Transports = {
14+
add: jest.fn(),
15+
addBeforeSendHooks: jest.fn(),
16+
execute: jest.fn(),
17+
getBeforeSendHooks: jest.fn(),
18+
remove: jest.fn(),
19+
removeBeforeSendHooks: jest.fn(),
20+
isPaused: function (): boolean {
21+
throw new Error('Function not implemented.');
22+
},
23+
transports: [],
24+
pause: function (): void {
25+
throw new Error('Function not implemented.');
26+
},
27+
unpause: function (): void {
28+
throw new Error('Function not implemented.');
29+
},
30+
};
31+
32+
export const mockTracesApi: TracesAPI = {
33+
getOTEL: jest.fn(),
34+
getTraceContext: jest.fn(),
35+
initOTEL: jest.fn(),
36+
isOTELInitialized: jest.fn(),
37+
pushTraces: jest.fn(),
38+
};

packages/core/src/api/const.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const USER_ACTION_START_MESSAGE_TYPE = 'user-action-start';
2+
export const USER_ACTION_END_MESSAGE_TYPE = 'user-action-end';
3+
export const USER_ACTION_CANCEL_MESSAGE_TYPE = 'user-action-cancel';

packages/core/src/api/events/initialize.test.ts

+67-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import type { TransportItem } from '../..';
22
import { initializeFaro } from '../../initialize';
3-
import { mockConfig, MockTransport } from '../../testUtils';
4-
import type { API } from '../types';
5-
3+
import { mockConfig, mockInternalLogger, MockTransport } from '../../testUtils';
4+
import { dateNow } from '../../utils';
5+
import { mockMetas, mockTracesApi, mockTransports } from '../apiTestHelpers';
6+
import {
7+
USER_ACTION_CANCEL_MESSAGE_TYPE,
8+
USER_ACTION_END_MESSAGE_TYPE,
9+
USER_ACTION_START_MESSAGE_TYPE,
10+
} from '../const';
11+
import { ItemBuffer } from '../ItemBuffer';
12+
import type { API, APIEvent, ApiMessageBusMessages } from '../types';
13+
14+
import { initializeEventsAPI } from './initialize';
615
import type { EventEvent, PushEventOptions } from './types';
716

817
describe('api.events', () => {
@@ -161,4 +170,59 @@ describe('api.events', () => {
161170
});
162171
});
163172
});
173+
174+
describe('User action', () => {
175+
it('buffers the error if a user action is in progress', () => {
176+
const internalLogger = mockInternalLogger;
177+
const config = mockConfig();
178+
179+
const actionBuffer = new ItemBuffer<TransportItem<APIEvent>>();
180+
181+
let message: ApiMessageBusMessages | undefined;
182+
183+
const getMessage = () => message;
184+
185+
message = {
186+
type: USER_ACTION_START_MESSAGE_TYPE,
187+
name: 'testAction',
188+
startTime: Date.now(),
189+
parentId: 'parent-id',
190+
};
191+
const api = initializeEventsAPI({
192+
unpatchedConsole: console,
193+
internalLogger,
194+
config,
195+
metas: mockMetas,
196+
transports: mockTransports,
197+
tracesApi: mockTracesApi,
198+
actionBuffer,
199+
getMessage,
200+
});
201+
202+
api.pushEvent('test');
203+
expect(actionBuffer.size()).toBe(1);
204+
205+
message = {
206+
type: USER_ACTION_END_MESSAGE_TYPE,
207+
name: 'testAction',
208+
id: 'parent-id',
209+
startTime: dateNow(),
210+
endTime: dateNow(),
211+
duration: 0,
212+
eventType: 'click',
213+
};
214+
215+
api.pushEvent('test-2');
216+
expect(actionBuffer.size()).toBe(1);
217+
218+
message = {
219+
type: USER_ACTION_CANCEL_MESSAGE_TYPE,
220+
name: 'testAction',
221+
parentId: 'parent-id',
222+
};
223+
224+
api.pushEvent('test-3');
225+
expect(actionBuffer.size()).toBe(1);
226+
});
227+
});
164228
});

packages/core/src/api/events/initialize.ts

+30-12
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,45 @@ import type { TransportItem, Transports } from '../../transports';
66
import type { UnpatchedConsole } from '../../unpatchedConsole';
77
import { deepEqual, getCurrentTimestamp, isEmpty, isNull, stringifyObjectValues } from '../../utils';
88
import { timestampToIsoString } from '../../utils/date';
9+
import { USER_ACTION_START_MESSAGE_TYPE } from '../const';
10+
import type { ItemBuffer } from '../ItemBuffer';
911
import type { TracesAPI } from '../traces';
12+
import type { ApiMessageBusMessages } from '../types';
1013

1114
import type { EventEvent, EventsAPI } from './types';
1215

13-
export function initializeEventsAPI(
14-
_unpatchedConsole: UnpatchedConsole,
15-
internalLogger: InternalLogger,
16-
config: Config,
17-
metas: Metas,
18-
transports: Transports,
19-
tracesApi: TracesAPI
20-
): EventsAPI {
16+
export function initializeEventsAPI({
17+
internalLogger,
18+
config,
19+
metas,
20+
transports,
21+
tracesApi,
22+
actionBuffer,
23+
getMessage,
24+
}: {
25+
unpatchedConsole: UnpatchedConsole;
26+
internalLogger: InternalLogger;
27+
config: Config;
28+
metas: Metas;
29+
transports: Transports;
30+
tracesApi: TracesAPI;
31+
actionBuffer: ItemBuffer<TransportItem>;
32+
getMessage: () => ApiMessageBusMessages | undefined;
33+
}): EventsAPI {
2134
let lastPayload: Pick<EventEvent, 'name' | 'domain' | 'attributes'> | null = null;
2235

2336
const pushEvent: EventsAPI['pushEvent'] = (
2437
name,
2538
attributes,
2639
domain,
27-
{ skipDedupe, spanContext, timestampOverwriteMs } = {}
40+
{ skipDedupe, spanContext, timestampOverwriteMs, customPayloadTransformer = (payload: EventEvent) => payload } = {}
2841
) => {
2942
try {
3043
const attrs = stringifyObjectValues(attributes);
3144

3245
const item: TransportItem<EventEvent> = {
3346
meta: metas.value,
34-
payload: {
47+
payload: customPayloadTransformer({
3548
name,
3649
domain: domain ?? config.eventDomain,
3750
attributes: isEmpty(attrs) ? undefined : attrs,
@@ -42,7 +55,7 @@ export function initializeEventsAPI(
4255
span_id: spanContext.spanId,
4356
}
4457
: tracesApi.getTraceContext(),
45-
},
58+
}),
4659
type: TransportItemType.EVENT,
4760
};
4861

@@ -62,7 +75,12 @@ export function initializeEventsAPI(
6275

6376
internalLogger.debug('Pushing event\n', item);
6477

65-
transports.execute(item);
78+
const msg = getMessage();
79+
if (msg && msg.type === USER_ACTION_START_MESSAGE_TYPE) {
80+
actionBuffer.addItem(item);
81+
} else {
82+
transports.execute(item);
83+
}
6684
} catch (err) {
6785
internalLogger.error('Error pushing event', err);
6886
}

packages/core/src/api/events/types.ts

+13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SpanContext } from '@opentelemetry/api';
22

33
import type { TraceContext } from '../traces';
4+
import type { UserAction } from '../types';
45

56
export type EventAttributes = Record<string, string>;
67

@@ -11,12 +12,24 @@ export interface EventEvent {
1112
domain?: string;
1213
attributes?: EventAttributes;
1314
trace?: TraceContext;
15+
16+
action?: UserAction;
1417
}
1518

1619
export interface PushEventOptions {
1720
skipDedupe?: boolean;
1821
spanContext?: Pick<SpanContext, 'traceId' | 'spanId'>;
1922
timestampOverwriteMs?: number;
23+
24+
/**
25+
* Allows manual transformation of the payload before adding it to the internal buffer.
26+
*
27+
* @param payload - The event payload to be transformed.
28+
* @returns The transformed event payload.
29+
*
30+
* @remarks This should be used sparingly and only in special cases where custom payload processing cannot be deferred to the before-send hook.
31+
*/
32+
customPayloadTransformer?: (payload: EventEvent) => EventEvent;
2033
}
2134

2235
export interface EventsAPI {

0 commit comments

Comments
 (0)