@crelora/mark 0.1.1 → 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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Mark is Crelora's lightweight attribution SDK for capturing user journeys, conversions, and consent across browsers and server-side runtimes. The npm package includes both browser and Node entry points so you can send consistent data from any surface.
4
4
 
5
- Use it to feed **OneLence** with first‑party behavioral and conversion data so you can build **analytics**, **insights**, **signals**, and **decisions** on top of a unified event stream.
5
+ Use it to feed [**OneLence**](https://onelence.com) with first‑party behavioral and conversion data so you can build **analytics**, **insights**, **signals**, and **decisions** on top of a unified event stream.
6
6
 
7
7
  ### API keys and documentation
8
8
 
@@ -104,6 +104,7 @@ The Node factory accepts optional custom storage or transport adapters so you ca
104
104
  - `flush()` – flushes queued/persisted delivery items.
105
105
  - `reset()` – clears user/session/attribution state and rotates visitor identity for logout flows.
106
106
  - `getStats()` – returns runtime delivery stats `{ queued, sent, failed, dropped }`.
107
+ - `setInternal(value)` / `getInternal()` – flags the current visitor as internal traffic (QA, staff, smoke tests). While set, every outgoing event is stamped with `is_internal: true` so the backend can exclude it from customer-facing reports by default. See [Flagging internal traffic](#flagging-internal-traffic).
107
108
 
108
109
  Reserved SDK fields (for example `event_name`, `user_id`, `consent_state`, and internal identity metadata) are sanitized from user payloads/traits and cannot override SDK-managed values.
109
110
 
@@ -223,6 +224,77 @@ Mark.identify('user_123', {
223
224
  - Autocapture (optional): set `autocapture: { pageview: true }` (and optionally `track_route_changes: true`) to emit on first load and SPA route changes. If consent is required and not yet granted, initial pageview is deferred and emitted once consent is granted.
224
225
  - All SDK config and event fields use snake_case (`site_id`, `site_host`, etc.) and map directly to stored payloads and database columns.
225
226
 
227
+ ## Extended autocapture (browser)
228
+
229
+ All of the following are **opt-in** under `autocapture`. They respect the same consent, DNT/GPC, and sampling rules as `track()` (see `sample_rate`: conversions are exempt; these modes are **not** conversion events). They are registered when `Mark.init()` runs.
230
+
231
+ | Flag | Event name(s) | Behavior |
232
+ | --- | --- | --- |
233
+ | `click` | `data-mark-event` value, or `click` | Listens for clicks. **Default:** only elements matching `[data-mark-event]`; set `click: { selector: '.my-tracked' }` to use a custom selector. Sends `element_id`, `element_classes`, truncated `text`, and `href` when applicable. |
234
+ | `form_submit` | `form_submit` | Listens for `submit`; sends `form_id`, `form_name`, `action`. |
235
+ | `outbound_link` | `outbound_link_click` | On click, if the link target is another origin, sends `href`. Uses `preferBeacon: true` so the event is more likely to fire before navigation. |
236
+ | `scroll_depth` | `scroll_depth` | Fires at **25%, 50%, 75%, and 100%** of vertical scroll depth (once each per page load). Payload includes `percent`. |
237
+ | `web_vitals` | `web_vital` | Lazy-loads the `web-vitals` dependency and reports **LCP, CLS, INP, and TTFB** with `metric`, `value`, optional `rating`, and `metric_id`. If the module fails to load, only `debug: true` logs a warning. |
238
+
239
+ Example:
240
+
241
+ ```ts
242
+ Mark.init({
243
+ key: 'pk_xxxxx',
244
+ require_consent: 'auto',
245
+ autocapture: {
246
+ pageview: true,
247
+ click: true,
248
+ // or: click: { selector: '[data-analytics]' },
249
+ form_submit: true,
250
+ outbound_link: true,
251
+ scroll_depth: true,
252
+ web_vitals: true,
253
+ },
254
+ });
255
+ ```
256
+
257
+ ## Flagging internal traffic
258
+
259
+ Use the `is_internal` marker to keep QA sessions, staff testing, or automated smoke tests out of customer-facing reports without dropping them on the client. The SDK supports it in two complementary ways:
260
+
261
+ **1. Per-event field.** Any `track` / `conversion` call can carry `is_internal: true`:
262
+
263
+ ```ts
264
+ Mark.track('checkout_started', { value: 1299, is_internal: true });
265
+ ```
266
+
267
+ **2. Persistent visitor flag.** Set it once and every subsequent event is stamped automatically:
268
+
269
+ ```ts
270
+ Mark.setInternal(true); // stamps is_internal: true on all future events
271
+ Mark.setInternal(false); // stops stamping
272
+ Mark.getInternal(); // boolean
273
+ ```
274
+
275
+ The persistent flag is stored in the SDK's browser storage so it survives reloads. It is cleared automatically by `Mark.reset()` and by `Mark.setConsent('denied')`.
276
+
277
+ **Design note.** The SDK intentionally does *not* read a magic URL parameter or write to a well-known `localStorage` key on its own — that would let anyone opt themselves out of your analytics just by visiting a URL, and it would bake a specific policy into every integration. Instead, your app decides when to flip the flag. A common pattern is a URL query parameter for staff onboarding:
278
+
279
+ ```ts
280
+ // After Mark.init(...)
281
+ const params = new URLSearchParams(window.location.search);
282
+ if (params.get('onelence_internal') === '1') Mark.setInternal(true);
283
+ if (params.get('onelence_internal') === '0') Mark.setInternal(false);
284
+ ```
285
+
286
+ Other reasonable triggers: an authenticated admin/staff role, an internal IP range detected server-side, a feature flag, or an explicit toggle in your own settings UI.
287
+
288
+ **Server-side** the same API is available on `NodeMark`:
289
+
290
+ ```ts
291
+ const mark = createNodeMark({ key: process.env.MARK_SECRET_KEY! });
292
+ mark.setInternal(true);
293
+ mark.track('backoffice_action'); // is_internal: true
294
+ ```
295
+
296
+ `is_internal` is a first-class field on the backend: events are still ingested (so you can audit and debug integrations), but excluded from customer-facing reports by default.
297
+
226
298
  ## Support
227
299
 
228
300
  Need help? Reach out through your account team or file a ticket via the OneLence dashboard. Please include the SDK version, runtime (browser or Node), and any reproduction steps so we can assist quickly.
@@ -1,4 +1,4 @@
1
- const S = "https://ingest.onelence.com";
1
+ const I = "https://ingest.onelence.com";
2
2
  class h extends Error {
3
3
  status;
4
4
  retryAfterMs;
@@ -6,7 +6,7 @@ class h extends Error {
6
6
  super(t), this.name = "TransportError", this.status = e.status, this.retryAfterMs = e.retryAfterMs;
7
7
  }
8
8
  }
9
- function I(c) {
9
+ function S(c) {
10
10
  return !(typeof c != "number" || c < 400 || c >= 500 || c === 408 || c === 429);
11
11
  }
12
12
  function E(c) {
@@ -22,10 +22,10 @@ function E(c) {
22
22
  return s > 0 ? s : 0;
23
23
  }
24
24
  }
25
- const x = 5, q = 300, T = 15e3, k = 2880 * 60 * 1e3;
26
- class D {
25
+ const x = 5, q = 300, T = 15e3, D = 2880 * 60 * 1e3;
26
+ class C {
27
27
  constructor(t, e = {}) {
28
- this.transport = t, this.maxAttempts = e.maxAttempts ?? x, this.baseBackoffMs = e.baseBackoffMs ?? q, this.maxBackoffMs = e.maxBackoffMs ?? T, this.maxItemAgeMs = e.maxItemAgeMs ?? k, this.debug = e.debug ?? !1, this.loadPersisted = e.loadPersisted, this.savePersisted = e.savePersisted, this.onError = e.onError;
28
+ this.transport = t, this.maxAttempts = e.maxAttempts ?? x, this.baseBackoffMs = e.baseBackoffMs ?? q, this.maxBackoffMs = e.maxBackoffMs ?? T, this.maxItemAgeMs = e.maxItemAgeMs ?? D, this.debug = e.debug ?? !1, this.loadPersisted = e.loadPersisted, this.savePersisted = e.savePersisted, this.onError = e.onError;
29
29
  const i = this.loadPersisted?.() ?? [];
30
30
  if (i.length > 0) {
31
31
  const s = Date.now();
@@ -118,7 +118,7 @@ class D {
118
118
  } catch (i) {
119
119
  this.failed += 1, this.onError?.(i, e.data);
120
120
  const s = i instanceof h ? i.status : void 0;
121
- if (I(s)) {
121
+ if (S(s)) {
122
122
  this.queue.shift(), this.dropped += 1, this.persist(), this.debug && console.error("[Mark] Dropping event after non-retriable status", s, e.path);
123
123
  continue;
124
124
  }
@@ -147,7 +147,7 @@ class D {
147
147
  }
148
148
  }
149
149
  }
150
- const C = /* @__PURE__ */ new Set(["event_name", "user_id", "consent_state", "source", "is_conversion"]), U = /* @__PURE__ */ new Set([
150
+ const U = /* @__PURE__ */ new Set(["event_name", "user_id", "consent_state", "source", "is_conversion"]), k = /* @__PURE__ */ new Set([
151
151
  "user_id",
152
152
  "visitor_id",
153
153
  "click_id",
@@ -159,10 +159,10 @@ const C = /* @__PURE__ */ new Set(["event_name", "user_id", "consent_state", "so
159
159
  class L {
160
160
  constructor(t, e) {
161
161
  this.deps = e, this.validateConfig(t), this.config = {
162
- endpoint: t.endpoint ?? S,
162
+ endpoint: t.endpoint ?? I,
163
163
  ...t,
164
164
  include_page_context: t.include_page_context ?? !0
165
- }, this.consentRequirement = t.require_consent ?? !1, this.siteId = t.site_id, this.siteHost = t.site_host, this.sessionTimeoutMs = t.session_timeout_ms ?? 1800 * 1e3, this.queue = new D(this.deps.transport, {
165
+ }, this.consentRequirement = t.require_consent ?? !1, this.siteId = t.site_id, this.siteHost = t.site_host, this.sessionTimeoutMs = t.session_timeout_ms ?? 1800 * 1e3, this.queue = new C(this.deps.transport, {
166
166
  debug: this.config.debug,
167
167
  loadPersisted: () => this.deps.storage.getOutbox?.() ?? [],
168
168
  savePersisted: (i) => this.deps.storage.setOutbox?.(i),
@@ -214,7 +214,7 @@ class L {
214
214
  };
215
215
  i && (a.is_conversion = !0);
216
216
  const l = r.site_id ?? this.siteId, f = r.site_host ?? this.siteHost;
217
- l && (a.site_id = l), f && (a.site_host = f), this.config.include_page_context && typeof window < "u" && (this.applyPageContext(a), !f && a.site && (a.site_host = a.site));
217
+ l && (a.site_id = l), f && (a.site_host = f), this.config.include_page_context && typeof window < "u" && (this.applyPageContext(a), !f && a.site && (a.site_host = a.site)), this.applyInternalFlag(a, r.is_internal);
218
218
  const d = this.config.before_send ? this.config.before_send(a) : a;
219
219
  return d ? (this.ensureSession(), this.config.batching?.enabled && !i && !s?.preferBeacon ? (this.enqueueBatch(d), !0) : (this.queue.enqueue("/event", { ...d, __prefer_beacon: s?.preferBeacon === !0 }), !0)) : !0;
220
220
  }
@@ -234,7 +234,7 @@ class L {
234
234
  ...this.sanitizeIdentifyTraits(e),
235
235
  ...this.getIdentityFields()
236
236
  };
237
- this.siteId && (i.site_id = this.siteId), this.siteHost && (i.site_host = this.siteHost);
237
+ this.siteId && (i.site_id = this.siteId), this.siteHost && (i.site_host = this.siteHost), this.applyInternalFlag(i);
238
238
  const s = this.config.before_send ? this.config.before_send(i) : i;
239
239
  s && this.queue.enqueue("/identify", s);
240
240
  }
@@ -253,14 +253,14 @@ class L {
253
253
  }
254
254
  setConsent(t) {
255
255
  const e = this.deps.storage.getConsentStatus();
256
- this.deps.storage.setConsentStatus(t), t === "denied" ? (this.deps.storage.clearAttribution?.(), this.deps.storage.clearCookieVisitorId?.()) : t === "granted" && e === "denied" && this.config.rotate_visitor_on_consent_change && this.deps.storage.rotateVisitorId?.();
256
+ this.deps.storage.setConsentStatus(t), t === "denied" ? (this.deps.storage.clearAttribution?.(), this.deps.storage.clearCookieVisitorId?.(), this.deps.storage.setInternal?.(!1)) : t === "granted" && e === "denied" && this.config.rotate_visitor_on_consent_change && this.deps.storage.rotateVisitorId?.();
257
257
  const i = {
258
258
  visitor_id: this.deps.storage.getVisitorId(),
259
259
  consent_state: t,
260
260
  source: "sdk",
261
261
  message_id: this.createMessageId()
262
262
  };
263
- this.siteId && (i.site_id = this.siteId), this.siteHost && (i.site_host = this.siteHost);
263
+ this.siteId && (i.site_id = this.siteId), this.siteHost && (i.site_host = this.siteHost), this.applyInternalFlag(i);
264
264
  const s = this.config.before_send ? this.config.before_send(i) : i;
265
265
  s && this.queue.enqueue("/consent", s);
266
266
  }
@@ -272,15 +272,51 @@ class L {
272
272
  query_params: void 0,
273
273
  session_id: void 0,
274
274
  session_started_at: void 0,
275
- last_activity_at: void 0
275
+ last_activity_at: void 0,
276
+ is_internal: void 0
276
277
  }), this.deps.storage.rotateVisitorId?.();
277
278
  }
279
+ /**
280
+ * Marks or unmarks the current visitor as internal traffic. While set, every
281
+ * subsequent event (track/identify/conversion/consent) is stamped with
282
+ * `is_internal: true`, so the backend can exclude it from customer-facing
283
+ * reports by default.
284
+ *
285
+ * The flag is persisted via the storage adapter (browser: localStorage) so
286
+ * it survives reloads, and is cleared by `reset()` and by
287
+ * `setConsent('denied')`.
288
+ */
289
+ setInternal(t) {
290
+ this.deps.storage.setInternal?.(!!t);
291
+ }
292
+ /**
293
+ * Returns the currently persisted internal-traffic flag, if any.
294
+ */
295
+ getInternal() {
296
+ return !!this.deps.storage.getInternal?.();
297
+ }
278
298
  flush() {
279
299
  return this.flushBatch(), this.queue.flush();
280
300
  }
281
301
  getStats() {
282
302
  return this.queue.getStats();
283
303
  }
304
+ /**
305
+ * Stamps `is_internal: true` on the payload when either:
306
+ * - the persistent visitor flag is set (via setInternal), or
307
+ * - the caller passed `is_internal: true` on this specific event.
308
+ *
309
+ * Explicit `is_internal: false` on a single event wins over the visitor flag
310
+ * so individual calls can opt out.
311
+ */
312
+ applyInternalFlag(t, e) {
313
+ if (e === !1) {
314
+ delete t.is_internal;
315
+ return;
316
+ }
317
+ const i = this.deps.storage.getInternal?.() === !0;
318
+ e === !0 || i ? t.is_internal = !0 : delete t.is_internal;
319
+ }
284
320
  getIdentityFields(t) {
285
321
  const e = t?.visitor_id ?? this.deps.storage.getVisitorId(), i = t?.user_id ?? this.deps.storage.getUserId?.(), s = t?.click_id ?? this.deps.storage.getLastClickId(), r = t?.campaign_id ?? this.deps.storage.getCampaignId(), o = t?.session_id ?? this.deps.storage.getSessionId?.(), a = this.deps.storage.getQueryParams() ?? {}, l = t?.query ?? {}, f = { ...a, ...l }, d = {};
286
322
  return e && (d.visitor_id = e), i && (d.user_id = i), s && (d.click_id = s), r && (d.campaign_id = r), o && (d.session_id = o), Object.keys(f).length > 0 && (d.query = f), d;
@@ -296,13 +332,13 @@ class L {
296
332
  sanitizeTrackData(t) {
297
333
  const e = {};
298
334
  for (const [i, s] of Object.entries(t))
299
- C.has(i) || (e[i] = s);
335
+ U.has(i) || (e[i] = s);
300
336
  return e;
301
337
  }
302
338
  sanitizeIdentifyTraits(t) {
303
339
  const e = {};
304
340
  for (const [i, s] of Object.entries(t))
305
- U.has(i) || (e[i] = s);
341
+ k.has(i) || (e[i] = s);
306
342
  return e;
307
343
  }
308
344
  validateConfig(t) {
@@ -426,7 +462,7 @@ class P {
426
462
  endpoint;
427
463
  pending = /* @__PURE__ */ new Set();
428
464
  constructor(t) {
429
- this.config = t, this.endpoint = t.endpoint ?? S;
465
+ this.config = t, this.endpoint = t.endpoint ?? I;
430
466
  }
431
467
  async send(t, e, i) {
432
468
  const s = this.sendInternal(t, e, i);
@@ -456,7 +492,7 @@ class P {
456
492
  throw this.config.debug && console.error("[Mark] Global fetch is not available in this runtime."), new h("[Mark] Global fetch is not available in this runtime.");
457
493
  const l = this.config.request_timeout_ms ?? 1e4, f = new AbortController();
458
494
  let d = !1;
459
- const p = setTimeout(() => {
495
+ const g = setTimeout(() => {
460
496
  d = !0, f.abort();
461
497
  }, l);
462
498
  try {
@@ -468,16 +504,16 @@ class P {
468
504
  signal: f.signal
469
505
  });
470
506
  if (!u.ok) {
471
- const g = await this.readErrorSnippet(u), y = E(u.headers.get("Retry-After"));
507
+ const p = await this.readErrorSnippet(u), w = E(u.headers.get("Retry-After"));
472
508
  throw this.config.debug && console.error("[Mark] Request rejected", {
473
509
  url: s,
474
510
  status: u.status,
475
511
  statusText: u.statusText,
476
- body: g,
477
- retryAfterMs: y
512
+ body: p,
513
+ retryAfterMs: w
478
514
  }), new h(
479
- `[Mark] Request rejected with status ${u.status}: ${g}`,
480
- { status: u.status, retryAfterMs: y }
515
+ `[Mark] Request rejected with status ${u.status}: ${p}`,
516
+ { status: u.status, retryAfterMs: w }
481
517
  );
482
518
  }
483
519
  } catch (u) {
@@ -485,10 +521,10 @@ class P {
485
521
  throw u;
486
522
  if (d)
487
523
  throw new h(`[Mark] Request timed out after ${l}ms`, { status: 408 });
488
- const g = u instanceof Error ? u.message : String(u);
489
- throw new h(`[Mark] Network error: ${g}`);
524
+ const p = u instanceof Error ? u.message : String(u);
525
+ throw new h(`[Mark] Network error: ${p}`);
490
526
  } finally {
491
- clearTimeout(p);
527
+ clearTimeout(g);
492
528
  }
493
529
  }
494
530
  joinUrl(t, e) {
@@ -503,7 +539,7 @@ class P {
503
539
  }
504
540
  }
505
541
  }
506
- const R = "crelora_mark_data", B = "crelora_mark_outbox", w = "crelora_mark_vid";
542
+ const R = "crelora_mark_data", B = "crelora_mark_outbox", y = "crelora_mark_vid";
507
543
  class F {
508
544
  data;
509
545
  storageKey;
@@ -546,6 +582,12 @@ class F {
546
582
  getLastActivityAt() {
547
583
  return this.data.last_activity_at;
548
584
  }
585
+ getInternal() {
586
+ return this.data.is_internal;
587
+ }
588
+ setInternal(t) {
589
+ t ? this.update({ is_internal: !0 }) : this.update({ is_internal: void 0 });
590
+ }
549
591
  update(t) {
550
592
  this.data = { ...this.data, ...t }, this.save();
551
593
  }
@@ -560,7 +602,7 @@ class F {
560
602
  });
561
603
  }
562
604
  clearCookieVisitorId() {
563
- this.setCookie(w, "", -1);
605
+ this.setCookie(y, "", -1);
564
606
  }
565
607
  rotateVisitorId() {
566
608
  this.update({ visitor_id: this.generateUUID() });
@@ -592,7 +634,7 @@ class F {
592
634
  return JSON.parse(e);
593
635
  } catch {
594
636
  }
595
- const t = this.getCookie(w);
637
+ const t = this.getCookie(y);
596
638
  return t ? { visitor_id: t } : {};
597
639
  }
598
640
  save() {
@@ -601,7 +643,7 @@ class F {
601
643
  localStorage.setItem(this.storageKey, JSON.stringify(this.data));
602
644
  } catch {
603
645
  }
604
- this.data.visitor_id && this.isCookieEnabled() && (this.setCookie(w, this.data.visitor_id, 365), this.options.bridge?.url && this.bridgeReady && this.postBridgeMessage({
646
+ this.data.visitor_id && this.isCookieEnabled() && (this.setCookie(y, this.data.visitor_id, 365), this.options.bridge?.url && this.bridgeReady && this.postBridgeMessage({
605
647
  type: "MARK_SYNC_UPDATE",
606
648
  visitorId: this.data.visitor_id
607
649
  }));
@@ -756,14 +798,14 @@ function j(c, t) {
756
798
  const d = m(l);
757
799
  if (!d)
758
800
  continue;
759
- const p = K[d] ?? d;
760
- if (i.has(d) || i.has(p) || a && !a.has(d))
801
+ const g = K[d] ?? d;
802
+ if (i.has(d) || i.has(g) || a && !a.has(d))
761
803
  continue;
762
804
  const u = f.trim();
763
805
  if (u) {
764
- if (!(p in e) && Object.keys(e).length >= s)
806
+ if (!(g in e) && Object.keys(e).length >= s)
765
807
  break;
766
- e[p] = u.slice(0, r);
808
+ e[g] = u.slice(0, r);
767
809
  }
768
810
  }
769
811
  return e;
@@ -877,6 +919,30 @@ class n {
877
919
  static flush() {
878
920
  return n.client ? n.client.flush() : Promise.resolve();
879
921
  }
922
+ /**
923
+ * Flags (or un-flags) the current visitor as internal traffic. Persists
924
+ * across reloads via the SDK's browser storage. Cleared by `reset()` and by
925
+ * `setConsent('denied')`.
926
+ *
927
+ * The policy for deciding *when* to call this (URL query parameter, auth
928
+ * role, IP, feature flag, etc.) is intentionally left to the host app.
929
+ *
930
+ * @example
931
+ * // URL-based opt-in: call once during app bootstrap, after Mark.init()
932
+ * const params = new URLSearchParams(window.location.search);
933
+ * if (params.get('onelence_internal') === '1') Mark.setInternal(true);
934
+ * if (params.get('onelence_internal') === '0') Mark.setInternal(false);
935
+ */
936
+ static setInternal(t) {
937
+ if (!n.client) {
938
+ n.config?.debug && console.warn("[Mark] Not initialized. Call init() first.");
939
+ return;
940
+ }
941
+ n.client.setInternal(t);
942
+ }
943
+ static getInternal() {
944
+ return n.client?.getInternal() ?? !1;
945
+ }
880
946
  static reset() {
881
947
  n.client?.reset(), n.pendingAttribution = {}, n.lastPageviewHref = null;
882
948
  }