@tellann/frontend-sdk 0.1.0 → 0.3.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,90 @@
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
+ /**
23
+ * The local relay the desktop observer injected for this run, if any.
24
+ *
25
+ * While a guided run is active every event has to travel through the relay:
26
+ * only the relay attaches the run-ingestion credential, and the collector
27
+ * advances the Flow boundary solely for credentialed events. A
28
+ * `FLOW_INITIAL_STATE` flushed straight to the configured gateway is ingested
29
+ * as ordinary telemetry, so the run would sit at "Waiting for
30
+ * FLOW_INITIAL_STATE" no matter how many times the page emits it.
31
+ */
32
+ function activeRunRelay() {
33
+ const run = globalThis.__TELLANN_RUN__;
34
+ if (!run || typeof run.runId !== 'string' || typeof run.relayToken !== 'string')
35
+ return null;
36
+ const endpoint = typeof run.relayEndpoint === 'string' ? run.relayEndpoint.replace(/\/$/, '') : '';
37
+ return endpoint ? { endpoint, token: run.relayToken } : null;
38
+ }
39
+ function serializeCandidate(value) {
40
+ if (value === undefined)
41
+ return undefined;
42
+ if (typeof value === 'string')
43
+ return value.slice(0, 16_384);
44
+ try {
45
+ return JSON.stringify(value)?.slice(0, 16_384);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Attaches candidate protected values for the QA pipeline. These are proposals,
53
+ * not decisions: the browser observer re-derives a classification and the
54
+ * ingestion API applies the authoritative server-side floor before persisting.
55
+ * Anything whose key looks like a secret is dropped here as well, so a secret
56
+ * never leaves the page even as a candidate.
57
+ */
58
+ function qaCandidateValues(key, previous, next) {
59
+ if (!qaRunActive() || CLIENT_STATE_SECRET_KEY.test(String(key)))
60
+ return {};
61
+ const previousValue = serializeCandidate(previous);
62
+ const nextValue = serializeCandidate(next);
63
+ if (previousValue === undefined && nextValue === undefined)
64
+ return {};
65
+ return {
66
+ qaProtectedCandidates: [
67
+ ...(previousValue === undefined ? [] : [{ keyPath: `clientState.${key}.previousValue`, value: previousValue }]),
68
+ ...(nextValue === undefined ? [] : [{ keyPath: `clientState.${key}.newValue`, value: nextValue }]),
69
+ ],
70
+ };
71
+ }
72
+ function safeState(value) {
73
+ return value && typeof value === 'object' && !Array.isArray(value)
74
+ ? value
75
+ : {};
76
+ }
77
+ /** Top-level slice keys whose reference actually changed. */
78
+ function changedSlicePaths(before, after) {
79
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
80
+ return [...keys].filter((key) => before[key] !== after[key]);
81
+ }
82
+ function pickPaths(source, paths) {
83
+ const result = {};
84
+ for (const path of paths.slice(0, 50))
85
+ result[path] = source[path];
86
+ return result;
87
+ }
4
88
  const MAX_EVENT_SIZE_BYTES = 32 * 1024; // 32 KB limit for standard events
5
89
  const MAX_REPLAY_SIZE_BYTES = 128 * 1024; // 128 KB limit for replay events (e.g. if eventType is a replay event)
6
90
  class TellannFrontendSDK {
@@ -64,6 +148,22 @@ class TellannFrontendSDK {
64
148
  }
65
149
  return;
66
150
  }
151
+ // Enforce the contract against the caller's payload before redaction. A
152
+ // multi-kilobyte value must not become an apparently valid tiny event just
153
+ // because the privacy layer replaced it with a marker.
154
+ try {
155
+ const rawPayload = JSON.stringify({ eventType, metadata });
156
+ const rawSize = typeof Blob !== 'undefined' ? new Blob([rawPayload]).size : rawPayload.length;
157
+ const rawLimit = eventType.includes('REPLAY') ? MAX_REPLAY_SIZE_BYTES : MAX_EVENT_SIZE_BYTES;
158
+ if (rawSize > rawLimit) {
159
+ console.error(`[Tellann] Event of type "${eventType}" discarded. Size (${rawSize} bytes) exceeds limit of ${rawLimit} bytes.`);
160
+ return;
161
+ }
162
+ }
163
+ catch (err) {
164
+ console.error('[Tellann] Failed to compute size of event, discarding', err);
165
+ return;
166
+ }
67
167
  // Apply privacy-by-default metadata sanitization
68
168
  const sanitizedMetadata = sanitizeMetadata(metadata);
69
169
  const event = {
@@ -134,6 +234,100 @@ class TellannFrontendSDK {
134
234
  action: action || 'NAVIGATE',
135
235
  });
136
236
  }
237
+ trackFlowInitialState(flowVersionId, stateKey) {
238
+ this.trackEvent('FLOW_INITIAL_STATE', { flowVersionId, stateKey });
239
+ }
240
+ trackFlowStateReached(flowVersionId, stateKey) {
241
+ this.trackEvent('FLOW_STATE_REACHED', { flowVersionId, stateKey });
242
+ }
243
+ trackFlowTransition(flowVersionId, fromStateKey, toStateKey, action) {
244
+ this.trackEvent('FLOW_TRANSITION', { flowVersionId, stateKey: toStateKey, fromStateKey, toStateKey, action });
245
+ }
246
+ trackFlowTerminalState(flowVersionId, stateKey) {
247
+ this.trackEvent('FLOW_TERMINAL_STATE', { flowVersionId, stateKey });
248
+ }
249
+ /**
250
+ * Explicit adapter for Zustand, MobX, custom Context, and other stores.
251
+ *
252
+ * Shape metadata (type, length, populated) always travels. Actual values are
253
+ * only attached while a QA run credential is present on the page and the run
254
+ * is not observation-only, and even then they are carried as *candidate*
255
+ * protected values: the browser observer and the ingestion API both classify
256
+ * and encrypt them before anything is persisted. Nothing raw is ever written
257
+ * to the generic telemetry wire.
258
+ */
259
+ trackClientState(store, key, previous, next) {
260
+ this.trackEvent('BUSINESS_EVENT', {
261
+ businessEventType: 'QA_CLIENT_STATE_MUTATION',
262
+ store: String(store).slice(0, 100),
263
+ key: String(key).slice(0, 200),
264
+ previous: describeClientStateValue(previous),
265
+ next: describeClientStateValue(next),
266
+ ...qaCandidateValues(key, previous, next),
267
+ });
268
+ }
269
+ /**
270
+ * Redux middleware. Records the action type, which top-level slice paths
271
+ * actually changed, and protected before/after values for those slices.
272
+ *
273
+ * const store = configureStore({
274
+ * reducer,
275
+ * middleware: (get) => get().concat(TELLANN.createReduxMiddleware()),
276
+ * });
277
+ */
278
+ createReduxMiddleware() {
279
+ const sdk = this;
280
+ return (store) => (next) => (action) => {
281
+ const before = safeState(store.getState());
282
+ const result = next(action);
283
+ const after = safeState(store.getState());
284
+ const type = String(action?.type ?? 'UNKNOWN_ACTION');
285
+ const changed = changedSlicePaths(before, after);
286
+ if (changed.length) {
287
+ sdk.trackEvent('BUSINESS_EVENT', {
288
+ businessEventType: 'QA_CLIENT_STATE_MUTATION',
289
+ store: 'redux',
290
+ key: type.slice(0, 200),
291
+ actionType: type.slice(0, 200),
292
+ changedSlicePaths: changed.slice(0, 50),
293
+ previous: describeClientStateValue(before),
294
+ next: describeClientStateValue(after),
295
+ ...qaCandidateValues(type, pickPaths(before, changed), pickPaths(after, changed)),
296
+ });
297
+ }
298
+ return result;
299
+ };
300
+ }
301
+ /**
302
+ * React Context adapter. Only providers explicitly identified and approved in
303
+ * the validated Flow instrumentation manifest should call this — there is no
304
+ * blanket interception of React internals, because that cannot be done
305
+ * reliably and would misreport what was actually captured.
306
+ *
307
+ * useEffect(() => TELLANN.trackContextValue('AuthContext', 'user', value), [value]);
308
+ */
309
+ trackContextValue(providerName, key, value) {
310
+ this.trackClientState(`context:${providerName}`, key, undefined, value);
311
+ }
312
+ /**
313
+ * Wraps an approved `useState` setter so Flow-relevant state changes are
314
+ * recorded. Intended to be applied to the specific setters identified during
315
+ * static analysis, not to every setter in the application.
316
+ *
317
+ * const [email, setEmail] = useState('');
318
+ * const setTracked = TELLANN.trackStateSetter('CheckoutForm', 'email', setEmail, email);
319
+ */
320
+ trackStateSetter(componentName, key, setter, current) {
321
+ let previous = current;
322
+ return (value) => {
323
+ const resolved = typeof value === 'function'
324
+ ? value(previous)
325
+ : value;
326
+ this.trackClientState(`useState:${componentName}`, key, previous, resolved);
327
+ previous = resolved;
328
+ setter(resolved);
329
+ };
330
+ }
137
331
  startWorkflow(workflowName) {
138
332
  const id = this.workflowTracker.start(workflowName);
139
333
  this.trackEvent('WORKFLOW_STARTED', {
@@ -220,13 +414,21 @@ class TellannFrontendSDK {
220
414
  console.error(`[Tellann] Batch payload size of ${payloadSize} bytes exceeds the 5 MB limit. Dropping batch.`);
221
415
  return;
222
416
  }
417
+ const relay = activeRunRelay();
418
+ const target = relay ? relay.endpoint : this.config.endpoint;
223
419
  const headers = {
224
420
  'Content-Type': 'application/json',
225
421
  };
226
- if (this.config.apiKey) {
422
+ if (relay) {
423
+ headers.Authorization = `Bearer ${relay.token}`;
424
+ }
425
+ else if (this.config.apiKey) {
227
426
  headers.Authorization = `Bearer ${this.config.apiKey}`;
228
427
  }
229
- if (this.config.environmentId) {
428
+ // The relay re-derives the environment from the run correlation, and its
429
+ // CORS allow-list does not carry this header — sending it would fail the
430
+ // preflight and lose the batch.
431
+ if (!relay && this.config.environmentId) {
230
432
  headers['x-tellann-environment-id'] = this.config.environmentId;
231
433
  }
232
434
  if (this.config.runId)
@@ -236,16 +438,16 @@ class TellannFrontendSDK {
236
438
  if (this.config.traceId)
237
439
  headers['x-tellann-trace-id'] = this.config.traceId;
238
440
  // 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') {
441
+ if (!relay && !this.config.apiKey && !this.config.environmentId && navigator.sendBeacon && typeof Blob !== 'undefined') {
240
442
  const blob = new Blob([payload], { type: 'application/json' });
241
- const success = navigator.sendBeacon(`${this.config.endpoint}/v1/events/batch`, blob);
443
+ const success = navigator.sendBeacon(`${target}/v1/events/batch`, blob);
242
444
  if (!success) {
243
445
  throw new Error('sendBeacon returned false');
244
446
  }
245
447
  }
246
448
  else {
247
449
  // Fallback to fetch
248
- await fetch(`${this.config.endpoint}/v1/events/batch`, {
450
+ await fetch(`${target}/v1/events/batch`, {
249
451
  method: 'POST',
250
452
  headers,
251
453
  body: payload,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellann/frontend-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Tellann browser telemetry and QA-run correlation SDK",
6
6
  "license": "UNLICENSED",
@@ -24,7 +24,8 @@
24
24
  "types": "./dist/index.d.ts",
25
25
  "import": "./dist/index.js",
26
26
  "default": "./dist/index.js"
27
- }
27
+ },
28
+ "./package.json": "./package.json"
28
29
  },
29
30
  "files": [
30
31
  "dist",
@@ -45,6 +46,6 @@
45
46
  "scripts": {
46
47
  "build": "tsc",
47
48
  "dev": "tsc -w",
48
- "test": "node --test dist/index.test.js"
49
+ "test": "tsc && node --test dist/index.test.js"
49
50
  }
50
51
  }