@edraj/sauron-browser 1.0.0 → 1.4.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/dist/index.cjs CHANGED
@@ -8,7 +8,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
8
8
 
9
9
  // src/utils.ts
10
10
  var SDK_NAME = "sauron.javascript";
11
- var SDK_VERSION = "1.0.0";
11
+ var SDK_VERSION = "1.4.0";
12
12
  function getGlobal() {
13
13
  return globalThis;
14
14
  }
@@ -91,6 +91,7 @@ function makeLogger(debug) {
91
91
  // src/identity.ts
92
92
  var DEVICE_ID_KEY = "sauron.device_id";
93
93
  var SESSION_ID_KEY = "sauron.session_id";
94
+ var ANON_ID_KEY = "sauron.anon_id";
94
95
  function webStorage(name) {
95
96
  try {
96
97
  const s = globalThis[name];
@@ -123,6 +124,7 @@ function persistentId(cached, storage, key) {
123
124
  }
124
125
  var deviceId = null;
125
126
  var sessionId = null;
127
+ var anonymousId = null;
126
128
  function getDeviceId() {
127
129
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
128
130
  return deviceId;
@@ -131,6 +133,40 @@ function getSessionId() {
131
133
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
132
134
  return sessionId;
133
135
  }
136
+ function getAnonymousId() {
137
+ if (anonymousId) return anonymousId;
138
+ const storage = webStorage("localStorage");
139
+ if (storage) {
140
+ try {
141
+ const existing = storage.getItem(ANON_ID_KEY);
142
+ if (existing) {
143
+ anonymousId = existing;
144
+ return anonymousId;
145
+ }
146
+ } catch {
147
+ }
148
+ }
149
+ const fresh = `anon_${uuidv4()}`;
150
+ if (storage) {
151
+ try {
152
+ storage.setItem(ANON_ID_KEY, fresh);
153
+ } catch {
154
+ }
155
+ }
156
+ anonymousId = fresh;
157
+ return anonymousId;
158
+ }
159
+ function resetAnonymousId() {
160
+ anonymousId = null;
161
+ const storage = webStorage("localStorage");
162
+ if (storage) {
163
+ try {
164
+ storage.removeItem(ANON_ID_KEY);
165
+ } catch {
166
+ }
167
+ }
168
+ return getAnonymousId();
169
+ }
134
170
 
135
171
  // src/context.ts
136
172
  function getNavigator() {
@@ -628,12 +664,7 @@ function captureMessage(message, level = "info", hint) {
628
664
  type: "error",
629
665
  timestamp: nowIso(),
630
666
  level,
631
- exception: {
632
- type: null,
633
- value: message,
634
- mechanism: { type: "message", handled: true },
635
- stacktrace: []
636
- },
667
+ message,
637
668
  breadcrumbs,
638
669
  fingerprint: hint?.fingerprint ?? null,
639
670
  session_id: getSessionId(),
@@ -836,6 +867,30 @@ var Scope = class {
836
867
  }
837
868
  };
838
869
 
870
+ // src/workflow.ts
871
+ var WORKFLOW_NAME_MAX = 120;
872
+ var WORKFLOW_REASON_MAX = 120;
873
+ var current = null;
874
+ function getWorkflow() {
875
+ return current;
876
+ }
877
+ function setWorkflowState(workflow) {
878
+ current = workflow;
879
+ }
880
+ function resetWorkflow() {
881
+ current = null;
882
+ }
883
+ function normalizeWorkflowName(name) {
884
+ if (typeof name !== "string") return null;
885
+ const trimmed = name.trim();
886
+ if (trimmed.length === 0 || trimmed.length > WORKFLOW_NAME_MAX) return null;
887
+ return trimmed;
888
+ }
889
+ function normalizeReason(reason) {
890
+ if (typeof reason !== "string" || reason.trim().length === 0) return "user";
891
+ return reason.trim().slice(0, WORKFLOW_REASON_MAX);
892
+ }
893
+
839
894
  // src/api/product.ts
840
895
  function track(name, properties = {}, options = {}) {
841
896
  const client = getClient();
@@ -865,12 +920,12 @@ function setScreen(name) {
865
920
  function identify(id, traits = {}) {
866
921
  const client = getClient();
867
922
  if (!client) return;
868
- const anonymousId = client.getAnonymousId();
923
+ const anonymousId2 = client.getAnonymousId();
869
924
  client.getScope().setUser({ id, traits });
870
925
  const item = {
871
926
  type: "identify",
872
927
  distinct_id: id,
873
- anonymous_id: anonymousId,
928
+ anonymous_id: anonymousId2,
874
929
  traits: traits ?? {}
875
930
  };
876
931
  client.captureItem(item);
@@ -906,6 +961,86 @@ function trackTransaction(input) {
906
961
  const item = buildTransactionItem(input, client.getDistinctId(), getSessionId());
907
962
  client.captureItem(item);
908
963
  }
964
+ var NOOP_LOGGER = makeLogger(false);
965
+ function emitWorkflowClose(active, eventName, reason, logger) {
966
+ const properties = {
967
+ workflow_id: active.workflowId,
968
+ workflow_name: active.name,
969
+ duration_ms: Math.max(0, Date.now() - Date.parse(active.startedAt))
970
+ };
971
+ if (eventName === "$workflow_cancel") {
972
+ properties.reason = normalizeReason(reason);
973
+ }
974
+ try {
975
+ track(eventName, properties);
976
+ } catch (err) {
977
+ logger.warn(`${eventName}: failed to emit the lifecycle event`, err);
978
+ } finally {
979
+ resetWorkflow();
980
+ }
981
+ }
982
+ function startWorkflow(name, options) {
983
+ let logger = NOOP_LOGGER;
984
+ try {
985
+ const client = getClient();
986
+ if (!client || !client.isEnabled()) return { status: "disabled" };
987
+ logger = makeLogger(client.options.debug);
988
+ const normalized = normalizeWorkflowName(name);
989
+ if (!normalized) {
990
+ logger.warn("startWorkflow: invalid name", name);
991
+ return { status: "invalid_name" };
992
+ }
993
+ const active = getWorkflow();
994
+ if (active && !options?.force) {
995
+ logger.warn(
996
+ `startWorkflow("${normalized}"): "${active.name}" is already active; pass { force: true } to replace it`
997
+ );
998
+ return { status: "already_active" };
999
+ }
1000
+ const workflow = {
1001
+ workflowId: uuidv4(),
1002
+ name: normalized,
1003
+ startedAt: nowIso()
1004
+ };
1005
+ if (active) emitWorkflowClose(active, "$workflow_cancel", "superseded", logger);
1006
+ setWorkflowState(workflow);
1007
+ try {
1008
+ track("$workflow_start", { workflow_id: workflow.workflowId, workflow_name: workflow.name });
1009
+ } catch (err) {
1010
+ logger.warn("startWorkflow: failed to emit $workflow_start", err);
1011
+ }
1012
+ return { status: "ok", workflowId: workflow.workflowId };
1013
+ } catch (err) {
1014
+ logger.warn("startWorkflow failed", err);
1015
+ return { status: "disabled" };
1016
+ }
1017
+ }
1018
+ function closeWorkflow(eventName, name, reason) {
1019
+ let logger = NOOP_LOGGER;
1020
+ try {
1021
+ const client = getClient();
1022
+ if (!client || !client.isEnabled()) return { status: "disabled" };
1023
+ logger = makeLogger(client.options.debug);
1024
+ const active = getWorkflow();
1025
+ if (!active) return { status: "not_active" };
1026
+ if (name !== void 0 && normalizeWorkflowName(name) !== active.name) {
1027
+ logger.warn(`${eventName}: "${name}" does not match active workflow "${active.name}"`);
1028
+ return { status: "name_mismatch" };
1029
+ }
1030
+ const workflowId = active.workflowId;
1031
+ emitWorkflowClose(active, eventName, reason, logger);
1032
+ return { status: "ok", workflowId };
1033
+ } catch (err) {
1034
+ logger.warn(`${eventName} failed`, err);
1035
+ return { status: "disabled" };
1036
+ }
1037
+ }
1038
+ function endWorkflow(name) {
1039
+ return closeWorkflow("$workflow_end", name);
1040
+ }
1041
+ function cancelWorkflow(name, options) {
1042
+ return closeWorkflow("$workflow_cancel", name, options?.reason);
1043
+ }
909
1044
 
910
1045
  // src/integrations/performance.ts
911
1046
  var PERF_FETCH = "__sauron_perf_fetch__";
@@ -1295,6 +1430,21 @@ var OfflineQueue = class {
1295
1430
  if (entries.length) this.write([]);
1296
1431
  return entries;
1297
1432
  }
1433
+ /**
1434
+ * Put drained payloads BACK at the head, keeping their relative order.
1435
+ *
1436
+ * The counterpart to {@link drain}: a drain empties the store immediately, so
1437
+ * whatever the caller could not deliver only exists in its local array and is
1438
+ * lost the moment the caller returns. Re-parking at the head (rather than via
1439
+ * {@link enqueue}) keeps the queue oldest-first, which is what the byte-cap
1440
+ * eviction policy assumes — the oldest entry must stay the first one evicted.
1441
+ */
1442
+ requeueFront(payloads) {
1443
+ if (!this.storage || payloads.length === 0) return;
1444
+ const entries = [...payloads, ...this.read()];
1445
+ this.evict(entries);
1446
+ this.write(entries);
1447
+ }
1298
1448
  /** Non-destructive read of the current entries, oldest first. */
1299
1449
  peek() {
1300
1450
  return this.read();
@@ -1358,6 +1508,9 @@ var Transport = class {
1358
1508
  __publicField(this, "pending", []);
1359
1509
  __publicField(this, "timer", null);
1360
1510
  __publicField(this, "onlineHandler", null);
1511
+ /** Permanent auto-disable latch, flipped by this transport itself the moment
1512
+ * it classifies a response as 401/403 — it is the source of truth for
1513
+ * {@link isEnabled}, not merely a mirror of something the client decided. */
1361
1514
  __publicField(this, "disabled", false);
1362
1515
  this.dsn = config.dsn;
1363
1516
  this.makeEnvelope = config.makeEnvelope;
@@ -1405,6 +1558,10 @@ var Transport = class {
1405
1558
  this.pending = [];
1406
1559
  this.stop();
1407
1560
  }
1561
+ /** Whether the transport still accepts items (false once auth-disabled by a 401/403). */
1562
+ isEnabled() {
1563
+ return !this.disabled;
1564
+ }
1408
1565
  /** Queue an item for the next batch; flush eagerly once the batch is full. */
1409
1566
  send(item) {
1410
1567
  if (this.disabled) return;
@@ -1447,6 +1604,7 @@ var Transport = class {
1447
1604
  return;
1448
1605
  case "disable":
1449
1606
  this.logger.warn("server rejected credentials; disabling client");
1607
+ this.disable();
1450
1608
  this.onDisable();
1451
1609
  return;
1452
1610
  case "split": {
@@ -1472,11 +1630,21 @@ var Transport = class {
1472
1630
  }
1473
1631
  }
1474
1632
  }
1475
- /** Re-attempt any envelopes that were parked while offline. */
1633
+ /**
1634
+ * Re-attempt any envelopes that were parked while offline.
1635
+ *
1636
+ * `drain()` empties `localStorage` in one shot, so from here on the ONLY copy
1637
+ * of the backlog is the local `payloads` array. Every early return therefore
1638
+ * has to re-park the whole untried remainder, not just the payload that
1639
+ * failed: this used to re-park the failing one and return, which silently
1640
+ * deleted every payload behind it — the exact reconnect-then-one-500 case the
1641
+ * queue exists for.
1642
+ */
1476
1643
  async drainOfflineQueue() {
1477
1644
  if (this.disabled || !this.offline.available) return;
1478
1645
  const payloads = this.offline.drain();
1479
- for (const json of payloads) {
1646
+ for (let i = 0; i < payloads.length; i++) {
1647
+ const json = payloads[i];
1480
1648
  let outcome;
1481
1649
  try {
1482
1650
  outcome = await this.post(json);
@@ -1484,12 +1652,14 @@ var Transport = class {
1484
1652
  outcome = { action: "retry_backoff" };
1485
1653
  }
1486
1654
  if (outcome.action === "disable") {
1655
+ this.logger.warn("server rejected credentials while draining; disabling client");
1656
+ this.disable();
1487
1657
  this.onDisable();
1488
- this.offline.enqueue(json);
1658
+ this.offline.requeueFront(payloads.slice(i));
1489
1659
  return;
1490
1660
  }
1491
1661
  if (outcome.action === "retry_after" || outcome.action === "retry_backoff") {
1492
- this.offline.enqueue(json);
1662
+ this.offline.requeueFront(payloads.slice(i));
1493
1663
  return;
1494
1664
  }
1495
1665
  }
@@ -1599,8 +1769,17 @@ var SauronClient = class {
1599
1769
  __publicField(this, "nativeFetch");
1600
1770
  __publicField(this, "enabled", true);
1601
1771
  __publicField(this, "installed", false);
1602
- __publicField(this, "anonymousId", null);
1603
1772
  __publicField(this, "beaconCleanup", null);
1773
+ /**
1774
+ * Whether the anonymous id has actually been USED as a `distinct_id` in this
1775
+ * browser session.
1776
+ *
1777
+ * A persisted id that has never been observed anonymously must not create a
1778
+ * permanent `identities` alias row on the server: aliasing is a durable
1779
+ * server-side binding of this browser profile to a named user, and an
1780
+ * identify() on a first-ever page load has no anonymous history to link.
1781
+ */
1782
+ __publicField(this, "anonUsed", false);
1604
1783
  this.options = options;
1605
1784
  this.dsn = parseDsn(options.dsn);
1606
1785
  this.logger = makeLogger(options.debug);
@@ -1649,22 +1828,43 @@ var SauronClient = class {
1649
1828
  getScope() {
1650
1829
  return this.scope;
1651
1830
  }
1831
+ /**
1832
+ * False once this client was explicitly disabled/closed, OR once the
1833
+ * transport has auto-disabled itself on a 401/403 (revoked/invalid DSN
1834
+ * key) — computed from the transport's own state on every call, not a
1835
+ * separately mirrored flag, so a propagation regression there cannot leave
1836
+ * this predicate stale. `this.transport` always exists once a client
1837
+ * exists (it is constructed synchronously in the constructor); the
1838
+ * "nothing installed yet" case is instead handled one layer up, by every
1839
+ * module-level API (`startWorkflow`, `track`, ...) treating `getClient() ===
1840
+ * null` as the no-op/disabled case before it ever reaches here.
1841
+ */
1652
1842
  isEnabled() {
1653
- return this.enabled;
1843
+ return this.enabled && this.transport.isEnabled();
1654
1844
  }
1655
1845
  /** The current distinct id: the user id when identified, else an anon id. */
1656
1846
  getDistinctId() {
1657
1847
  const user = this.scope.getUser();
1658
1848
  if (user.id) return user.id;
1659
- return this.ensureAnonymousId();
1849
+ this.anonUsed = true;
1850
+ return getAnonymousId();
1660
1851
  }
1661
- /** The anonymous id, or null if one was never needed. */
1852
+ /** The anonymous id, or null when it was never actually used as an identity. */
1662
1853
  getAnonymousId() {
1663
- return this.anonymousId;
1854
+ return this.anonUsed ? getAnonymousId() : null;
1664
1855
  }
1665
- ensureAnonymousId() {
1666
- if (!this.anonymousId) this.anonymousId = `anon_${uuidv4()}`;
1667
- return this.anonymousId;
1856
+ /**
1857
+ * Forget the current person: clear the scope user and mint a fresh anonymous
1858
+ * id.
1859
+ *
1860
+ * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1861
+ * browser reuses the persisted anon id, and a later identify() aliases their
1862
+ * activity to the previous account server-side, permanently.
1863
+ */
1864
+ reset() {
1865
+ this.scope.setUser(null);
1866
+ resetAnonymousId();
1867
+ this.anonUsed = false;
1668
1868
  }
1669
1869
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1670
1870
  makeEnvelope(items) {
@@ -1672,7 +1872,6 @@ var SauronClient = class {
1672
1872
  dsn: this.dsn.raw,
1673
1873
  sdk: { name: SDK_NAME, version: SDK_VERSION },
1674
1874
  sent_at: nowIso(),
1675
- environment: this.options.environment,
1676
1875
  release: this.options.release
1677
1876
  };
1678
1877
  const context = buildContext(this.options.release, this.scope.getUser());
@@ -1718,6 +1917,39 @@ var SauronClient = class {
1718
1917
  item.user = this.scope.getUser();
1719
1918
  }
1720
1919
  }
1920
+ /**
1921
+ * Stamp the active workflow (if any) onto a signal item.
1922
+ *
1923
+ * Done HERE — the single choke point every capture path funnels through —
1924
+ * rather than at each item-construction site, so a capture path added later
1925
+ * is stamped by construction instead of by remembering to. The keys are
1926
+ * ASSIGNED ONLY when a workflow is active: an item with no workflow keeps
1927
+ * them absent entirely (not present-as-`undefined`), which is what makes
1928
+ * `JSON.stringify` omit them and keeps the no-workflow wire bytes identical
1929
+ * to pre-1.3.0.
1930
+ *
1931
+ * Only error/event/transaction carry `workflow_id`/`workflow_name` columns
1932
+ * server-side — identify and breadcrumb_batch items are deliberately left
1933
+ * alone. An item that already carries an explicit `workflow_id` is left
1934
+ * untouched, matching how `enrichErrorItem` defers to caller-set fields.
1935
+ */
1936
+ stampWorkflow(item) {
1937
+ if (item.type !== "error" && item.type !== "event" && item.type !== "transaction") return;
1938
+ const hasId = item.workflow_id !== void 0;
1939
+ const hasName = item.workflow_name !== void 0;
1940
+ if (hasId || hasName) {
1941
+ if (hasId !== hasName) {
1942
+ this.logger.warn(
1943
+ "item sets only one of workflow_id/workflow_name; the server treats them as a pair and will drop this attribution. Set both, or neither."
1944
+ );
1945
+ }
1946
+ return;
1947
+ }
1948
+ const workflow = getWorkflow();
1949
+ if (!workflow) return;
1950
+ item.workflow_id = workflow.workflowId;
1951
+ item.workflow_name = workflow.name;
1952
+ }
1721
1953
  /**
1722
1954
  * Run an item through sampling (errors only) and `beforeSend`, then hand it to
1723
1955
  * the transport. Returns silently when dropped.
@@ -1731,6 +1963,7 @@ var SauronClient = class {
1731
1963
  }
1732
1964
  this.enrichErrorItem(item, hint);
1733
1965
  }
1966
+ this.stampWorkflow(item);
1734
1967
  let processed = item;
1735
1968
  if (this.options.beforeSend) {
1736
1969
  try {
@@ -1767,6 +2000,7 @@ var SauronClient = class {
1767
2000
  }
1768
2001
  onNavigation(null);
1769
2002
  resetScreen();
2003
+ resetWorkflow();
1770
2004
  unpatchAll();
1771
2005
  setDsnHost(null);
1772
2006
  this.installed = false;
@@ -1789,7 +2023,6 @@ function resolveOptions(options) {
1789
2023
  const t = options.transport ?? {};
1790
2024
  return {
1791
2025
  dsn: options.dsn,
1792
- environment: options.environment ?? "production",
1793
2026
  release: options.release ?? null,
1794
2027
  sampleRate: clamp(options.sampleRate ?? 1, 0, 1),
1795
2028
  maxBreadcrumbs: options.maxBreadcrumbs ?? 50,
@@ -1874,12 +2107,31 @@ function setScreen2(name) {
1874
2107
  function getScreen2() {
1875
2108
  return getScreen();
1876
2109
  }
2110
+ function startWorkflow2(name, options) {
2111
+ return startWorkflow(name, options);
2112
+ }
2113
+ function endWorkflow2(name) {
2114
+ return endWorkflow(name);
2115
+ }
2116
+ function cancelWorkflow2(name, options) {
2117
+ return cancelWorkflow(name, options);
2118
+ }
2119
+ function getWorkflow2() {
2120
+ return getWorkflow();
2121
+ }
1877
2122
  function addBreadcrumb2(breadcrumb, hint) {
1878
2123
  addBreadcrumb(breadcrumb, hint);
1879
2124
  }
1880
2125
  function setUser(user) {
2126
+ if (user === null) {
2127
+ getClient()?.reset();
2128
+ return;
2129
+ }
1881
2130
  getClient()?.getScope().setUser(user);
1882
2131
  }
2132
+ function reset() {
2133
+ getClient()?.reset();
2134
+ }
1883
2135
  function setTag(key, value) {
1884
2136
  getClient()?.getScope().setTag(key, value);
1885
2137
  }
@@ -1909,12 +2161,17 @@ var Sauron = {
1909
2161
  identify: identify2,
1910
2162
  addBreadcrumb: addBreadcrumb2,
1911
2163
  setUser,
2164
+ reset,
1912
2165
  setTag,
1913
2166
  setTags,
1914
2167
  setContext,
1915
2168
  setExtra,
1916
2169
  setScreen: setScreen2,
1917
2170
  getScreen: getScreen2,
2171
+ startWorkflow: startWorkflow2,
2172
+ endWorkflow: endWorkflow2,
2173
+ cancelWorkflow: cancelWorkflow2,
2174
+ getWorkflow: getWorkflow2,
1918
2175
  flush,
1919
2176
  close,
1920
2177
  getClient
@@ -1928,25 +2185,30 @@ exports.Sauron = Sauron;
1928
2185
  exports.SauronClient = SauronClient;
1929
2186
  exports.addBreadcrumb = addBreadcrumb2;
1930
2187
  exports.buildEnvelope = buildEnvelope;
2188
+ exports.cancelWorkflow = cancelWorkflow2;
1931
2189
  exports.captureException = captureException2;
1932
2190
  exports.captureMessage = captureMessage2;
1933
2191
  exports.close = close;
1934
2192
  exports.default = index_default;
2193
+ exports.endWorkflow = endWorkflow2;
1935
2194
  exports.flush = flush;
1936
2195
  exports.getClient = getClient;
1937
2196
  exports.getScreen = getScreen2;
2197
+ exports.getWorkflow = getWorkflow2;
1938
2198
  exports.identify = identify2;
1939
2199
  exports.init = init2;
1940
2200
  exports.isInAppFrame = isInAppFrame;
1941
2201
  exports.parseDsn = parseDsn;
1942
2202
  exports.parseError = parseError;
1943
2203
  exports.parseStackString = parseStackString;
2204
+ exports.reset = reset;
1944
2205
  exports.setContext = setContext;
1945
2206
  exports.setExtra = setExtra;
1946
2207
  exports.setScreen = setScreen2;
1947
2208
  exports.setTag = setTag;
1948
2209
  exports.setTags = setTags;
1949
2210
  exports.setUser = setUser;
2211
+ exports.startWorkflow = startWorkflow2;
1950
2212
  exports.track = track2;
1951
2213
  exports.trackTransaction = trackTransaction2;
1952
2214
  //# sourceMappingURL=index.cjs.map