@obsrviq/tracker 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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +1827 -0
- package/dist/index.js.map +1 -0
- package/dist/obsrviq.global.js +189 -0
- package/dist/obsrviq.global.js.map +1 -0
- package/package.json +41 -0
- package/src/console.ts +96 -0
- package/src/context.ts +44 -0
- package/src/errors.ts +78 -0
- package/src/forms.ts +189 -0
- package/src/global.ts +61 -0
- package/src/index.ts +436 -0
- package/src/interactions.ts +70 -0
- package/src/mask.ts +42 -0
- package/src/network.ts +439 -0
- package/src/pageviews.ts +67 -0
- package/src/recorder.ts +53 -0
- package/src/scroll.ts +65 -0
- package/src/serialize.ts +120 -0
- package/src/storage.ts +131 -0
- package/src/transport.ts +181 -0
- package/src/ui.ts +182 -0
- package/src/util.ts +96 -0
- package/src/vitals.ts +111 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import type { IngestSessionMeta, ObsrviqEvent, ObsrviqInitConfig } from '@obsrviq/types';
|
|
2
|
+
import type { InstrumentCtx, ResolvedConfig, Teardown } from './context.js';
|
|
3
|
+
import { Transport } from './transport.js';
|
|
4
|
+
import { instrumentDom } from './recorder.js';
|
|
5
|
+
import { instrumentConsole } from './console.js';
|
|
6
|
+
import { instrumentNetwork } from './network.js';
|
|
7
|
+
import { instrumentErrors } from './errors.js';
|
|
8
|
+
import { instrumentVitals } from './vitals.js';
|
|
9
|
+
import { instrumentInteractions } from './interactions.js';
|
|
10
|
+
import { instrumentForms } from './forms.js';
|
|
11
|
+
import { instrumentScrollDepth } from './scroll.js';
|
|
12
|
+
import { instrumentPageviews } from './pageviews.js';
|
|
13
|
+
import { showConsent, showRecordingDot } from './ui.js';
|
|
14
|
+
import { clearSession, readSession, registerVisit, writeSession } from './storage.js';
|
|
15
|
+
import { Clock, detectDevice, privacySignalsOptOut, sha256Hex, uuid } from './util.js';
|
|
16
|
+
|
|
17
|
+
const DEFAULTS = {
|
|
18
|
+
maskAllInputs: true,
|
|
19
|
+
blockSelector: '.obsrviq-block',
|
|
20
|
+
noPrivacy: false,
|
|
21
|
+
captureNetworkBodies: false,
|
|
22
|
+
redactHeaders: ['authorization', 'cookie', 'set-cookie'],
|
|
23
|
+
sampleRate: 1.0,
|
|
24
|
+
recordCanvas: false,
|
|
25
|
+
endpoint: 'https://in.lumera.app',
|
|
26
|
+
flushIntervalMs: 5000,
|
|
27
|
+
continueSession: false,
|
|
28
|
+
sessionTimeoutMs: 30 * 60 * 1000,
|
|
29
|
+
requireConsent: false,
|
|
30
|
+
askPermission: false,
|
|
31
|
+
showRecording: false,
|
|
32
|
+
maxArgBytes: 8 * 1024,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface Diagnostics {
|
|
36
|
+
recording: boolean;
|
|
37
|
+
sessionId: string | null;
|
|
38
|
+
flushes: number;
|
|
39
|
+
bytesSent: number;
|
|
40
|
+
eventsSent: number;
|
|
41
|
+
eventsQueued: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class ObsrviqClient {
|
|
45
|
+
private config: ResolvedConfig | null = null;
|
|
46
|
+
private clock: Clock | null = null;
|
|
47
|
+
private transport: Transport | null = null;
|
|
48
|
+
private teardowns: Teardown[] = [];
|
|
49
|
+
private meta: IngestSessionMeta | null = null;
|
|
50
|
+
private recording = false;
|
|
51
|
+
private consent: boolean | null = null;
|
|
52
|
+
private sampledIn = true;
|
|
53
|
+
private lastActivity = 0;
|
|
54
|
+
/** The userId this session is attributed to (for the shared-device split). */
|
|
55
|
+
private currentUserId: string | undefined;
|
|
56
|
+
/** Next batch seq — mirrored from the transport so we can persist continuation. */
|
|
57
|
+
private nextSeq = 0;
|
|
58
|
+
/** Open task spans keyed by task name → the span id + start time, so endTask()
|
|
59
|
+
* can pair with its startTask(). Re-starting the same name supersedes the
|
|
60
|
+
* earlier open span (the player pairs by spanId, so this only affects which
|
|
61
|
+
* start an unqualified end() resolves to). */
|
|
62
|
+
private openTasks = new Map<string, { spanId: string; startT: number }>();
|
|
63
|
+
private consentUnmount: (() => void) | null = null;
|
|
64
|
+
private recDotUnmount: (() => void) | null = null;
|
|
65
|
+
private diag: Diagnostics = {
|
|
66
|
+
recording: false,
|
|
67
|
+
sessionId: null,
|
|
68
|
+
flushes: 0,
|
|
69
|
+
bytesSent: 0,
|
|
70
|
+
eventsSent: 0,
|
|
71
|
+
eventsQueued: 0,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The current session id (null until init). */
|
|
75
|
+
get sessionId(): string | null {
|
|
76
|
+
return this.diag.sessionId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
init(config: ObsrviqInitConfig): void {
|
|
80
|
+
if (this.config) return; // idempotent
|
|
81
|
+
if (!config.siteKey) {
|
|
82
|
+
console.warn('[obsrviq] init() called without siteKey — recording disabled.');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.config = { ...DEFAULTS, ...stripUndefined(config) } as ResolvedConfig;
|
|
86
|
+
// No-privacy: record input values verbatim by unmasking inputs — but only if
|
|
87
|
+
// the host didn't set maskAllInputs explicitly (their choice always wins).
|
|
88
|
+
// Page text is already verbatim unless maskTextSelector is set. Passwords stay
|
|
89
|
+
// masked via the recorder's maskInputOptions floor.
|
|
90
|
+
if (this.config.noPrivacy && config.maskAllInputs === undefined) {
|
|
91
|
+
this.config.maskAllInputs = false;
|
|
92
|
+
}
|
|
93
|
+
// askPermission = "show a built-in consent prompt"; it implies a consent gate.
|
|
94
|
+
if (this.config.askPermission) this.config.requireConsent = true;
|
|
95
|
+
|
|
96
|
+
this.startSession();
|
|
97
|
+
|
|
98
|
+
// Init-time identity (rarely known this early — usually set later via identify()).
|
|
99
|
+
if (config.userId != null) this.applyUserId(config.userId);
|
|
100
|
+
if (config.metadata && this.meta) this.meta.metadata = { ...config.metadata };
|
|
101
|
+
|
|
102
|
+
// GPC / DNT always wins → no recording, no UI (R-1).
|
|
103
|
+
if (privacySignalsOptOut()) {
|
|
104
|
+
this.consent = false;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (this.config.requireConsent) {
|
|
109
|
+
// Built-in prompt, if asked; otherwise the host calls setConsent(true) itself.
|
|
110
|
+
if (this.config.askPermission) {
|
|
111
|
+
this.consentUnmount = showConsent(
|
|
112
|
+
this.config.blockSelector,
|
|
113
|
+
this.config.consentText,
|
|
114
|
+
() => this.setConsent(true),
|
|
115
|
+
() => this.setConsent(false),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
this.maybeStart();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** (Re)create the session id, clock, meta, and transport. Used by init() and
|
|
124
|
+
* reset(). Does not start recording — callers decide via maybeStart().
|
|
125
|
+
* @param seedInitUser seed identity from the init-time `cfg.userId`. True for
|
|
126
|
+
* the initial init() session; FALSE for reset() (logout) — otherwise the fresh
|
|
127
|
+
* post-logout session would be re-attributed to the user who just logged out. */
|
|
128
|
+
private startSession(seedInitUser = true): void {
|
|
129
|
+
const cfg = this.config!;
|
|
130
|
+
// Session continuation (opt-in): resume the same session for a same-device
|
|
131
|
+
// return within the inactivity window — but NOT when a different known user is
|
|
132
|
+
// at the device (the shared-device split). A resumed session keeps recording
|
|
133
|
+
// regardless of sampling (it was already sampled in).
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
let resume: ReturnType<typeof readSession> = null;
|
|
136
|
+
if (cfg.continueSession) {
|
|
137
|
+
const rec = readSession();
|
|
138
|
+
if (
|
|
139
|
+
rec &&
|
|
140
|
+
now - rec.lastActivity < cfg.sessionTimeoutMs &&
|
|
141
|
+
!(cfg.userId != null && rec.userId != null && rec.userId !== String(cfg.userId))
|
|
142
|
+
) {
|
|
143
|
+
resume = rec;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const sessionId = resume ? resume.id : uuid();
|
|
147
|
+
this.clock = resume ? new Clock(resume.startedAt, resume.lastT) : new Clock();
|
|
148
|
+
this.currentUserId =
|
|
149
|
+
resume?.userId ?? (seedInitUser && cfg.userId != null ? String(cfg.userId) : undefined);
|
|
150
|
+
this.nextSeq = resume ? resume.seq : 0;
|
|
151
|
+
this.diag.sessionId = sessionId;
|
|
152
|
+
this.meta = {
|
|
153
|
+
startedAt: this.clock.startedAt,
|
|
154
|
+
entryUrl: location.href,
|
|
155
|
+
device: detectDevice(),
|
|
156
|
+
};
|
|
157
|
+
// Sampling decision for the whole session — a resumed one stays in.
|
|
158
|
+
this.sampledIn = resume ? true : Math.random() < (cfg.sampleRate ?? 1);
|
|
159
|
+
this.transport = new Transport({
|
|
160
|
+
endpoint: cfg.endpoint,
|
|
161
|
+
siteKey: cfg.siteKey,
|
|
162
|
+
sessionId,
|
|
163
|
+
getMeta: () => this.meta ?? undefined,
|
|
164
|
+
beforeSend: cfg.beforeSend,
|
|
165
|
+
flushIntervalMs: cfg.flushIntervalMs,
|
|
166
|
+
startSeq: this.nextSeq,
|
|
167
|
+
onSeqDispatch: (next) => {
|
|
168
|
+
this.nextSeq = next;
|
|
169
|
+
this.persistSession();
|
|
170
|
+
},
|
|
171
|
+
onFlush: ({ bytes, events, ok }) => {
|
|
172
|
+
if (ok) {
|
|
173
|
+
this.diag.flushes++;
|
|
174
|
+
this.diag.bytesSent += bytes;
|
|
175
|
+
this.diag.eventsSent += events;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
this.recording = false;
|
|
180
|
+
this.diag.recording = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Persist the session-continuation record (no-op unless continueSession). Only
|
|
184
|
+
* ever writes for a RECORDING session — a sampled-out or pre-consent session must
|
|
185
|
+
* not leave a record that a later page would resume (and force back into
|
|
186
|
+
* recording), and identify() can fire before recording starts. */
|
|
187
|
+
private persistSession(): void {
|
|
188
|
+
if (!this.config?.continueSession || !this.recording || !this.clock || !this.diag.sessionId) return;
|
|
189
|
+
writeSession({
|
|
190
|
+
id: this.diag.sessionId,
|
|
191
|
+
startedAt: this.clock.startedAt,
|
|
192
|
+
lastActivity: Date.now(),
|
|
193
|
+
seq: this.nextSeq,
|
|
194
|
+
lastT: this.clock.now(),
|
|
195
|
+
userId: this.currentUserId,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private maybeStart(): void {
|
|
200
|
+
if (this.recording || !this.config || !this.clock || !this.transport) return;
|
|
201
|
+
if (this.config.requireConsent && this.consent !== true) return;
|
|
202
|
+
if (this.consent === false) return;
|
|
203
|
+
if (!this.sampledIn) return;
|
|
204
|
+
|
|
205
|
+
// Returning-visitor identity: now that consent is granted and we're about to
|
|
206
|
+
// record, read-or-create the persistent (anonymous) visitor record and stamp
|
|
207
|
+
// it onto the session meta. Guarded internally — never blocks recording.
|
|
208
|
+
if (this.meta && this.meta.visitorId == null) {
|
|
209
|
+
const visit = registerVisit(Date.now());
|
|
210
|
+
this.meta.visitorId = visit.id;
|
|
211
|
+
this.meta.visitCount = visit.visits;
|
|
212
|
+
this.meta.firstSeenAt = visit.firstSeenAt;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ctx: InstrumentCtx = {
|
|
216
|
+
clock: this.clock,
|
|
217
|
+
sessionId: this.diag.sessionId!,
|
|
218
|
+
config: this.config,
|
|
219
|
+
emit: (e: ObsrviqEvent) => {
|
|
220
|
+
this.transport!.enqueue(e);
|
|
221
|
+
this.diag.eventsQueued++;
|
|
222
|
+
},
|
|
223
|
+
markActivity: () => {
|
|
224
|
+
this.lastActivity = this.clock!.now();
|
|
225
|
+
},
|
|
226
|
+
lastActivityT: () => this.lastActivity,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
this.transport.start();
|
|
230
|
+
// Order matters slightly: network/console wrappers should be installed
|
|
231
|
+
// before app code runs more requests; DOM last so its snapshot is current.
|
|
232
|
+
this.teardowns.push(instrumentConsole(ctx));
|
|
233
|
+
this.teardowns.push(instrumentNetwork(ctx));
|
|
234
|
+
this.teardowns.push(instrumentErrors(ctx));
|
|
235
|
+
this.teardowns.push(instrumentVitals(ctx));
|
|
236
|
+
this.teardowns.push(instrumentInteractions(ctx));
|
|
237
|
+
this.teardowns.push(instrumentForms(ctx));
|
|
238
|
+
this.teardowns.push(instrumentScrollDepth(ctx));
|
|
239
|
+
this.teardowns.push(instrumentPageviews(ctx));
|
|
240
|
+
this.teardowns.push(instrumentDom(ctx));
|
|
241
|
+
|
|
242
|
+
this.recording = true;
|
|
243
|
+
this.diag.recording = true;
|
|
244
|
+
|
|
245
|
+
// Now that we're actually recording, anchor the continuation record so a next
|
|
246
|
+
// page can resume this (and ONLY a recording) session, even before any flush.
|
|
247
|
+
this.persistSession();
|
|
248
|
+
|
|
249
|
+
// Transparency: optional visible "recording" indicator.
|
|
250
|
+
if (this.config.showRecording && !this.recDotUnmount) {
|
|
251
|
+
this.recDotUnmount = showRecordingDot(this.config.blockSelector);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Associate the session with an application user. Stores the RAW userId (for
|
|
256
|
+
* search + display) AND a SHA-256 identityHash, merges any traits into metadata,
|
|
257
|
+
* and force-flushes so the live session is retro-stamped promptly. Safe to call
|
|
258
|
+
* before recording starts (values ride the first batch) and after login. */
|
|
259
|
+
identify(userId: string, traits?: Record<string, unknown>): void {
|
|
260
|
+
if (!this.meta) return;
|
|
261
|
+
const uid = String(userId);
|
|
262
|
+
// Shared-device split: if this session is already attributed to a DIFFERENT
|
|
263
|
+
// user (continueSession only), rotate to a fresh session so two people never
|
|
264
|
+
// merge into one. reset() clears the continuation record so it can't resume
|
|
265
|
+
// the prior user. (Same user, or first identify on the session → no split.)
|
|
266
|
+
if (this.config?.continueSession && this.currentUserId != null && this.currentUserId !== uid) {
|
|
267
|
+
this.reset();
|
|
268
|
+
}
|
|
269
|
+
this.currentUserId = uid;
|
|
270
|
+
this.applyUserId(uid);
|
|
271
|
+
if (traits) this.mergeMetadata(traits);
|
|
272
|
+
// track() emits an event only while recording — which also guarantees the
|
|
273
|
+
// next flush is non-empty so the updated meta actually ships (empty buffers
|
|
274
|
+
// are skipped by transport.flush()).
|
|
275
|
+
this.track('identify', { hasTraits: !!traits });
|
|
276
|
+
void this.flush();
|
|
277
|
+
// Stamp the userId onto the continuation record so the next page knows it.
|
|
278
|
+
this.persistSession();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Merge custom properties onto the session at any time (searchable server-side). */
|
|
282
|
+
setMetadata(props: Record<string, unknown>): void {
|
|
283
|
+
if (!this.meta) return;
|
|
284
|
+
this.mergeMetadata(props);
|
|
285
|
+
this.track('metadata', { keys: Object.keys(props) });
|
|
286
|
+
void this.flush();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Forget the current identity (e.g. on logout) and rotate to a fresh anonymous
|
|
290
|
+
* session, so post-logout activity isn't attributed to the prior user. (Identity
|
|
291
|
+
* can't be cleared on the existing row — the server merge only fills nulls — so
|
|
292
|
+
* we start a new session instead.) */
|
|
293
|
+
reset(): void {
|
|
294
|
+
if (!this.config) return;
|
|
295
|
+
const wasRecording = this.recording;
|
|
296
|
+
if (this.recording) this.teardown(); // flushes the old session's final batch
|
|
297
|
+
// An explicit reset (logout / user switch) must NEVER resume the prior
|
|
298
|
+
// session — drop the continuation record so startSession() begins fresh.
|
|
299
|
+
clearSession();
|
|
300
|
+
this.currentUserId = undefined;
|
|
301
|
+
// Fresh session must stay anonymous — do NOT re-seed the init-time userId, or a
|
|
302
|
+
// post-logout session would be re-attributed to the user who just logged out.
|
|
303
|
+
this.startSession(false);
|
|
304
|
+
if (wasRecording && this.consent !== false && !privacySignalsOptOut()) this.maybeStart();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Set the raw userId on meta and compute its hash (async). */
|
|
308
|
+
private applyUserId(userId: string): void {
|
|
309
|
+
const raw = String(userId);
|
|
310
|
+
if (this.meta) this.meta.userId = raw;
|
|
311
|
+
void sha256Hex(raw).then((hash) => {
|
|
312
|
+
if (this.meta && this.meta.userId === raw) this.meta.identityHash = hash;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private mergeMetadata(props: Record<string, unknown>): void {
|
|
317
|
+
if (!this.meta) return;
|
|
318
|
+
this.meta.metadata = { ...(this.meta.metadata ?? {}), ...props };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Emit a custom event (e.g. conversions). */
|
|
322
|
+
track(name: string, props?: Record<string, unknown>): void {
|
|
323
|
+
if (!this.recording || !this.clock || !this.transport) return;
|
|
324
|
+
this.transport.enqueue({
|
|
325
|
+
id: uuid(),
|
|
326
|
+
sessionId: this.diag.sessionId!,
|
|
327
|
+
type: 'custom',
|
|
328
|
+
t: this.clock.now(),
|
|
329
|
+
ts: this.clock.wall(),
|
|
330
|
+
name,
|
|
331
|
+
props,
|
|
332
|
+
});
|
|
333
|
+
this.diag.eventsQueued++;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Mark a conversion. Conversion is client-defined, so the NAME matters —
|
|
337
|
+
* "checkout", "signup", "trial_started" — and each is denormalised onto the
|
|
338
|
+
* session for per-client filtering. Sets props.conversion so the legacy
|
|
339
|
+
* 'conversion' flag still lights up. Sugar over track(name, {conversion:true}). */
|
|
340
|
+
conversion(name: string, props?: Record<string, unknown>): void {
|
|
341
|
+
const clean = String(name).trim();
|
|
342
|
+
if (!clean) return;
|
|
343
|
+
this.track(clean, { ...props, conversion: true });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Attach one or more free-form, searchable labels to the current session.
|
|
347
|
+
* Tags are session-level (deduped server-side) — use them to slice sessions
|
|
348
|
+
* however a client likes ("vip", "ab_variant_b", "mobile_app"). */
|
|
349
|
+
tag(...names: string[]): void {
|
|
350
|
+
const clean = names.map((n) => String(n).trim()).filter(Boolean);
|
|
351
|
+
if (!clean.length) return;
|
|
352
|
+
this.track('_tag', { tags: clean });
|
|
353
|
+
void this.flush();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Begin a named sub-event span. Pairs with end() / endTask(name) to produce a
|
|
357
|
+
* searchable, duration-bearing span on the session timeline. Returns a handle
|
|
358
|
+
* so callers can `const t = Obsrviq.startTask('checkout'); …; t.end()`. */
|
|
359
|
+
startTask(name: string, props?: Record<string, unknown>): { end: (endProps?: Record<string, unknown>) => void } {
|
|
360
|
+
const clean = String(name).trim();
|
|
361
|
+
const spanId = uuid();
|
|
362
|
+
const startT = this.clock ? this.clock.now() : 0;
|
|
363
|
+
if (clean) {
|
|
364
|
+
this.openTasks.set(clean, { spanId, startT });
|
|
365
|
+
this.track(`${clean}:started`, { ...props, spanId, task: clean });
|
|
366
|
+
}
|
|
367
|
+
return { end: (endProps?: Record<string, unknown>) => this.endTask(clean, endProps) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Close a named sub-event span opened by startTask(name). Safe to call even
|
|
371
|
+
* if the start was never seen (renders as an orphan end on the timeline). */
|
|
372
|
+
endTask(name: string, props?: Record<string, unknown>): void {
|
|
373
|
+
const clean = String(name).trim();
|
|
374
|
+
if (!clean) return;
|
|
375
|
+
const open = this.openTasks.get(clean);
|
|
376
|
+
this.openTasks.delete(clean);
|
|
377
|
+
const spanId = open?.spanId ?? uuid();
|
|
378
|
+
this.track(`${clean}:ended`, {
|
|
379
|
+
...props,
|
|
380
|
+
spanId,
|
|
381
|
+
task: clean,
|
|
382
|
+
...(open ? { startT: open.startT } : {}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Gate recording (PRIV-2). true starts; false stops + flushes. Dismisses the
|
|
387
|
+
* built-in consent prompt if one is showing. */
|
|
388
|
+
setConsent(granted: boolean): void {
|
|
389
|
+
this.consent = granted;
|
|
390
|
+
this.consentUnmount?.();
|
|
391
|
+
this.consentUnmount = null;
|
|
392
|
+
if (granted) this.maybeStart();
|
|
393
|
+
else if (this.recording) this.teardown();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Hard stop + flush. */
|
|
397
|
+
stop(): void {
|
|
398
|
+
this.teardown();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Force a flush now (returns when send is attempted). */
|
|
402
|
+
flush(): Promise<boolean> {
|
|
403
|
+
return this.transport ? this.transport.flush('manual') : Promise.resolve(true);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
getDiagnostics(): Diagnostics {
|
|
407
|
+
return { ...this.diag };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private teardown(): void {
|
|
411
|
+
for (const t of this.teardowns.splice(0)) {
|
|
412
|
+
try {
|
|
413
|
+
t();
|
|
414
|
+
} catch {
|
|
415
|
+
/* ignore */
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
this.recDotUnmount?.();
|
|
419
|
+
this.recDotUnmount = null;
|
|
420
|
+
this.transport?.stop();
|
|
421
|
+
this.recording = false;
|
|
422
|
+
this.diag.recording = false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function stripUndefined<T extends object>(obj: T): Partial<T> {
|
|
427
|
+
const out: Partial<T> = {};
|
|
428
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
429
|
+
if (v !== undefined) (out as Record<string, unknown>)[k] = v;
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export const Obsrviq = new ObsrviqClient();
|
|
435
|
+
export type { ObsrviqInitConfig } from '@obsrviq/types';
|
|
436
|
+
export default Obsrviq;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CustomEvent as ObsrviqCustomEvent } from '@obsrviq/types';
|
|
2
|
+
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
|
+
import { cssPath } from './serialize.js';
|
|
4
|
+
import { uuid } from './util.js';
|
|
5
|
+
|
|
6
|
+
const RAGE_WINDOW_MS = 1000;
|
|
7
|
+
const RAGE_THRESHOLD = 3;
|
|
8
|
+
const DEAD_WAIT_MS = 3000;
|
|
9
|
+
|
|
10
|
+
function isInteractive(el: Element | null): boolean {
|
|
11
|
+
if (!el) return false;
|
|
12
|
+
let node: Element | null = el;
|
|
13
|
+
for (let i = 0; i < 4 && node; i++) {
|
|
14
|
+
const tag = node.tagName.toLowerCase();
|
|
15
|
+
if (['a', 'button', 'input', 'select', 'textarea', 'label', 'summary'].includes(tag))
|
|
16
|
+
return true;
|
|
17
|
+
if (node.getAttribute('role') === 'button' || node.hasAttribute('onclick')) return true;
|
|
18
|
+
if (node.getAttribute('contenteditable') === 'true') return true;
|
|
19
|
+
node = node.parentElement;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function instrumentInteractions(ctx: InstrumentCtx): Teardown {
|
|
25
|
+
let recent: { el: Element; t: number }[] = [];
|
|
26
|
+
let lastRageAt = -Infinity;
|
|
27
|
+
|
|
28
|
+
const emitCustom = (name: string, props: Record<string, unknown>, t: number) => {
|
|
29
|
+
const e: ObsrviqCustomEvent = {
|
|
30
|
+
id: uuid(),
|
|
31
|
+
sessionId: ctx.sessionId,
|
|
32
|
+
type: 'custom',
|
|
33
|
+
t,
|
|
34
|
+
ts: ctx.clock.wall(),
|
|
35
|
+
name,
|
|
36
|
+
props,
|
|
37
|
+
};
|
|
38
|
+
ctx.emit(e);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const onClick = (ev: MouseEvent) => {
|
|
42
|
+
const target = ev.target as Element | null;
|
|
43
|
+
if (!target || !(target instanceof Element)) return;
|
|
44
|
+
const t = ctx.clock.now();
|
|
45
|
+
const selector = cssPath(target);
|
|
46
|
+
|
|
47
|
+
// ── Rage click detection ──
|
|
48
|
+
recent = recent.filter((r) => t - r.t <= RAGE_WINDOW_MS);
|
|
49
|
+
recent.push({ el: target, t });
|
|
50
|
+
const sameTarget = recent.filter((r) => r.el === target);
|
|
51
|
+
if (sameTarget.length >= RAGE_THRESHOLD && t - lastRageAt > RAGE_WINDOW_MS) {
|
|
52
|
+
lastRageAt = t;
|
|
53
|
+
emitCustom('rage_click', { selector, count: sameTarget.length }, t);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Dead click detection ──
|
|
57
|
+
if (!isInteractive(target)) {
|
|
58
|
+
const before = ctx.lastActivityT();
|
|
59
|
+
window.setTimeout(() => {
|
|
60
|
+
// No app activity (mutation/nav/network) after the click → dead.
|
|
61
|
+
if (ctx.lastActivityT() <= Math.max(before, t)) {
|
|
62
|
+
emitCustom('dead_click', { selector }, t);
|
|
63
|
+
}
|
|
64
|
+
}, DEAD_WAIT_MS);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
window.addEventListener('click', onClick, true);
|
|
69
|
+
return () => window.removeEventListener('click', onClick, true);
|
|
70
|
+
}
|
package/src/mask.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Masking pipeline (PRIV). Applied to console args and network bodies before
|
|
2
|
+
// anything leaves the page. Heuristics catch the obvious PII; tenants tighten
|
|
3
|
+
// with maskTextSelector / beforeSend.
|
|
4
|
+
|
|
5
|
+
const EMAIL = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
|
|
6
|
+
const CC = /\b(?:\d[ -]*?){13,19}\b/g; // candidate card numbers
|
|
7
|
+
const SSN = /\b\d{3}-\d{2}-\d{4}\b/g;
|
|
8
|
+
const LONG_DIGITS = /\b\d{9,}\b/g; // phone-ish / account-ish
|
|
9
|
+
|
|
10
|
+
// Keys whose values we always redact regardless of content.
|
|
11
|
+
const SENSITIVE_KEYS =
|
|
12
|
+
/(pass(word)?|secret|token|api[-_]?key|authorization|cookie|ssn|card|cvv|pin|otp)/i;
|
|
13
|
+
|
|
14
|
+
export function maskString(input: string): string {
|
|
15
|
+
return input
|
|
16
|
+
.replace(EMAIL, '«email»')
|
|
17
|
+
.replace(CC, (m) => (luhnish(m) ? '«card»' : m))
|
|
18
|
+
.replace(SSN, '«ssn»')
|
|
19
|
+
.replace(LONG_DIGITS, '«number»');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function luhnish(s: string): boolean {
|
|
23
|
+
const digits = s.replace(/\D/g, '');
|
|
24
|
+
return digits.length >= 13 && digits.length <= 19;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isSensitiveKey(key: string): boolean {
|
|
28
|
+
return SENSITIVE_KEYS.test(key);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Recursively mask an object's string values and sensitive keys. */
|
|
32
|
+
export function maskValue(value: unknown, depth = 0): unknown {
|
|
33
|
+
if (depth > 6) return '«depth»';
|
|
34
|
+
if (typeof value === 'string') return maskString(value);
|
|
35
|
+
if (value === null || typeof value !== 'object') return value;
|
|
36
|
+
if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
|
|
37
|
+
const out: Record<string, unknown> = {};
|
|
38
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
39
|
+
out[k] = isSensitiveKey(k) ? '«redacted»' : maskValue(v, depth + 1);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|