@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/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
+ }