@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.js CHANGED
@@ -4,7 +4,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4
4
 
5
5
  // src/utils.ts
6
6
  var SDK_NAME = "sauron.javascript";
7
- var SDK_VERSION = "1.0.0";
7
+ var SDK_VERSION = "1.4.0";
8
8
  function getGlobal() {
9
9
  return globalThis;
10
10
  }
@@ -87,6 +87,7 @@ function makeLogger(debug) {
87
87
  // src/identity.ts
88
88
  var DEVICE_ID_KEY = "sauron.device_id";
89
89
  var SESSION_ID_KEY = "sauron.session_id";
90
+ var ANON_ID_KEY = "sauron.anon_id";
90
91
  function webStorage(name) {
91
92
  try {
92
93
  const s = globalThis[name];
@@ -119,6 +120,7 @@ function persistentId(cached, storage, key) {
119
120
  }
120
121
  var deviceId = null;
121
122
  var sessionId = null;
123
+ var anonymousId = null;
122
124
  function getDeviceId() {
123
125
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
124
126
  return deviceId;
@@ -127,6 +129,40 @@ function getSessionId() {
127
129
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
128
130
  return sessionId;
129
131
  }
132
+ function getAnonymousId() {
133
+ if (anonymousId) return anonymousId;
134
+ const storage = webStorage("localStorage");
135
+ if (storage) {
136
+ try {
137
+ const existing = storage.getItem(ANON_ID_KEY);
138
+ if (existing) {
139
+ anonymousId = existing;
140
+ return anonymousId;
141
+ }
142
+ } catch {
143
+ }
144
+ }
145
+ const fresh = `anon_${uuidv4()}`;
146
+ if (storage) {
147
+ try {
148
+ storage.setItem(ANON_ID_KEY, fresh);
149
+ } catch {
150
+ }
151
+ }
152
+ anonymousId = fresh;
153
+ return anonymousId;
154
+ }
155
+ function resetAnonymousId() {
156
+ anonymousId = null;
157
+ const storage = webStorage("localStorage");
158
+ if (storage) {
159
+ try {
160
+ storage.removeItem(ANON_ID_KEY);
161
+ } catch {
162
+ }
163
+ }
164
+ return getAnonymousId();
165
+ }
130
166
 
131
167
  // src/context.ts
132
168
  function getNavigator() {
@@ -624,12 +660,7 @@ function captureMessage(message, level = "info", hint) {
624
660
  type: "error",
625
661
  timestamp: nowIso(),
626
662
  level,
627
- exception: {
628
- type: null,
629
- value: message,
630
- mechanism: { type: "message", handled: true },
631
- stacktrace: []
632
- },
663
+ message,
633
664
  breadcrumbs,
634
665
  fingerprint: hint?.fingerprint ?? null,
635
666
  session_id: getSessionId(),
@@ -832,6 +863,30 @@ var Scope = class {
832
863
  }
833
864
  };
834
865
 
866
+ // src/workflow.ts
867
+ var WORKFLOW_NAME_MAX = 120;
868
+ var WORKFLOW_REASON_MAX = 120;
869
+ var current = null;
870
+ function getWorkflow() {
871
+ return current;
872
+ }
873
+ function setWorkflowState(workflow) {
874
+ current = workflow;
875
+ }
876
+ function resetWorkflow() {
877
+ current = null;
878
+ }
879
+ function normalizeWorkflowName(name) {
880
+ if (typeof name !== "string") return null;
881
+ const trimmed = name.trim();
882
+ if (trimmed.length === 0 || trimmed.length > WORKFLOW_NAME_MAX) return null;
883
+ return trimmed;
884
+ }
885
+ function normalizeReason(reason) {
886
+ if (typeof reason !== "string" || reason.trim().length === 0) return "user";
887
+ return reason.trim().slice(0, WORKFLOW_REASON_MAX);
888
+ }
889
+
835
890
  // src/api/product.ts
836
891
  function track(name, properties = {}, options = {}) {
837
892
  const client = getClient();
@@ -861,12 +916,12 @@ function setScreen(name) {
861
916
  function identify(id, traits = {}) {
862
917
  const client = getClient();
863
918
  if (!client) return;
864
- const anonymousId = client.getAnonymousId();
919
+ const anonymousId2 = client.getAnonymousId();
865
920
  client.getScope().setUser({ id, traits });
866
921
  const item = {
867
922
  type: "identify",
868
923
  distinct_id: id,
869
- anonymous_id: anonymousId,
924
+ anonymous_id: anonymousId2,
870
925
  traits: traits ?? {}
871
926
  };
872
927
  client.captureItem(item);
@@ -902,6 +957,86 @@ function trackTransaction(input) {
902
957
  const item = buildTransactionItem(input, client.getDistinctId(), getSessionId());
903
958
  client.captureItem(item);
904
959
  }
960
+ var NOOP_LOGGER = makeLogger(false);
961
+ function emitWorkflowClose(active, eventName, reason, logger) {
962
+ const properties = {
963
+ workflow_id: active.workflowId,
964
+ workflow_name: active.name,
965
+ duration_ms: Math.max(0, Date.now() - Date.parse(active.startedAt))
966
+ };
967
+ if (eventName === "$workflow_cancel") {
968
+ properties.reason = normalizeReason(reason);
969
+ }
970
+ try {
971
+ track(eventName, properties);
972
+ } catch (err) {
973
+ logger.warn(`${eventName}: failed to emit the lifecycle event`, err);
974
+ } finally {
975
+ resetWorkflow();
976
+ }
977
+ }
978
+ function startWorkflow(name, options) {
979
+ let logger = NOOP_LOGGER;
980
+ try {
981
+ const client = getClient();
982
+ if (!client || !client.isEnabled()) return { status: "disabled" };
983
+ logger = makeLogger(client.options.debug);
984
+ const normalized = normalizeWorkflowName(name);
985
+ if (!normalized) {
986
+ logger.warn("startWorkflow: invalid name", name);
987
+ return { status: "invalid_name" };
988
+ }
989
+ const active = getWorkflow();
990
+ if (active && !options?.force) {
991
+ logger.warn(
992
+ `startWorkflow("${normalized}"): "${active.name}" is already active; pass { force: true } to replace it`
993
+ );
994
+ return { status: "already_active" };
995
+ }
996
+ const workflow = {
997
+ workflowId: uuidv4(),
998
+ name: normalized,
999
+ startedAt: nowIso()
1000
+ };
1001
+ if (active) emitWorkflowClose(active, "$workflow_cancel", "superseded", logger);
1002
+ setWorkflowState(workflow);
1003
+ try {
1004
+ track("$workflow_start", { workflow_id: workflow.workflowId, workflow_name: workflow.name });
1005
+ } catch (err) {
1006
+ logger.warn("startWorkflow: failed to emit $workflow_start", err);
1007
+ }
1008
+ return { status: "ok", workflowId: workflow.workflowId };
1009
+ } catch (err) {
1010
+ logger.warn("startWorkflow failed", err);
1011
+ return { status: "disabled" };
1012
+ }
1013
+ }
1014
+ function closeWorkflow(eventName, name, reason) {
1015
+ let logger = NOOP_LOGGER;
1016
+ try {
1017
+ const client = getClient();
1018
+ if (!client || !client.isEnabled()) return { status: "disabled" };
1019
+ logger = makeLogger(client.options.debug);
1020
+ const active = getWorkflow();
1021
+ if (!active) return { status: "not_active" };
1022
+ if (name !== void 0 && normalizeWorkflowName(name) !== active.name) {
1023
+ logger.warn(`${eventName}: "${name}" does not match active workflow "${active.name}"`);
1024
+ return { status: "name_mismatch" };
1025
+ }
1026
+ const workflowId = active.workflowId;
1027
+ emitWorkflowClose(active, eventName, reason, logger);
1028
+ return { status: "ok", workflowId };
1029
+ } catch (err) {
1030
+ logger.warn(`${eventName} failed`, err);
1031
+ return { status: "disabled" };
1032
+ }
1033
+ }
1034
+ function endWorkflow(name) {
1035
+ return closeWorkflow("$workflow_end", name);
1036
+ }
1037
+ function cancelWorkflow(name, options) {
1038
+ return closeWorkflow("$workflow_cancel", name, options?.reason);
1039
+ }
905
1040
 
906
1041
  // src/integrations/performance.ts
907
1042
  var PERF_FETCH = "__sauron_perf_fetch__";
@@ -1291,6 +1426,21 @@ var OfflineQueue = class {
1291
1426
  if (entries.length) this.write([]);
1292
1427
  return entries;
1293
1428
  }
1429
+ /**
1430
+ * Put drained payloads BACK at the head, keeping their relative order.
1431
+ *
1432
+ * The counterpart to {@link drain}: a drain empties the store immediately, so
1433
+ * whatever the caller could not deliver only exists in its local array and is
1434
+ * lost the moment the caller returns. Re-parking at the head (rather than via
1435
+ * {@link enqueue}) keeps the queue oldest-first, which is what the byte-cap
1436
+ * eviction policy assumes — the oldest entry must stay the first one evicted.
1437
+ */
1438
+ requeueFront(payloads) {
1439
+ if (!this.storage || payloads.length === 0) return;
1440
+ const entries = [...payloads, ...this.read()];
1441
+ this.evict(entries);
1442
+ this.write(entries);
1443
+ }
1294
1444
  /** Non-destructive read of the current entries, oldest first. */
1295
1445
  peek() {
1296
1446
  return this.read();
@@ -1354,6 +1504,9 @@ var Transport = class {
1354
1504
  __publicField(this, "pending", []);
1355
1505
  __publicField(this, "timer", null);
1356
1506
  __publicField(this, "onlineHandler", null);
1507
+ /** Permanent auto-disable latch, flipped by this transport itself the moment
1508
+ * it classifies a response as 401/403 — it is the source of truth for
1509
+ * {@link isEnabled}, not merely a mirror of something the client decided. */
1357
1510
  __publicField(this, "disabled", false);
1358
1511
  this.dsn = config.dsn;
1359
1512
  this.makeEnvelope = config.makeEnvelope;
@@ -1401,6 +1554,10 @@ var Transport = class {
1401
1554
  this.pending = [];
1402
1555
  this.stop();
1403
1556
  }
1557
+ /** Whether the transport still accepts items (false once auth-disabled by a 401/403). */
1558
+ isEnabled() {
1559
+ return !this.disabled;
1560
+ }
1404
1561
  /** Queue an item for the next batch; flush eagerly once the batch is full. */
1405
1562
  send(item) {
1406
1563
  if (this.disabled) return;
@@ -1443,6 +1600,7 @@ var Transport = class {
1443
1600
  return;
1444
1601
  case "disable":
1445
1602
  this.logger.warn("server rejected credentials; disabling client");
1603
+ this.disable();
1446
1604
  this.onDisable();
1447
1605
  return;
1448
1606
  case "split": {
@@ -1468,11 +1626,21 @@ var Transport = class {
1468
1626
  }
1469
1627
  }
1470
1628
  }
1471
- /** Re-attempt any envelopes that were parked while offline. */
1629
+ /**
1630
+ * Re-attempt any envelopes that were parked while offline.
1631
+ *
1632
+ * `drain()` empties `localStorage` in one shot, so from here on the ONLY copy
1633
+ * of the backlog is the local `payloads` array. Every early return therefore
1634
+ * has to re-park the whole untried remainder, not just the payload that
1635
+ * failed: this used to re-park the failing one and return, which silently
1636
+ * deleted every payload behind it — the exact reconnect-then-one-500 case the
1637
+ * queue exists for.
1638
+ */
1472
1639
  async drainOfflineQueue() {
1473
1640
  if (this.disabled || !this.offline.available) return;
1474
1641
  const payloads = this.offline.drain();
1475
- for (const json of payloads) {
1642
+ for (let i = 0; i < payloads.length; i++) {
1643
+ const json = payloads[i];
1476
1644
  let outcome;
1477
1645
  try {
1478
1646
  outcome = await this.post(json);
@@ -1480,12 +1648,14 @@ var Transport = class {
1480
1648
  outcome = { action: "retry_backoff" };
1481
1649
  }
1482
1650
  if (outcome.action === "disable") {
1651
+ this.logger.warn("server rejected credentials while draining; disabling client");
1652
+ this.disable();
1483
1653
  this.onDisable();
1484
- this.offline.enqueue(json);
1654
+ this.offline.requeueFront(payloads.slice(i));
1485
1655
  return;
1486
1656
  }
1487
1657
  if (outcome.action === "retry_after" || outcome.action === "retry_backoff") {
1488
- this.offline.enqueue(json);
1658
+ this.offline.requeueFront(payloads.slice(i));
1489
1659
  return;
1490
1660
  }
1491
1661
  }
@@ -1595,8 +1765,17 @@ var SauronClient = class {
1595
1765
  __publicField(this, "nativeFetch");
1596
1766
  __publicField(this, "enabled", true);
1597
1767
  __publicField(this, "installed", false);
1598
- __publicField(this, "anonymousId", null);
1599
1768
  __publicField(this, "beaconCleanup", null);
1769
+ /**
1770
+ * Whether the anonymous id has actually been USED as a `distinct_id` in this
1771
+ * browser session.
1772
+ *
1773
+ * A persisted id that has never been observed anonymously must not create a
1774
+ * permanent `identities` alias row on the server: aliasing is a durable
1775
+ * server-side binding of this browser profile to a named user, and an
1776
+ * identify() on a first-ever page load has no anonymous history to link.
1777
+ */
1778
+ __publicField(this, "anonUsed", false);
1600
1779
  this.options = options;
1601
1780
  this.dsn = parseDsn(options.dsn);
1602
1781
  this.logger = makeLogger(options.debug);
@@ -1645,22 +1824,43 @@ var SauronClient = class {
1645
1824
  getScope() {
1646
1825
  return this.scope;
1647
1826
  }
1827
+ /**
1828
+ * False once this client was explicitly disabled/closed, OR once the
1829
+ * transport has auto-disabled itself on a 401/403 (revoked/invalid DSN
1830
+ * key) — computed from the transport's own state on every call, not a
1831
+ * separately mirrored flag, so a propagation regression there cannot leave
1832
+ * this predicate stale. `this.transport` always exists once a client
1833
+ * exists (it is constructed synchronously in the constructor); the
1834
+ * "nothing installed yet" case is instead handled one layer up, by every
1835
+ * module-level API (`startWorkflow`, `track`, ...) treating `getClient() ===
1836
+ * null` as the no-op/disabled case before it ever reaches here.
1837
+ */
1648
1838
  isEnabled() {
1649
- return this.enabled;
1839
+ return this.enabled && this.transport.isEnabled();
1650
1840
  }
1651
1841
  /** The current distinct id: the user id when identified, else an anon id. */
1652
1842
  getDistinctId() {
1653
1843
  const user = this.scope.getUser();
1654
1844
  if (user.id) return user.id;
1655
- return this.ensureAnonymousId();
1845
+ this.anonUsed = true;
1846
+ return getAnonymousId();
1656
1847
  }
1657
- /** The anonymous id, or null if one was never needed. */
1848
+ /** The anonymous id, or null when it was never actually used as an identity. */
1658
1849
  getAnonymousId() {
1659
- return this.anonymousId;
1850
+ return this.anonUsed ? getAnonymousId() : null;
1660
1851
  }
1661
- ensureAnonymousId() {
1662
- if (!this.anonymousId) this.anonymousId = `anon_${uuidv4()}`;
1663
- return this.anonymousId;
1852
+ /**
1853
+ * Forget the current person: clear the scope user and mint a fresh anonymous
1854
+ * id.
1855
+ *
1856
+ * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1857
+ * browser reuses the persisted anon id, and a later identify() aliases their
1858
+ * activity to the previous account server-side, permanently.
1859
+ */
1860
+ reset() {
1861
+ this.scope.setUser(null);
1862
+ resetAnonymousId();
1863
+ this.anonUsed = false;
1664
1864
  }
1665
1865
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1666
1866
  makeEnvelope(items) {
@@ -1668,7 +1868,6 @@ var SauronClient = class {
1668
1868
  dsn: this.dsn.raw,
1669
1869
  sdk: { name: SDK_NAME, version: SDK_VERSION },
1670
1870
  sent_at: nowIso(),
1671
- environment: this.options.environment,
1672
1871
  release: this.options.release
1673
1872
  };
1674
1873
  const context = buildContext(this.options.release, this.scope.getUser());
@@ -1714,6 +1913,39 @@ var SauronClient = class {
1714
1913
  item.user = this.scope.getUser();
1715
1914
  }
1716
1915
  }
1916
+ /**
1917
+ * Stamp the active workflow (if any) onto a signal item.
1918
+ *
1919
+ * Done HERE — the single choke point every capture path funnels through —
1920
+ * rather than at each item-construction site, so a capture path added later
1921
+ * is stamped by construction instead of by remembering to. The keys are
1922
+ * ASSIGNED ONLY when a workflow is active: an item with no workflow keeps
1923
+ * them absent entirely (not present-as-`undefined`), which is what makes
1924
+ * `JSON.stringify` omit them and keeps the no-workflow wire bytes identical
1925
+ * to pre-1.3.0.
1926
+ *
1927
+ * Only error/event/transaction carry `workflow_id`/`workflow_name` columns
1928
+ * server-side — identify and breadcrumb_batch items are deliberately left
1929
+ * alone. An item that already carries an explicit `workflow_id` is left
1930
+ * untouched, matching how `enrichErrorItem` defers to caller-set fields.
1931
+ */
1932
+ stampWorkflow(item) {
1933
+ if (item.type !== "error" && item.type !== "event" && item.type !== "transaction") return;
1934
+ const hasId = item.workflow_id !== void 0;
1935
+ const hasName = item.workflow_name !== void 0;
1936
+ if (hasId || hasName) {
1937
+ if (hasId !== hasName) {
1938
+ this.logger.warn(
1939
+ "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."
1940
+ );
1941
+ }
1942
+ return;
1943
+ }
1944
+ const workflow = getWorkflow();
1945
+ if (!workflow) return;
1946
+ item.workflow_id = workflow.workflowId;
1947
+ item.workflow_name = workflow.name;
1948
+ }
1717
1949
  /**
1718
1950
  * Run an item through sampling (errors only) and `beforeSend`, then hand it to
1719
1951
  * the transport. Returns silently when dropped.
@@ -1727,6 +1959,7 @@ var SauronClient = class {
1727
1959
  }
1728
1960
  this.enrichErrorItem(item, hint);
1729
1961
  }
1962
+ this.stampWorkflow(item);
1730
1963
  let processed = item;
1731
1964
  if (this.options.beforeSend) {
1732
1965
  try {
@@ -1763,6 +1996,7 @@ var SauronClient = class {
1763
1996
  }
1764
1997
  onNavigation(null);
1765
1998
  resetScreen();
1999
+ resetWorkflow();
1766
2000
  unpatchAll();
1767
2001
  setDsnHost(null);
1768
2002
  this.installed = false;
@@ -1785,7 +2019,6 @@ function resolveOptions(options) {
1785
2019
  const t = options.transport ?? {};
1786
2020
  return {
1787
2021
  dsn: options.dsn,
1788
- environment: options.environment ?? "production",
1789
2022
  release: options.release ?? null,
1790
2023
  sampleRate: clamp(options.sampleRate ?? 1, 0, 1),
1791
2024
  maxBreadcrumbs: options.maxBreadcrumbs ?? 50,
@@ -1870,12 +2103,31 @@ function setScreen2(name) {
1870
2103
  function getScreen2() {
1871
2104
  return getScreen();
1872
2105
  }
2106
+ function startWorkflow2(name, options) {
2107
+ return startWorkflow(name, options);
2108
+ }
2109
+ function endWorkflow2(name) {
2110
+ return endWorkflow(name);
2111
+ }
2112
+ function cancelWorkflow2(name, options) {
2113
+ return cancelWorkflow(name, options);
2114
+ }
2115
+ function getWorkflow2() {
2116
+ return getWorkflow();
2117
+ }
1873
2118
  function addBreadcrumb2(breadcrumb, hint) {
1874
2119
  addBreadcrumb(breadcrumb, hint);
1875
2120
  }
1876
2121
  function setUser(user) {
2122
+ if (user === null) {
2123
+ getClient()?.reset();
2124
+ return;
2125
+ }
1877
2126
  getClient()?.getScope().setUser(user);
1878
2127
  }
2128
+ function reset() {
2129
+ getClient()?.reset();
2130
+ }
1879
2131
  function setTag(key, value) {
1880
2132
  getClient()?.getScope().setTag(key, value);
1881
2133
  }
@@ -1905,18 +2157,23 @@ var Sauron = {
1905
2157
  identify: identify2,
1906
2158
  addBreadcrumb: addBreadcrumb2,
1907
2159
  setUser,
2160
+ reset,
1908
2161
  setTag,
1909
2162
  setTags,
1910
2163
  setContext,
1911
2164
  setExtra,
1912
2165
  setScreen: setScreen2,
1913
2166
  getScreen: getScreen2,
2167
+ startWorkflow: startWorkflow2,
2168
+ endWorkflow: endWorkflow2,
2169
+ cancelWorkflow: cancelWorkflow2,
2170
+ getWorkflow: getWorkflow2,
1914
2171
  flush,
1915
2172
  close,
1916
2173
  getClient
1917
2174
  };
1918
2175
  var index_default = Sauron;
1919
2176
 
1920
- export { DsnError, SDK_NAME, SDK_VERSION, Sauron, SauronClient, addBreadcrumb2 as addBreadcrumb, buildEnvelope, captureException2 as captureException, captureMessage2 as captureMessage, close, index_default as default, flush, getClient, getScreen2 as getScreen, identify2 as identify, init2 as init, isInAppFrame, parseDsn, parseError, parseStackString, setContext, setExtra, setScreen2 as setScreen, setTag, setTags, setUser, track2 as track, trackTransaction2 as trackTransaction };
2177
+ export { DsnError, SDK_NAME, SDK_VERSION, Sauron, SauronClient, addBreadcrumb2 as addBreadcrumb, buildEnvelope, cancelWorkflow2 as cancelWorkflow, captureException2 as captureException, captureMessage2 as captureMessage, close, index_default as default, endWorkflow2 as endWorkflow, flush, getClient, getScreen2 as getScreen, getWorkflow2 as getWorkflow, identify2 as identify, init2 as init, isInAppFrame, parseDsn, parseError, parseStackString, reset, setContext, setExtra, setScreen2 as setScreen, setTag, setTags, setUser, startWorkflow2 as startWorkflow, track2 as track, trackTransaction2 as trackTransaction };
1921
2178
  //# sourceMappingURL=index.js.map
1922
2179
  //# sourceMappingURL=index.js.map