@tellann/frontend-sdk 0.1.0 → 0.2.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/dist/auto-track.js +30 -7
- package/dist/index.d.ts +45 -0
- package/dist/index.js +177 -0
- package/package.json +2 -2
package/dist/auto-track.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
function shouldIgnore(element) {
|
|
3
3
|
let curr = element;
|
|
4
4
|
while (curr) {
|
|
5
|
-
if (curr.hasAttribute && curr.hasAttribute('data-tellann-ignore')) {
|
|
5
|
+
if (curr.hasAttribute && (curr.hasAttribute('data-tellann-ignore') || curr.hasAttribute('data-tellann-sensitive'))) {
|
|
6
6
|
return true;
|
|
7
7
|
}
|
|
8
8
|
// Ignore password inputs completely
|
|
@@ -39,28 +39,51 @@ export function sanitizeMetadata(metadata) {
|
|
|
39
39
|
'secret',
|
|
40
40
|
'private_key',
|
|
41
41
|
'access_token',
|
|
42
|
-
'authorization'
|
|
42
|
+
'authorization',
|
|
43
|
+
'cookie',
|
|
44
|
+
'session_id',
|
|
45
|
+
'sessionid',
|
|
46
|
+
'card_number',
|
|
47
|
+
'file_content'
|
|
43
48
|
];
|
|
44
|
-
const
|
|
49
|
+
const identifierKeys = ['email', 'phone', 'mobile', 'user_id', 'userid', 'account_id', 'accountid'];
|
|
50
|
+
const sanitizeUrl = (value) => {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(value, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
|
53
|
+
const names = [...new Set([...parsed.searchParams.keys()])];
|
|
54
|
+
parsed.search = names.length ? `?${names.map((name) => `${encodeURIComponent(name)}=`).join('&')}` : '';
|
|
55
|
+
parsed.hash = '';
|
|
56
|
+
return parsed.toString();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return value.slice(0, 2_000);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const sanitize = (val, keyPath = '') => {
|
|
45
63
|
if (val === null || val === undefined)
|
|
46
64
|
return val;
|
|
47
65
|
if (Array.isArray(val)) {
|
|
48
|
-
return val.map(sanitize);
|
|
66
|
+
return val.map((item, index) => sanitize(item, `${keyPath}.${index}`));
|
|
49
67
|
}
|
|
50
68
|
if (typeof val === 'object') {
|
|
51
69
|
const result = {};
|
|
52
70
|
for (const key of Object.keys(val)) {
|
|
53
71
|
const lowerKey = key.toLowerCase();
|
|
54
72
|
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
|
|
55
|
-
result[key] = '[
|
|
73
|
+
result[key] = '[NOT CAPTURED]';
|
|
74
|
+
}
|
|
75
|
+
else if (identifierKeys.some(identifier => lowerKey === identifier || lowerKey.endsWith(`_${identifier}`))) {
|
|
76
|
+
result[key] = '[PSEUDONYMIZED BY QA INGESTION]';
|
|
56
77
|
}
|
|
57
78
|
else {
|
|
58
|
-
result[key] = sanitize(val[key]);
|
|
79
|
+
result[key] = sanitize(val[key], keyPath ? `${keyPath}.${key}` : key);
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
return result;
|
|
62
83
|
}
|
|
63
|
-
|
|
84
|
+
if (typeof val === 'string' && /(^|\.)(url|href|from|to|referrer)$/i.test(keyPath))
|
|
85
|
+
return sanitizeUrl(val);
|
|
86
|
+
return typeof val === 'string' ? val.slice(0, 2_000) : val;
|
|
64
87
|
};
|
|
65
88
|
return sanitize(metadata);
|
|
66
89
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,51 @@ declare class TellannFrontendSDK {
|
|
|
38
38
|
}): void;
|
|
39
39
|
trackState(stateName: string, category?: string): void;
|
|
40
40
|
trackTransition(fromState: string, toState: string, action?: string): void;
|
|
41
|
+
trackFlowInitialState(flowVersionId: string, stateKey: string): void;
|
|
42
|
+
trackFlowStateReached(flowVersionId: string, stateKey: string): void;
|
|
43
|
+
trackFlowTransition(flowVersionId: string, fromStateKey: string, toStateKey: string, action: string): void;
|
|
44
|
+
trackFlowTerminalState(flowVersionId: string, stateKey: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Explicit adapter for Zustand, MobX, custom Context, and other stores.
|
|
47
|
+
*
|
|
48
|
+
* Shape metadata (type, length, populated) always travels. Actual values are
|
|
49
|
+
* only attached while a QA run credential is present on the page and the run
|
|
50
|
+
* is not observation-only, and even then they are carried as *candidate*
|
|
51
|
+
* protected values: the browser observer and the ingestion API both classify
|
|
52
|
+
* and encrypt them before anything is persisted. Nothing raw is ever written
|
|
53
|
+
* to the generic telemetry wire.
|
|
54
|
+
*/
|
|
55
|
+
trackClientState(store: string, key: string, previous: unknown, next: unknown): void;
|
|
56
|
+
/**
|
|
57
|
+
* Redux middleware. Records the action type, which top-level slice paths
|
|
58
|
+
* actually changed, and protected before/after values for those slices.
|
|
59
|
+
*
|
|
60
|
+
* const store = configureStore({
|
|
61
|
+
* reducer,
|
|
62
|
+
* middleware: (get) => get().concat(TELLANN.createReduxMiddleware()),
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
createReduxMiddleware(): (store: {
|
|
66
|
+
getState(): unknown;
|
|
67
|
+
}) => (next: (action: unknown) => unknown) => (action: unknown) => unknown;
|
|
68
|
+
/**
|
|
69
|
+
* React Context adapter. Only providers explicitly identified and approved in
|
|
70
|
+
* the validated Flow instrumentation manifest should call this — there is no
|
|
71
|
+
* blanket interception of React internals, because that cannot be done
|
|
72
|
+
* reliably and would misreport what was actually captured.
|
|
73
|
+
*
|
|
74
|
+
* useEffect(() => TELLANN.trackContextValue('AuthContext', 'user', value), [value]);
|
|
75
|
+
*/
|
|
76
|
+
trackContextValue(providerName: string, key: string, value: unknown): void;
|
|
77
|
+
/**
|
|
78
|
+
* Wraps an approved `useState` setter so Flow-relevant state changes are
|
|
79
|
+
* recorded. Intended to be applied to the specific setters identified during
|
|
80
|
+
* static analysis, not to every setter in the application.
|
|
81
|
+
*
|
|
82
|
+
* const [email, setEmail] = useState('');
|
|
83
|
+
* const setTracked = TELLANN.trackStateSetter('CheckoutForm', 'email', setEmail, email);
|
|
84
|
+
*/
|
|
85
|
+
trackStateSetter<T>(componentName: string, key: string, setter: (value: T) => void, current?: T): (value: T) => void;
|
|
41
86
|
startWorkflow(workflowName: string): string;
|
|
42
87
|
completeWorkflow(workflowId: string): void;
|
|
43
88
|
failWorkflow(workflowId: string, reason?: string): void;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from 'uuid';
|
|
2
2
|
import { WorkflowTracker } from './workflow-tracker.js';
|
|
3
3
|
import { setupAutoTrack, sanitizeMetadata } from './auto-track.js';
|
|
4
|
+
const CLIENT_STATE_SECRET_KEY = /password|passwd|passcode|secret|token|authorization|cookie|session|auth|private.?key|cvv|cvc|card/i;
|
|
5
|
+
/** Shape-only description; always safe to send regardless of environment. */
|
|
6
|
+
function describeClientStateValue(value) {
|
|
7
|
+
return {
|
|
8
|
+
type: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value,
|
|
9
|
+
length: typeof value === 'string' || Array.isArray(value) ? value.length : null,
|
|
10
|
+
populated: value !== null && value !== undefined && value !== '',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* True only while a desktop QA run credential is present on the page. Outside a
|
|
15
|
+
* run — and in any observation-only run — client state values never leave the
|
|
16
|
+
* page at all.
|
|
17
|
+
*/
|
|
18
|
+
function qaRunActive() {
|
|
19
|
+
const run = globalThis.__TELLANN_RUN__;
|
|
20
|
+
return Boolean(run && run.runId && run.relayToken);
|
|
21
|
+
}
|
|
22
|
+
function serializeCandidate(value) {
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (typeof value === 'string')
|
|
26
|
+
return value.slice(0, 16_384);
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(value)?.slice(0, 16_384);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Attaches candidate protected values for the QA pipeline. These are proposals,
|
|
36
|
+
* not decisions: the browser observer re-derives a classification and the
|
|
37
|
+
* ingestion API applies the authoritative server-side floor before persisting.
|
|
38
|
+
* Anything whose key looks like a secret is dropped here as well, so a secret
|
|
39
|
+
* never leaves the page even as a candidate.
|
|
40
|
+
*/
|
|
41
|
+
function qaCandidateValues(key, previous, next) {
|
|
42
|
+
if (!qaRunActive() || CLIENT_STATE_SECRET_KEY.test(String(key)))
|
|
43
|
+
return {};
|
|
44
|
+
const previousValue = serializeCandidate(previous);
|
|
45
|
+
const nextValue = serializeCandidate(next);
|
|
46
|
+
if (previousValue === undefined && nextValue === undefined)
|
|
47
|
+
return {};
|
|
48
|
+
return {
|
|
49
|
+
qaProtectedCandidates: [
|
|
50
|
+
...(previousValue === undefined ? [] : [{ keyPath: `clientState.${key}.previousValue`, value: previousValue }]),
|
|
51
|
+
...(nextValue === undefined ? [] : [{ keyPath: `clientState.${key}.newValue`, value: nextValue }]),
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function safeState(value) {
|
|
56
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
57
|
+
? value
|
|
58
|
+
: {};
|
|
59
|
+
}
|
|
60
|
+
/** Top-level slice keys whose reference actually changed. */
|
|
61
|
+
function changedSlicePaths(before, after) {
|
|
62
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
63
|
+
return [...keys].filter((key) => before[key] !== after[key]);
|
|
64
|
+
}
|
|
65
|
+
function pickPaths(source, paths) {
|
|
66
|
+
const result = {};
|
|
67
|
+
for (const path of paths.slice(0, 50))
|
|
68
|
+
result[path] = source[path];
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
4
71
|
const MAX_EVENT_SIZE_BYTES = 32 * 1024; // 32 KB limit for standard events
|
|
5
72
|
const MAX_REPLAY_SIZE_BYTES = 128 * 1024; // 128 KB limit for replay events (e.g. if eventType is a replay event)
|
|
6
73
|
class TellannFrontendSDK {
|
|
@@ -64,6 +131,22 @@ class TellannFrontendSDK {
|
|
|
64
131
|
}
|
|
65
132
|
return;
|
|
66
133
|
}
|
|
134
|
+
// Enforce the contract against the caller's payload before redaction. A
|
|
135
|
+
// multi-kilobyte value must not become an apparently valid tiny event just
|
|
136
|
+
// because the privacy layer replaced it with a marker.
|
|
137
|
+
try {
|
|
138
|
+
const rawPayload = JSON.stringify({ eventType, metadata });
|
|
139
|
+
const rawSize = typeof Blob !== 'undefined' ? new Blob([rawPayload]).size : rawPayload.length;
|
|
140
|
+
const rawLimit = eventType.includes('REPLAY') ? MAX_REPLAY_SIZE_BYTES : MAX_EVENT_SIZE_BYTES;
|
|
141
|
+
if (rawSize > rawLimit) {
|
|
142
|
+
console.error(`[Tellann] Event of type "${eventType}" discarded. Size (${rawSize} bytes) exceeds limit of ${rawLimit} bytes.`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error('[Tellann] Failed to compute size of event, discarding', err);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
67
150
|
// Apply privacy-by-default metadata sanitization
|
|
68
151
|
const sanitizedMetadata = sanitizeMetadata(metadata);
|
|
69
152
|
const event = {
|
|
@@ -134,6 +217,100 @@ class TellannFrontendSDK {
|
|
|
134
217
|
action: action || 'NAVIGATE',
|
|
135
218
|
});
|
|
136
219
|
}
|
|
220
|
+
trackFlowInitialState(flowVersionId, stateKey) {
|
|
221
|
+
this.trackEvent('FLOW_INITIAL_STATE', { flowVersionId, stateKey });
|
|
222
|
+
}
|
|
223
|
+
trackFlowStateReached(flowVersionId, stateKey) {
|
|
224
|
+
this.trackEvent('FLOW_STATE_REACHED', { flowVersionId, stateKey });
|
|
225
|
+
}
|
|
226
|
+
trackFlowTransition(flowVersionId, fromStateKey, toStateKey, action) {
|
|
227
|
+
this.trackEvent('FLOW_TRANSITION', { flowVersionId, stateKey: toStateKey, fromStateKey, toStateKey, action });
|
|
228
|
+
}
|
|
229
|
+
trackFlowTerminalState(flowVersionId, stateKey) {
|
|
230
|
+
this.trackEvent('FLOW_TERMINAL_STATE', { flowVersionId, stateKey });
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Explicit adapter for Zustand, MobX, custom Context, and other stores.
|
|
234
|
+
*
|
|
235
|
+
* Shape metadata (type, length, populated) always travels. Actual values are
|
|
236
|
+
* only attached while a QA run credential is present on the page and the run
|
|
237
|
+
* is not observation-only, and even then they are carried as *candidate*
|
|
238
|
+
* protected values: the browser observer and the ingestion API both classify
|
|
239
|
+
* and encrypt them before anything is persisted. Nothing raw is ever written
|
|
240
|
+
* to the generic telemetry wire.
|
|
241
|
+
*/
|
|
242
|
+
trackClientState(store, key, previous, next) {
|
|
243
|
+
this.trackEvent('BUSINESS_EVENT', {
|
|
244
|
+
businessEventType: 'QA_CLIENT_STATE_MUTATION',
|
|
245
|
+
store: String(store).slice(0, 100),
|
|
246
|
+
key: String(key).slice(0, 200),
|
|
247
|
+
previous: describeClientStateValue(previous),
|
|
248
|
+
next: describeClientStateValue(next),
|
|
249
|
+
...qaCandidateValues(key, previous, next),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Redux middleware. Records the action type, which top-level slice paths
|
|
254
|
+
* actually changed, and protected before/after values for those slices.
|
|
255
|
+
*
|
|
256
|
+
* const store = configureStore({
|
|
257
|
+
* reducer,
|
|
258
|
+
* middleware: (get) => get().concat(TELLANN.createReduxMiddleware()),
|
|
259
|
+
* });
|
|
260
|
+
*/
|
|
261
|
+
createReduxMiddleware() {
|
|
262
|
+
const sdk = this;
|
|
263
|
+
return (store) => (next) => (action) => {
|
|
264
|
+
const before = safeState(store.getState());
|
|
265
|
+
const result = next(action);
|
|
266
|
+
const after = safeState(store.getState());
|
|
267
|
+
const type = String(action?.type ?? 'UNKNOWN_ACTION');
|
|
268
|
+
const changed = changedSlicePaths(before, after);
|
|
269
|
+
if (changed.length) {
|
|
270
|
+
sdk.trackEvent('BUSINESS_EVENT', {
|
|
271
|
+
businessEventType: 'QA_CLIENT_STATE_MUTATION',
|
|
272
|
+
store: 'redux',
|
|
273
|
+
key: type.slice(0, 200),
|
|
274
|
+
actionType: type.slice(0, 200),
|
|
275
|
+
changedSlicePaths: changed.slice(0, 50),
|
|
276
|
+
previous: describeClientStateValue(before),
|
|
277
|
+
next: describeClientStateValue(after),
|
|
278
|
+
...qaCandidateValues(type, pickPaths(before, changed), pickPaths(after, changed)),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* React Context adapter. Only providers explicitly identified and approved in
|
|
286
|
+
* the validated Flow instrumentation manifest should call this — there is no
|
|
287
|
+
* blanket interception of React internals, because that cannot be done
|
|
288
|
+
* reliably and would misreport what was actually captured.
|
|
289
|
+
*
|
|
290
|
+
* useEffect(() => TELLANN.trackContextValue('AuthContext', 'user', value), [value]);
|
|
291
|
+
*/
|
|
292
|
+
trackContextValue(providerName, key, value) {
|
|
293
|
+
this.trackClientState(`context:${providerName}`, key, undefined, value);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Wraps an approved `useState` setter so Flow-relevant state changes are
|
|
297
|
+
* recorded. Intended to be applied to the specific setters identified during
|
|
298
|
+
* static analysis, not to every setter in the application.
|
|
299
|
+
*
|
|
300
|
+
* const [email, setEmail] = useState('');
|
|
301
|
+
* const setTracked = TELLANN.trackStateSetter('CheckoutForm', 'email', setEmail, email);
|
|
302
|
+
*/
|
|
303
|
+
trackStateSetter(componentName, key, setter, current) {
|
|
304
|
+
let previous = current;
|
|
305
|
+
return (value) => {
|
|
306
|
+
const resolved = typeof value === 'function'
|
|
307
|
+
? value(previous)
|
|
308
|
+
: value;
|
|
309
|
+
this.trackClientState(`useState:${componentName}`, key, previous, resolved);
|
|
310
|
+
previous = resolved;
|
|
311
|
+
setter(resolved);
|
|
312
|
+
};
|
|
313
|
+
}
|
|
137
314
|
startWorkflow(workflowName) {
|
|
138
315
|
const id = this.workflowTracker.start(workflowName);
|
|
139
316
|
this.trackEvent('WORKFLOW_STARTED', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tellann/frontend-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tellann browser telemetry and QA-run correlation SDK",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "tsc",
|
|
47
47
|
"dev": "tsc -w",
|
|
48
|
-
"test": "node --test dist/index.test.js"
|
|
48
|
+
"test": "tsc && node --test dist/index.test.js"
|
|
49
49
|
}
|
|
50
50
|
}
|