@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.
@@ -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 sanitize = (val) => {
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] = '[REDACTED]';
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
- return val;
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.1.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
  }