@tellann/frontend-sdk 0.1.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 +92 -0
- package/dist/auto-track.d.ts +12 -0
- package/dist/auto-track.js +200 -0
- package/dist/event-types.d.ts +17 -0
- package/dist/event-types.js +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +266 -0
- package/dist/workflow-tracker.d.ts +13 -0
- package/dist/workflow-tracker.js +35 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @tellann/frontend-sdk
|
|
2
|
+
|
|
3
|
+
Browser telemetry and QA-run correlation SDK for Tellann. Captures page views, clicks,
|
|
4
|
+
form submissions, route changes, errors, workflow lifecycle events, and custom business
|
|
5
|
+
events, then batches them to a Tellann collector endpoint.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @tellann/frontend-sdk
|
|
11
|
+
# or
|
|
12
|
+
pnpm add @tellann/frontend-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This is an ES module and targets modern browsers (`fetch`, `navigator.sendBeacon`, `Blob`).
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { TELLANN } from '@tellann/frontend-sdk';
|
|
21
|
+
|
|
22
|
+
TELLANN.initialize({
|
|
23
|
+
endpoint: 'https://collector.example.com',
|
|
24
|
+
applicationId: 'my-web-app',
|
|
25
|
+
environmentId: 'production',
|
|
26
|
+
apiKey: '<publishable-key>', // optional
|
|
27
|
+
autoTrackClicks: true, // default true
|
|
28
|
+
autoTrackForms: true, // default true
|
|
29
|
+
autoTrackRoutes: true, // default true
|
|
30
|
+
errorTracking: true, // default true
|
|
31
|
+
debug: false,
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`initialize` starts a session (emitting a `PAGE_VIEW`), wires up auto-tracking, and
|
|
36
|
+
begins a flush interval (`flushIntervalMs`, default 5000). Events are also flushed
|
|
37
|
+
immediately when the buffer reaches `maxBufferSize` (default 200).
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
| Option | Type | Default | Notes |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| `endpoint` | `string` | – | Collector base URL. Events POST to `${endpoint}/v1/events/batch`. |
|
|
44
|
+
| `applicationId` | `string` | – | Identifies the app sending events. |
|
|
45
|
+
| `tenantId` | `string` | `'unknown'` | Optional tenant identifier. |
|
|
46
|
+
| `environmentId` | `string` | `null` | Sent as `x-tellann-environment-id`. |
|
|
47
|
+
| `apiKey` | `string` | – | Sent as `Authorization: Bearer`. Disables `sendBeacon` fallback. |
|
|
48
|
+
| `runId` / `sessionId` / `traceId` | `string` | generated | QA-run / trace correlation IDs. |
|
|
49
|
+
| `agentVersion` / `instrumentationManifestVersion` | `string` | `null` | Correlation metadata. |
|
|
50
|
+
| `autoTrackClicks` / `autoTrackForms` / `autoTrackRoutes` | `boolean` | `true` | DOM auto-instrumentation. |
|
|
51
|
+
| `errorTracking` | `boolean` | `true` | Capture uncaught errors and rejections. |
|
|
52
|
+
| `flushIntervalMs` | `number` | `5000` | Batch flush cadence. |
|
|
53
|
+
| `maxBufferSize` | `number` | `200` | Force a flush at this many buffered events. |
|
|
54
|
+
| `debug` | `boolean` | `false` | Verbose console logging. |
|
|
55
|
+
|
|
56
|
+
## Manual tracking API
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
TELLANN.trackEvent('PAGE_VIEW', { url: location.href });
|
|
60
|
+
TELLANN.trackBusinessEvent({ type: 'checkout_completed', payload: { total: 42 } });
|
|
61
|
+
|
|
62
|
+
TELLANN.trackState('cart_open', 'BUSINESS');
|
|
63
|
+
TELLANN.trackTransition('cart_open', 'checkout', 'NAVIGATE');
|
|
64
|
+
|
|
65
|
+
const wf = TELLANN.startWorkflow('signup');
|
|
66
|
+
TELLANN.completeWorkflow(wf); // or failWorkflow(wf, reason) / cancelWorkflow(wf)
|
|
67
|
+
|
|
68
|
+
TELLANN.captureException(err, { route: '/checkout' });
|
|
69
|
+
TELLANN.captureMessage('payment provider slow', 'warning');
|
|
70
|
+
TELLANN.identifyUser('user_123', { plan: 'pro' });
|
|
71
|
+
|
|
72
|
+
await TELLANN.verifyInstallation(); // emits TELLANN_INITIALIZED and flushes
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Call `TELLANN.teardown()` on unmount / page teardown to clear the flush interval,
|
|
76
|
+
detach auto-tracking listeners, and flush remaining events.
|
|
77
|
+
|
|
78
|
+
### Payload limits
|
|
79
|
+
|
|
80
|
+
- Standard event: 32 KB (oversized events are dropped with a console error).
|
|
81
|
+
- Replay events (`eventType` containing `REPLAY`): 128 KB.
|
|
82
|
+
- Batch payload: 5 MB (oversized batches are dropped).
|
|
83
|
+
|
|
84
|
+
Metadata is sanitized before send (privacy-by-default).
|
|
85
|
+
|
|
86
|
+
## TypeScript
|
|
87
|
+
|
|
88
|
+
Types ship with the package. `TellannConfig`, `EventType`, and `TellannEvent` are exported.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
`UNLICENSED` — see the repository for terms.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EventType } from './event-types.js';
|
|
2
|
+
interface AutoTrackConfig {
|
|
3
|
+
autoTrackClicks?: boolean;
|
|
4
|
+
autoTrackForms?: boolean;
|
|
5
|
+
autoTrackRoutes?: boolean;
|
|
6
|
+
errorTracking?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function sanitizeMetadata(metadata: Record<string, any>): Record<string, any>;
|
|
9
|
+
export declare function setupAutoTrack(sdk: {
|
|
10
|
+
trackEvent: (type: EventType, metadata?: Record<string, any>) => void;
|
|
11
|
+
}, config: AutoTrackConfig): () => void;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Utility to check if element or its parents should be ignored
|
|
2
|
+
function shouldIgnore(element) {
|
|
3
|
+
let curr = element;
|
|
4
|
+
while (curr) {
|
|
5
|
+
if (curr.hasAttribute && curr.hasAttribute('data-tellann-ignore')) {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
// Ignore password inputs completely
|
|
9
|
+
if (curr.tagName === 'INPUT' && curr.type === 'password') {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
curr = curr.parentElement;
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
// CSS Selector generator
|
|
17
|
+
function getCssSelector(el) {
|
|
18
|
+
if (el.id)
|
|
19
|
+
return `#${el.id}`;
|
|
20
|
+
let path = [];
|
|
21
|
+
let curr = el;
|
|
22
|
+
while (curr && curr.nodeType === Node.ELEMENT_NODE) {
|
|
23
|
+
let selector = curr.nodeName.toLowerCase();
|
|
24
|
+
if (curr.className) {
|
|
25
|
+
selector += `.${curr.className.trim().split(/\s+/).join('.')}`;
|
|
26
|
+
}
|
|
27
|
+
path.unshift(selector);
|
|
28
|
+
curr = curr.parentElement;
|
|
29
|
+
}
|
|
30
|
+
return path.join(' > ');
|
|
31
|
+
}
|
|
32
|
+
// Utility to recursively sanitize sensitive keys in metadata
|
|
33
|
+
export function sanitizeMetadata(metadata) {
|
|
34
|
+
const sensitiveKeys = [
|
|
35
|
+
'password',
|
|
36
|
+
'credit_card',
|
|
37
|
+
'cvv',
|
|
38
|
+
'token',
|
|
39
|
+
'secret',
|
|
40
|
+
'private_key',
|
|
41
|
+
'access_token',
|
|
42
|
+
'authorization'
|
|
43
|
+
];
|
|
44
|
+
const sanitize = (val) => {
|
|
45
|
+
if (val === null || val === undefined)
|
|
46
|
+
return val;
|
|
47
|
+
if (Array.isArray(val)) {
|
|
48
|
+
return val.map(sanitize);
|
|
49
|
+
}
|
|
50
|
+
if (typeof val === 'object') {
|
|
51
|
+
const result = {};
|
|
52
|
+
for (const key of Object.keys(val)) {
|
|
53
|
+
const lowerKey = key.toLowerCase();
|
|
54
|
+
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
|
|
55
|
+
result[key] = '[REDACTED]';
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
result[key] = sanitize(val[key]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
return val;
|
|
64
|
+
};
|
|
65
|
+
return sanitize(metadata);
|
|
66
|
+
}
|
|
67
|
+
export function setupAutoTrack(sdk, config) {
|
|
68
|
+
const cleanups = [];
|
|
69
|
+
// 1. Clicks (Buttons & Links)
|
|
70
|
+
if (config.autoTrackClicks !== false) {
|
|
71
|
+
const clickHandler = (e) => {
|
|
72
|
+
const target = e.target;
|
|
73
|
+
if (!target)
|
|
74
|
+
return;
|
|
75
|
+
// Find closest button or link
|
|
76
|
+
const interactiveEl = target.closest('a, button, [role="button"]');
|
|
77
|
+
if (!interactiveEl || shouldIgnore(interactiveEl))
|
|
78
|
+
return;
|
|
79
|
+
const isLink = interactiveEl.tagName === 'A';
|
|
80
|
+
const eventType = isLink ? 'LINK_CLICK' : 'BUTTON_CLICK';
|
|
81
|
+
const text = interactiveEl.innerText?.trim().slice(0, 100) || '';
|
|
82
|
+
const elementId = interactiveEl.id || '';
|
|
83
|
+
const selector = getCssSelector(interactiveEl);
|
|
84
|
+
const metadata = {
|
|
85
|
+
elementId,
|
|
86
|
+
text,
|
|
87
|
+
selector,
|
|
88
|
+
};
|
|
89
|
+
if (isLink) {
|
|
90
|
+
metadata.href = interactiveEl.getAttribute('href') || '';
|
|
91
|
+
}
|
|
92
|
+
sdk.trackEvent(eventType, metadata);
|
|
93
|
+
};
|
|
94
|
+
document.addEventListener('click', clickHandler, true);
|
|
95
|
+
cleanups.push(() => document.removeEventListener('click', clickHandler, true));
|
|
96
|
+
}
|
|
97
|
+
// 2. Form Submissions
|
|
98
|
+
if (config.autoTrackForms !== false) {
|
|
99
|
+
const submitHandler = (e) => {
|
|
100
|
+
const form = e.target;
|
|
101
|
+
if (!form || shouldIgnore(form))
|
|
102
|
+
return;
|
|
103
|
+
const formId = form.id || '';
|
|
104
|
+
const formAction = form.getAttribute('action') || '';
|
|
105
|
+
// Capture only names of fields, never values
|
|
106
|
+
const fields = [];
|
|
107
|
+
const elements = form.elements;
|
|
108
|
+
for (let i = 0; i < elements.length; i++) {
|
|
109
|
+
const item = elements[i];
|
|
110
|
+
if (item.name) {
|
|
111
|
+
fields.push(item.name);
|
|
112
|
+
}
|
|
113
|
+
else if (item.id) {
|
|
114
|
+
fields.push(item.id);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
sdk.trackEvent('FORM_SUBMITTED', {
|
|
118
|
+
formId,
|
|
119
|
+
formAction,
|
|
120
|
+
fields,
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
document.addEventListener('submit', submitHandler, true);
|
|
124
|
+
cleanups.push(() => document.removeEventListener('submit', submitHandler, true));
|
|
125
|
+
}
|
|
126
|
+
// 3. SPA Route Changes
|
|
127
|
+
if (config.autoTrackRoutes !== false) {
|
|
128
|
+
let lastUrl = window.location.href;
|
|
129
|
+
const handleRouteChange = () => {
|
|
130
|
+
const currentUrl = window.location.href;
|
|
131
|
+
if (currentUrl === lastUrl)
|
|
132
|
+
return;
|
|
133
|
+
const from = lastUrl;
|
|
134
|
+
const to = currentUrl;
|
|
135
|
+
lastUrl = currentUrl;
|
|
136
|
+
sdk.trackEvent('ROUTE_CHANGE', { from, to });
|
|
137
|
+
// Emit settled PAGE_VIEW
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
sdk.trackEvent('PAGE_VIEW', {
|
|
140
|
+
url: window.location.href,
|
|
141
|
+
title: document.title,
|
|
142
|
+
referrer: document.referrer,
|
|
143
|
+
});
|
|
144
|
+
}, 50);
|
|
145
|
+
};
|
|
146
|
+
// Patch history pushState and replaceState
|
|
147
|
+
const origPushState = history.pushState;
|
|
148
|
+
const origReplaceState = history.replaceState;
|
|
149
|
+
history.pushState = function (...args) {
|
|
150
|
+
origPushState.apply(this, args);
|
|
151
|
+
handleRouteChange();
|
|
152
|
+
};
|
|
153
|
+
history.replaceState = function (...args) {
|
|
154
|
+
origReplaceState.apply(this, args);
|
|
155
|
+
handleRouteChange();
|
|
156
|
+
};
|
|
157
|
+
window.addEventListener('popstate', handleRouteChange);
|
|
158
|
+
window.addEventListener('hashchange', handleRouteChange);
|
|
159
|
+
cleanups.push(() => {
|
|
160
|
+
history.pushState = origPushState;
|
|
161
|
+
history.replaceState = origReplaceState;
|
|
162
|
+
window.removeEventListener('popstate', handleRouteChange);
|
|
163
|
+
window.removeEventListener('hashchange', handleRouteChange);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// 4. Unhandled Errors and Promise Rejections
|
|
167
|
+
if (config.errorTracking !== false) {
|
|
168
|
+
const errorHandler = (e) => {
|
|
169
|
+
// Ignore errors that don't look like actual exceptions or are cross-origin script issues
|
|
170
|
+
const message = e.message || 'Unknown window error';
|
|
171
|
+
const stack = e.error instanceof Error ? e.error.stack : null;
|
|
172
|
+
const name = e.error instanceof Error ? e.error.name : 'Error';
|
|
173
|
+
sdk.trackEvent('UNHANDLED_EXCEPTION', {
|
|
174
|
+
message,
|
|
175
|
+
stack,
|
|
176
|
+
name,
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
const rejectionHandler = (e) => {
|
|
180
|
+
const reason = e.reason;
|
|
181
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
182
|
+
const stack = reason instanceof Error ? reason.stack : null;
|
|
183
|
+
const name = reason instanceof Error ? reason.name : 'UnhandledRejection';
|
|
184
|
+
sdk.trackEvent('UNHANDLED_EXCEPTION', {
|
|
185
|
+
message,
|
|
186
|
+
stack,
|
|
187
|
+
name,
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
window.addEventListener('error', errorHandler);
|
|
191
|
+
window.addEventListener('unhandledrejection', rejectionHandler);
|
|
192
|
+
cleanups.push(() => {
|
|
193
|
+
window.removeEventListener('error', errorHandler);
|
|
194
|
+
window.removeEventListener('unhandledrejection', rejectionHandler);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return () => {
|
|
198
|
+
cleanups.forEach(cleanup => cleanup());
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type EventType = 'PAGE_VIEW' | 'ROUTE_CHANGE' | 'BUTTON_CLICK' | 'LINK_CLICK' | 'FORM_SUBMIT' | 'FORM_SUBMITTED' | 'API_REQUEST' | 'ERROR_EVENT' | 'ERROR_OCCURRED' | 'UNHANDLED_EXCEPTION' | 'SERVER_ERROR' | 'CLIENT_ERROR' | 'BUSINESS_EVENT' | 'STATE_ENTERED' | 'STATE_TRANSITION' | 'FLOW_INITIAL_STATE' | 'FLOW_STATE_REACHED' | 'FLOW_TRANSITION' | 'FLOW_TERMINAL_STATE' | 'WORKFLOW_STARTED' | 'WORKFLOW_COMPLETED' | 'WORKFLOW_FAILED' | 'WORKFLOW_CANCELLED' | 'TELLANN_ONBOARDING_TEST' | 'TELLANN_INITIALIZED' | 'QA_RUN_STARTED' | 'QA_RUN_COMPLETED' | 'QA_RUN_FAILED' | 'BROWSER_PAGE_LOADED' | 'BROWSER_CONSOLE_ERROR' | 'BROWSER_NETWORK_FAILED' | 'VISUAL_ASSERTION_FAILED' | 'ACCESSIBILITY_FINDING' | 'INSTRUMENTATION_VERIFIED' | 'REPOSITORY_SNAPSHOT_CREATED' | 'EXPECTED_FLOW_VERSION_SELECTED';
|
|
2
|
+
export interface TellannEvent {
|
|
3
|
+
eventId: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
tenantId: string;
|
|
6
|
+
applicationId: string;
|
|
7
|
+
environmentId?: string | null;
|
|
8
|
+
runId?: string | null;
|
|
9
|
+
traceId?: string | null;
|
|
10
|
+
agentVersion?: string | null;
|
|
11
|
+
instrumentationManifestVersion?: string | null;
|
|
12
|
+
source: string;
|
|
13
|
+
eventVersion: string;
|
|
14
|
+
eventType: EventType;
|
|
15
|
+
timestamp: string;
|
|
16
|
+
metadata: Record<string, any>;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { EventType } from './event-types';
|
|
2
|
+
export type { EventType, TellannEvent } from './event-types';
|
|
3
|
+
export interface TellannConfig {
|
|
4
|
+
endpoint: string;
|
|
5
|
+
tenantId?: string;
|
|
6
|
+
applicationId: string;
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
environmentId?: string;
|
|
9
|
+
autoTrackClicks?: boolean;
|
|
10
|
+
autoTrackForms?: boolean;
|
|
11
|
+
autoTrackRoutes?: boolean;
|
|
12
|
+
errorTracking?: boolean;
|
|
13
|
+
debug?: boolean;
|
|
14
|
+
flushIntervalMs?: number;
|
|
15
|
+
maxBufferSize?: number;
|
|
16
|
+
runId?: string;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
traceId?: string;
|
|
19
|
+
agentVersion?: string;
|
|
20
|
+
instrumentationManifestVersion?: string;
|
|
21
|
+
}
|
|
22
|
+
declare class TellannFrontendSDK {
|
|
23
|
+
private config;
|
|
24
|
+
private sessionId;
|
|
25
|
+
private eventBuffer;
|
|
26
|
+
private flushInterval;
|
|
27
|
+
private workflowTracker;
|
|
28
|
+
private teardownAutoTrack;
|
|
29
|
+
initialize(config: TellannConfig): void;
|
|
30
|
+
startSession(): void;
|
|
31
|
+
endSession(): void;
|
|
32
|
+
teardown(): void;
|
|
33
|
+
trackEvent(eventType: EventType, metadata?: Record<string, any>): void;
|
|
34
|
+
verifyInstallation(): Promise<void>;
|
|
35
|
+
trackBusinessEvent(config: {
|
|
36
|
+
type: string;
|
|
37
|
+
payload?: Record<string, any>;
|
|
38
|
+
}): void;
|
|
39
|
+
trackState(stateName: string, category?: string): void;
|
|
40
|
+
trackTransition(fromState: string, toState: string, action?: string): void;
|
|
41
|
+
startWorkflow(workflowName: string): string;
|
|
42
|
+
completeWorkflow(workflowId: string): void;
|
|
43
|
+
failWorkflow(workflowId: string, reason?: string): void;
|
|
44
|
+
abandonWorkflow(workflowId: string): void;
|
|
45
|
+
cancelWorkflow(workflowId: string, reason?: string): void;
|
|
46
|
+
captureException(error: Error | unknown, context?: Record<string, any>): void;
|
|
47
|
+
captureMessage(message: string, severity?: 'info' | 'warning' | 'error'): void;
|
|
48
|
+
identifyUser(userId: string, traits?: Record<string, any>): void;
|
|
49
|
+
private startFlushInterval;
|
|
50
|
+
private flush;
|
|
51
|
+
}
|
|
52
|
+
export declare const TELLANN: TellannFrontendSDK;
|
|
53
|
+
export { TellannFrontendSDK };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { WorkflowTracker } from './workflow-tracker.js';
|
|
3
|
+
import { setupAutoTrack, sanitizeMetadata } from './auto-track.js';
|
|
4
|
+
const MAX_EVENT_SIZE_BYTES = 32 * 1024; // 32 KB limit for standard events
|
|
5
|
+
const MAX_REPLAY_SIZE_BYTES = 128 * 1024; // 128 KB limit for replay events (e.g. if eventType is a replay event)
|
|
6
|
+
class TellannFrontendSDK {
|
|
7
|
+
config = null;
|
|
8
|
+
sessionId = null;
|
|
9
|
+
eventBuffer = [];
|
|
10
|
+
flushInterval = null;
|
|
11
|
+
workflowTracker = new WorkflowTracker();
|
|
12
|
+
teardownAutoTrack = null;
|
|
13
|
+
initialize(config) {
|
|
14
|
+
this.config = {
|
|
15
|
+
autoTrackClicks: true,
|
|
16
|
+
autoTrackForms: true,
|
|
17
|
+
autoTrackRoutes: true,
|
|
18
|
+
errorTracking: true,
|
|
19
|
+
debug: false,
|
|
20
|
+
flushIntervalMs: 5000,
|
|
21
|
+
maxBufferSize: 200,
|
|
22
|
+
...config
|
|
23
|
+
};
|
|
24
|
+
this.startSession();
|
|
25
|
+
this.startFlushInterval();
|
|
26
|
+
// Set up auto-tracking
|
|
27
|
+
this.teardownAutoTrack = setupAutoTrack(this, {
|
|
28
|
+
autoTrackClicks: this.config.autoTrackClicks,
|
|
29
|
+
autoTrackForms: this.config.autoTrackForms,
|
|
30
|
+
autoTrackRoutes: this.config.autoTrackRoutes,
|
|
31
|
+
errorTracking: this.config.errorTracking
|
|
32
|
+
});
|
|
33
|
+
if (this.config.debug) {
|
|
34
|
+
console.log('[Tellann] Initialized and auto-tracking started', this.config);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
startSession() {
|
|
38
|
+
this.sessionId = this.config?.sessionId ?? uuidv4();
|
|
39
|
+
this.trackEvent('PAGE_VIEW', {
|
|
40
|
+
url: window.location.href,
|
|
41
|
+
title: document.title,
|
|
42
|
+
referrer: document.referrer,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
endSession() {
|
|
46
|
+
this.sessionId = null;
|
|
47
|
+
this.flush();
|
|
48
|
+
}
|
|
49
|
+
teardown() {
|
|
50
|
+
if (this.flushInterval) {
|
|
51
|
+
clearInterval(this.flushInterval);
|
|
52
|
+
this.flushInterval = null;
|
|
53
|
+
}
|
|
54
|
+
if (this.teardownAutoTrack) {
|
|
55
|
+
this.teardownAutoTrack();
|
|
56
|
+
this.teardownAutoTrack = null;
|
|
57
|
+
}
|
|
58
|
+
this.endSession();
|
|
59
|
+
}
|
|
60
|
+
trackEvent(eventType, metadata = {}) {
|
|
61
|
+
if (!this.config || !this.sessionId) {
|
|
62
|
+
if (this.config?.debug) {
|
|
63
|
+
console.warn('[Tellann] SDK not initialized or session not started');
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Apply privacy-by-default metadata sanitization
|
|
68
|
+
const sanitizedMetadata = sanitizeMetadata(metadata);
|
|
69
|
+
const event = {
|
|
70
|
+
eventId: uuidv4(),
|
|
71
|
+
sessionId: this.sessionId,
|
|
72
|
+
tenantId: this.config.tenantId ?? 'unknown',
|
|
73
|
+
applicationId: this.config.applicationId,
|
|
74
|
+
environmentId: this.config.environmentId ?? null,
|
|
75
|
+
runId: this.config.runId ?? null,
|
|
76
|
+
traceId: this.config.traceId ?? null,
|
|
77
|
+
agentVersion: this.config.agentVersion ?? null,
|
|
78
|
+
instrumentationManifestVersion: this.config.instrumentationManifestVersion ?? null,
|
|
79
|
+
source: 'frontend-sdk',
|
|
80
|
+
eventVersion: '1.0',
|
|
81
|
+
eventType,
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
metadata: sanitizedMetadata,
|
|
84
|
+
};
|
|
85
|
+
// Payload Size Enforcement
|
|
86
|
+
try {
|
|
87
|
+
const eventJson = JSON.stringify(event);
|
|
88
|
+
const eventSize = typeof Blob !== 'undefined'
|
|
89
|
+
? new Blob([eventJson]).size
|
|
90
|
+
: eventJson.length;
|
|
91
|
+
const limit = eventType.includes('REPLAY') ? MAX_REPLAY_SIZE_BYTES : MAX_EVENT_SIZE_BYTES;
|
|
92
|
+
if (eventSize > limit) {
|
|
93
|
+
console.error(`[Tellann] Event of type "${eventType}" discarded. Size (${eventSize} bytes) exceeds limit of ${limit} bytes.`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error('[Tellann] Failed to compute size of event, discarding', err);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.eventBuffer.push(event);
|
|
102
|
+
// If max buffer size reached, flush immediately
|
|
103
|
+
const maxBuffer = this.config.maxBufferSize ?? 200;
|
|
104
|
+
if (this.eventBuffer.length >= maxBuffer) {
|
|
105
|
+
this.flush();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async verifyInstallation() {
|
|
109
|
+
this.trackEvent('TELLANN_INITIALIZED', {
|
|
110
|
+
source: 'manual_verification',
|
|
111
|
+
verificationKind: 'BOOTSTRAP_INITIALIZED',
|
|
112
|
+
instrumentationManifestVersion: this.config?.instrumentationManifestVersion ?? null,
|
|
113
|
+
agentVersion: this.config?.agentVersion ?? null,
|
|
114
|
+
});
|
|
115
|
+
await this.flush();
|
|
116
|
+
}
|
|
117
|
+
trackBusinessEvent(config) {
|
|
118
|
+
this.trackEvent('BUSINESS_EVENT', {
|
|
119
|
+
businessEventType: config.type,
|
|
120
|
+
...(config.payload || {})
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// Missing Frontend SDK methods
|
|
124
|
+
trackState(stateName, category) {
|
|
125
|
+
this.trackEvent('STATE_ENTERED', {
|
|
126
|
+
stateName,
|
|
127
|
+
category: category || 'BUSINESS',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
trackTransition(fromState, toState, action) {
|
|
131
|
+
this.trackEvent('STATE_TRANSITION', {
|
|
132
|
+
fromState,
|
|
133
|
+
toState,
|
|
134
|
+
action: action || 'NAVIGATE',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
startWorkflow(workflowName) {
|
|
138
|
+
const id = this.workflowTracker.start(workflowName);
|
|
139
|
+
this.trackEvent('WORKFLOW_STARTED', {
|
|
140
|
+
workflowId: id,
|
|
141
|
+
workflowName,
|
|
142
|
+
});
|
|
143
|
+
return id;
|
|
144
|
+
}
|
|
145
|
+
completeWorkflow(workflowId) {
|
|
146
|
+
const result = this.workflowTracker.complete(workflowId);
|
|
147
|
+
if (result) {
|
|
148
|
+
this.trackEvent('WORKFLOW_COMPLETED', {
|
|
149
|
+
workflowId,
|
|
150
|
+
workflowName: result.name,
|
|
151
|
+
durationMs: result.durationMs,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
failWorkflow(workflowId, reason) {
|
|
156
|
+
const result = this.workflowTracker.fail(workflowId);
|
|
157
|
+
if (result) {
|
|
158
|
+
this.trackEvent('WORKFLOW_FAILED', {
|
|
159
|
+
workflowId,
|
|
160
|
+
workflowName: result.name,
|
|
161
|
+
durationMs: result.durationMs,
|
|
162
|
+
reason: reason || 'Unknown error',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
abandonWorkflow(workflowId) {
|
|
167
|
+
this.workflowTracker.abandon(workflowId);
|
|
168
|
+
}
|
|
169
|
+
cancelWorkflow(workflowId, reason) {
|
|
170
|
+
const result = this.workflowTracker.fail(workflowId);
|
|
171
|
+
if (result) {
|
|
172
|
+
this.trackEvent('WORKFLOW_CANCELLED', {
|
|
173
|
+
workflowId,
|
|
174
|
+
workflowName: result.name,
|
|
175
|
+
durationMs: result.durationMs,
|
|
176
|
+
reason: reason ?? 'Cancelled',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
captureException(error, context) {
|
|
181
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
182
|
+
this.trackEvent('ERROR_OCCURRED', {
|
|
183
|
+
message: err.message,
|
|
184
|
+
stack: err.stack || null,
|
|
185
|
+
name: err.name,
|
|
186
|
+
context: context || {},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
captureMessage(message, severity = 'error') {
|
|
190
|
+
this.trackEvent('CLIENT_ERROR', {
|
|
191
|
+
message,
|
|
192
|
+
severity,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
identifyUser(userId, traits) {
|
|
196
|
+
this.trackEvent('BUSINESS_EVENT', {
|
|
197
|
+
businessEventType: 'USER_IDENTIFIED',
|
|
198
|
+
userId,
|
|
199
|
+
traits: traits || {},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
startFlushInterval() {
|
|
203
|
+
const intervalMs = this.config?.flushIntervalMs || 5000;
|
|
204
|
+
this.flushInterval = window.setInterval(() => {
|
|
205
|
+
this.flush();
|
|
206
|
+
}, intervalMs);
|
|
207
|
+
}
|
|
208
|
+
async flush() {
|
|
209
|
+
if (this.eventBuffer.length === 0 || !this.config)
|
|
210
|
+
return;
|
|
211
|
+
const eventsToSend = [...this.eventBuffer];
|
|
212
|
+
this.eventBuffer = [];
|
|
213
|
+
try {
|
|
214
|
+
const payload = JSON.stringify(eventsToSend);
|
|
215
|
+
// Enforce 5 MB batch limit
|
|
216
|
+
const payloadSize = typeof Blob !== 'undefined'
|
|
217
|
+
? new Blob([payload]).size
|
|
218
|
+
: payload.length;
|
|
219
|
+
if (payloadSize > 5 * 1024 * 1024) {
|
|
220
|
+
console.error(`[Tellann] Batch payload size of ${payloadSize} bytes exceeds the 5 MB limit. Dropping batch.`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const headers = {
|
|
224
|
+
'Content-Type': 'application/json',
|
|
225
|
+
};
|
|
226
|
+
if (this.config.apiKey) {
|
|
227
|
+
headers.Authorization = `Bearer ${this.config.apiKey}`;
|
|
228
|
+
}
|
|
229
|
+
if (this.config.environmentId) {
|
|
230
|
+
headers['x-tellann-environment-id'] = this.config.environmentId;
|
|
231
|
+
}
|
|
232
|
+
if (this.config.runId)
|
|
233
|
+
headers['x-tellann-run-id'] = this.config.runId;
|
|
234
|
+
if (this.sessionId)
|
|
235
|
+
headers['x-tellann-session-id'] = this.sessionId;
|
|
236
|
+
if (this.config.traceId)
|
|
237
|
+
headers['x-tellann-trace-id'] = this.config.traceId;
|
|
238
|
+
// sendBeacon cannot set auth headers, so only use it for unauthenticated direct collector targets.
|
|
239
|
+
if (!this.config.apiKey && !this.config.environmentId && navigator.sendBeacon && typeof Blob !== 'undefined') {
|
|
240
|
+
const blob = new Blob([payload], { type: 'application/json' });
|
|
241
|
+
const success = navigator.sendBeacon(`${this.config.endpoint}/v1/events/batch`, blob);
|
|
242
|
+
if (!success) {
|
|
243
|
+
throw new Error('sendBeacon returned false');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// Fallback to fetch
|
|
248
|
+
await fetch(`${this.config.endpoint}/v1/events/batch`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers,
|
|
251
|
+
body: payload,
|
|
252
|
+
keepalive: true, // Use keepalive for page unloads if beacon is unavailable
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (this.config.debug) {
|
|
258
|
+
console.error('[Tellann] Failed to flush events', error);
|
|
259
|
+
}
|
|
260
|
+
// Re-add to buffer on failure
|
|
261
|
+
this.eventBuffer = [...eventsToSend, ...this.eventBuffer];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export const TELLANN = new TellannFrontendSDK();
|
|
266
|
+
export { TellannFrontendSDK };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class WorkflowTracker {
|
|
2
|
+
private workflows;
|
|
3
|
+
start(name: string): string;
|
|
4
|
+
complete(workflowId: string): {
|
|
5
|
+
name: string;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
} | null;
|
|
8
|
+
fail(workflowId: string): {
|
|
9
|
+
name: string;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
} | null;
|
|
12
|
+
abandon(workflowId: string): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
export class WorkflowTracker {
|
|
3
|
+
workflows = new Map();
|
|
4
|
+
start(name) {
|
|
5
|
+
const id = uuidv4();
|
|
6
|
+
this.workflows.set(id, {
|
|
7
|
+
name,
|
|
8
|
+
startedAt: Date.now()
|
|
9
|
+
});
|
|
10
|
+
return id;
|
|
11
|
+
}
|
|
12
|
+
complete(workflowId) {
|
|
13
|
+
const workflow = this.workflows.get(workflowId);
|
|
14
|
+
if (!workflow)
|
|
15
|
+
return null;
|
|
16
|
+
this.workflows.delete(workflowId);
|
|
17
|
+
return {
|
|
18
|
+
name: workflow.name,
|
|
19
|
+
durationMs: Date.now() - workflow.startedAt
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
fail(workflowId) {
|
|
23
|
+
const workflow = this.workflows.get(workflowId);
|
|
24
|
+
if (!workflow)
|
|
25
|
+
return null;
|
|
26
|
+
this.workflows.delete(workflowId);
|
|
27
|
+
return {
|
|
28
|
+
name: workflow.name,
|
|
29
|
+
durationMs: Date.now() - workflow.startedAt
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
abandon(workflowId) {
|
|
33
|
+
this.workflows.delete(workflowId);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tellann/frontend-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Tellann browser telemetry and QA-run correlation SDK",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"module": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Pellumi/monitor.git",
|
|
13
|
+
"directory": "packages/frontend-sdk"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Pellumi/monitor/tree/main/packages/frontend-sdk#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/Pellumi/monitor/issues"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"!dist/**/*.test.js",
|
|
33
|
+
"!dist/**/*.test.d.ts"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"uuid": "^9.0.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/uuid": "^9.0.8",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc",
|
|
47
|
+
"dev": "tsc -w",
|
|
48
|
+
"test": "node --test dist/index.test.js"
|
|
49
|
+
}
|
|
50
|
+
}
|