@edraj/sauron-browser 1.4.0 → 1.5.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.d.cts CHANGED
@@ -144,6 +144,19 @@ interface TransactionItem {
144
144
  workflow_id?: string;
145
145
  workflow_name?: string;
146
146
  timestamp: string;
147
+ /**
148
+ * Developer-supplied flat string tags for THIS transaction.
149
+ *
150
+ * Unlike {@link EventItem} and the error item, this is NOT merged with the
151
+ * scope — see {@link TransactionInput.tags}. Omitted entirely when empty.
152
+ */
153
+ tags?: Record<string, string>;
154
+ /**
155
+ * Developer-supplied freeform JSON for THIS transaction — the request body,
156
+ * the response body, a retry count. Capped and replaced with a truncation
157
+ * marker past {@link MAX_TRANSACTION_EXTRA_BYTES}. Omitted when empty.
158
+ */
159
+ extra?: Record<string, unknown>;
147
160
  }
148
161
  /** An identity association (PostHog-style `identify`). */
149
162
  interface IdentifyItem {
@@ -330,6 +343,29 @@ interface TransactionInput {
330
343
  httpMethod?: string | null;
331
344
  httpStatus?: number | null;
332
345
  url?: string | null;
346
+ /**
347
+ * Flat string tags for this transaction.
348
+ *
349
+ * **Per-call only — the scope is NOT merged in**, which is the one place
350
+ * transactions differ from `track()` and `captureException()`. Those two
351
+ * merge `setTag`/`setExtra` defaults; a transaction carries only what its own
352
+ * call site attached. Transactions are the highest-volume signal (one per
353
+ * navigation and per HTTP call), so inheriting a global blob would write it
354
+ * onto every row.
355
+ */
356
+ tags?: Record<string, string>;
357
+ /**
358
+ * Freeform JSON for this transaction — the request body, the response body,
359
+ * an order id, a retry count.
360
+ *
361
+ * Per-call only, for the reason on {@link TransactionInput.tags}. Serialized
362
+ * and capped at {@link MAX_TRANSACTION_EXTRA_BYTES}; past that the whole map
363
+ * is replaced with `{ _truncated: true, _bytes: N }` so one large body cannot
364
+ * take a batched envelope over the ingest limit and drop every span in it.
365
+ *
366
+ * Nothing here is scrubbed. `beforeSend` is the redaction seam.
367
+ */
368
+ extra?: Record<string, unknown>;
333
369
  }
334
370
 
335
371
  /**
@@ -374,6 +410,24 @@ declare class Scope {
374
410
  readonly extra: Record<string, unknown>;
375
411
  constructor(maxBreadcrumbs?: number);
376
412
  setMaxBreadcrumbs(max: number): void;
413
+ /**
414
+ * Replace the scope user.
415
+ *
416
+ * `id` is coerced with `String()` for the same reason `SauronClient.
417
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
418
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
419
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
420
+ * coercion entirely, and the consequence is not cosmetic: the scope user
421
+ * lands in the envelope context, where the server's `distinct_id` is a
422
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
423
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
424
+ * is non-retryable, so every event in it is dropped for good.
425
+ *
426
+ * Rebuilding the whole object (rather than merging into the existing one)
427
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
428
+ * `email` and `traits` come from the input alone, so setting a new user
429
+ * never inherits the previous person's contact details.
430
+ */
377
431
  setUser(user: UserInput): void;
378
432
  /** The user context for an envelope. Never null — defaults to an empty user. */
379
433
  getUser(): UserContext;
@@ -438,14 +492,40 @@ declare class SauronClient {
438
492
  /** The anonymous id, or null when it was never actually used as an identity. */
439
493
  getAnonymousId(): string | null;
440
494
  /**
441
- * Forget the current person: clear the scope user and mint a fresh anonymous
442
- * id.
495
+ * Forget the current person: clear the scope user, mint a fresh anonymous
496
+ * id, forget the last identified user, and rotate the session id.
443
497
  *
444
498
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
445
499
  * browser reuses the persisted anon id, and a later identify() aliases their
446
- * activity to the previous account server-side, permanently.
500
+ * activity to the previous account server-side, permanently. Rotating the
501
+ * session id matters too: the server's `bump_session` is last-write-wins on
502
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
503
+ * serially represent two different people and record only whichever wrote
504
+ * last.
447
505
  */
448
506
  reset(): void;
507
+ /**
508
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
509
+ *
510
+ * When a DIFFERENT user identifies than last time, the current anon id
511
+ * belongs to the previous person and is already burned server-side, so it is
512
+ * replaced before anything else happens and `null` is sent instead of a
513
+ * cross-user alias. This cannot repair events already sent under the burned
514
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
515
+ * window instead of every future one.
516
+ *
517
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
518
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
519
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
520
+ * against a value that already round-tripped through storage would treat
521
+ * the SAME numeric user as a switch on every single call. The comparison
522
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
523
+ * app that (unusually) identifies with `''` still has a later, different id
524
+ * correctly detected as a real switch — a falsy string is not "no identity
525
+ * yet". `last`/the persisted value are digests, not the raw id — see
526
+ * `hashIdentity`.
527
+ */
528
+ prepareIdentify(id: string): string | null;
449
529
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
450
530
  makeEnvelope(items: EnvelopeItem[]): Envelope;
451
531
  /** Add a breadcrumb, running it through `beforeBreadcrumb` first. */
@@ -517,7 +597,34 @@ declare function parseError(err: unknown): Frame[];
517
597
  /** Small dependency-free helpers shared across the SDK. */
518
598
  /** SDK identity, embedded in every envelope header. */
519
599
  declare const SDK_NAME = "sauron.javascript";
520
- declare const SDK_VERSION = "1.4.0";
600
+ declare const SDK_VERSION = "1.5.0";
601
+ /**
602
+ * Largest serialized `extra` a single transaction may carry, in bytes.
603
+ *
604
+ * Transactions are the highest-volume signal and they ship in BATCHED
605
+ * envelopes, so one oversized payload does not fail alone — ingest rejects the
606
+ * whole envelope past `INGEST_MAX_BODY_BYTES` (1 MiB by default) and every
607
+ * unrelated span batched with it is lost. Since the motivating use of
608
+ * transaction `extra` is request and response bodies, that is not a remote
609
+ * hazard.
610
+ */
611
+ declare const MAX_TRANSACTION_EXTRA_BYTES: number;
612
+ /**
613
+ * Cap a transaction's `extra`, substituting a marker when it is too large.
614
+ *
615
+ * Replaces the WHOLE map rather than trimming keys: a half-written JSON value
616
+ * is worse than an honest marker, and per-key trimming would make the result
617
+ * depend on key iteration order, which differs across the five SDKs. The
618
+ * marker is deliberately readable on the dashboard — `_truncated` says data
619
+ * was dropped rather than silently serving a short object that looks complete.
620
+ *
621
+ * Returns the input unchanged when it fits. A value that cannot be serialized
622
+ * at all (a cycle, a BigInt) is replaced by the same marker with `_bytes: -1`,
623
+ * because the alternative is throwing from inside `trackTransaction` — and an
624
+ * SDK that crashes the app it is measuring is worse than one that drops a
625
+ * payload.
626
+ */
627
+ declare function capTransactionExtra(extra: Record<string, unknown>, maxBytes?: number): Record<string, unknown>;
521
628
 
522
629
  /**
523
630
  * `@edraj/sauron-browser` — public API surface.
@@ -540,7 +647,16 @@ declare function captureException(err: unknown, hint?: Hint): void;
540
647
  declare function captureMessage(message: string, level?: Level, hint?: Hint): void;
541
648
  /** Record a product-analytics event, optionally with per-call tags/contexts/extra. */
542
649
  declare function track(name: string, properties?: Record<string, unknown>, options?: TrackOptions): void;
543
- /** Associate the session with a known user. */
650
+ /**
651
+ * Associate the session with a known user.
652
+ *
653
+ * The `anonymous_id` sent with the identify item is the current anon id — but
654
+ * only when it was actually used as a `distinct_id` this session, and never
655
+ * when it belongs to a different person than the last one who identified on
656
+ * this device. In that case a fresh anon id is minted first and `null` is
657
+ * sent instead, since the old one is already permanently bound to the
658
+ * previous person server-side (see `reset()`).
659
+ */
544
660
  declare function identify(id: string, traits?: Record<string, unknown>): void;
545
661
  /** Record a performance transaction (navigation, http, screen load, ...). */
546
662
  declare function trackTransaction(input: TransactionInput): void;
@@ -570,14 +686,15 @@ declare function addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: Hint): void;
570
686
  /**
571
687
  * Set (or clear, with `null`) the current user.
572
688
  *
573
- * `setUser(null)` is a logout, so it also rotates the anonymous id — otherwise
689
+ * `setUser(null)` is a logout, so it also calls `reset()` for you — otherwise
574
690
  * the next anonymous visitor on this browser inherits the previous person's
575
691
  * durable id and a later identify() aliases them together server-side.
576
692
  */
577
693
  declare function setUser(user: UserInput): void;
578
694
  /**
579
- * Forget the current person: clears the scope user and mints a fresh anonymous
580
- * id. Call this on logout.
695
+ * Forget the current person: clears the scope user, mints a fresh anonymous
696
+ * id, forgets the last identified user, and rotates the session id so a
697
+ * single session can never span two different people. Call this on logout.
581
698
  */
582
699
  declare function reset(): void;
583
700
  /** Set a single scope tag (lifted onto later errors/events). */
@@ -619,4 +736,4 @@ declare const Sauron: {
619
736
  getClient: typeof getClient;
620
737
  };
621
738
 
622
- export { type ActiveWorkflow, type AppContext, type BeforeBreadcrumb, type BeforeSend, type Breadcrumb, type BreadcrumbBatchItem, type BreadcrumbInput, type CaptureOptions, type Context, type DeviceContext, type Dsn, DsnError, type Envelope, type EnvelopeHeader, type EnvelopeItem, type ErrorItem, type EventItem, type ExceptionValue, type Frame, type Hint, type IdentifyItem, type InitOptions, type ItemType, type Level, type Mechanism, type OsContext, type ResolvedOptions, type RuntimeContext, SDK_NAME, SDK_VERSION, Sauron, SauronClient, type SdkInfo, type TrackOptions, type TransactionInput, type TransactionItem, type TransactionOp, type TransportOptions, type UserContext, type UserInput, type WorkflowResult, type WorkflowStatus, addBreadcrumb, buildEnvelope, cancelWorkflow, captureException, captureMessage, close, Sauron as default, endWorkflow, flush, getClient, getScreen, getWorkflow, identify, init, isInAppFrame, parseDsn, parseError, parseStackString, reset, setContext, setExtra, setScreen, setTag, setTags, setUser, startWorkflow, track, trackTransaction };
739
+ export { type ActiveWorkflow, type AppContext, type BeforeBreadcrumb, type BeforeSend, type Breadcrumb, type BreadcrumbBatchItem, type BreadcrumbInput, type CaptureOptions, type Context, type DeviceContext, type Dsn, DsnError, type Envelope, type EnvelopeHeader, type EnvelopeItem, type ErrorItem, type EventItem, type ExceptionValue, type Frame, type Hint, type IdentifyItem, type InitOptions, type ItemType, type Level, MAX_TRANSACTION_EXTRA_BYTES, type Mechanism, type OsContext, type ResolvedOptions, type RuntimeContext, SDK_NAME, SDK_VERSION, Sauron, SauronClient, type SdkInfo, type TrackOptions, type TransactionInput, type TransactionItem, type TransactionOp, type TransportOptions, type UserContext, type UserInput, type WorkflowResult, type WorkflowStatus, addBreadcrumb, buildEnvelope, cancelWorkflow, capTransactionExtra, captureException, captureMessage, close, Sauron as default, endWorkflow, flush, getClient, getScreen, getWorkflow, identify, init, isInAppFrame, parseDsn, parseError, parseStackString, reset, setContext, setExtra, setScreen, setTag, setTags, setUser, startWorkflow, track, trackTransaction };
package/dist/index.d.ts CHANGED
@@ -144,6 +144,19 @@ interface TransactionItem {
144
144
  workflow_id?: string;
145
145
  workflow_name?: string;
146
146
  timestamp: string;
147
+ /**
148
+ * Developer-supplied flat string tags for THIS transaction.
149
+ *
150
+ * Unlike {@link EventItem} and the error item, this is NOT merged with the
151
+ * scope — see {@link TransactionInput.tags}. Omitted entirely when empty.
152
+ */
153
+ tags?: Record<string, string>;
154
+ /**
155
+ * Developer-supplied freeform JSON for THIS transaction — the request body,
156
+ * the response body, a retry count. Capped and replaced with a truncation
157
+ * marker past {@link MAX_TRANSACTION_EXTRA_BYTES}. Omitted when empty.
158
+ */
159
+ extra?: Record<string, unknown>;
147
160
  }
148
161
  /** An identity association (PostHog-style `identify`). */
149
162
  interface IdentifyItem {
@@ -330,6 +343,29 @@ interface TransactionInput {
330
343
  httpMethod?: string | null;
331
344
  httpStatus?: number | null;
332
345
  url?: string | null;
346
+ /**
347
+ * Flat string tags for this transaction.
348
+ *
349
+ * **Per-call only — the scope is NOT merged in**, which is the one place
350
+ * transactions differ from `track()` and `captureException()`. Those two
351
+ * merge `setTag`/`setExtra` defaults; a transaction carries only what its own
352
+ * call site attached. Transactions are the highest-volume signal (one per
353
+ * navigation and per HTTP call), so inheriting a global blob would write it
354
+ * onto every row.
355
+ */
356
+ tags?: Record<string, string>;
357
+ /**
358
+ * Freeform JSON for this transaction — the request body, the response body,
359
+ * an order id, a retry count.
360
+ *
361
+ * Per-call only, for the reason on {@link TransactionInput.tags}. Serialized
362
+ * and capped at {@link MAX_TRANSACTION_EXTRA_BYTES}; past that the whole map
363
+ * is replaced with `{ _truncated: true, _bytes: N }` so one large body cannot
364
+ * take a batched envelope over the ingest limit and drop every span in it.
365
+ *
366
+ * Nothing here is scrubbed. `beforeSend` is the redaction seam.
367
+ */
368
+ extra?: Record<string, unknown>;
333
369
  }
334
370
 
335
371
  /**
@@ -374,6 +410,24 @@ declare class Scope {
374
410
  readonly extra: Record<string, unknown>;
375
411
  constructor(maxBreadcrumbs?: number);
376
412
  setMaxBreadcrumbs(max: number): void;
413
+ /**
414
+ * Replace the scope user.
415
+ *
416
+ * `id` is coerced with `String()` for the same reason `SauronClient.
417
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
418
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
419
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
420
+ * coercion entirely, and the consequence is not cosmetic: the scope user
421
+ * lands in the envelope context, where the server's `distinct_id` is a
422
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
423
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
424
+ * is non-retryable, so every event in it is dropped for good.
425
+ *
426
+ * Rebuilding the whole object (rather than merging into the existing one)
427
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
428
+ * `email` and `traits` come from the input alone, so setting a new user
429
+ * never inherits the previous person's contact details.
430
+ */
377
431
  setUser(user: UserInput): void;
378
432
  /** The user context for an envelope. Never null — defaults to an empty user. */
379
433
  getUser(): UserContext;
@@ -438,14 +492,40 @@ declare class SauronClient {
438
492
  /** The anonymous id, or null when it was never actually used as an identity. */
439
493
  getAnonymousId(): string | null;
440
494
  /**
441
- * Forget the current person: clear the scope user and mint a fresh anonymous
442
- * id.
495
+ * Forget the current person: clear the scope user, mint a fresh anonymous
496
+ * id, forget the last identified user, and rotate the session id.
443
497
  *
444
498
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
445
499
  * browser reuses the persisted anon id, and a later identify() aliases their
446
- * activity to the previous account server-side, permanently.
500
+ * activity to the previous account server-side, permanently. Rotating the
501
+ * session id matters too: the server's `bump_session` is last-write-wins on
502
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
503
+ * serially represent two different people and record only whichever wrote
504
+ * last.
447
505
  */
448
506
  reset(): void;
507
+ /**
508
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
509
+ *
510
+ * When a DIFFERENT user identifies than last time, the current anon id
511
+ * belongs to the previous person and is already burned server-side, so it is
512
+ * replaced before anything else happens and `null` is sent instead of a
513
+ * cross-user alias. This cannot repair events already sent under the burned
514
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
515
+ * window instead of every future one.
516
+ *
517
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
518
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
519
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
520
+ * against a value that already round-tripped through storage would treat
521
+ * the SAME numeric user as a switch on every single call. The comparison
522
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
523
+ * app that (unusually) identifies with `''` still has a later, different id
524
+ * correctly detected as a real switch — a falsy string is not "no identity
525
+ * yet". `last`/the persisted value are digests, not the raw id — see
526
+ * `hashIdentity`.
527
+ */
528
+ prepareIdentify(id: string): string | null;
449
529
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
450
530
  makeEnvelope(items: EnvelopeItem[]): Envelope;
451
531
  /** Add a breadcrumb, running it through `beforeBreadcrumb` first. */
@@ -517,7 +597,34 @@ declare function parseError(err: unknown): Frame[];
517
597
  /** Small dependency-free helpers shared across the SDK. */
518
598
  /** SDK identity, embedded in every envelope header. */
519
599
  declare const SDK_NAME = "sauron.javascript";
520
- declare const SDK_VERSION = "1.4.0";
600
+ declare const SDK_VERSION = "1.5.0";
601
+ /**
602
+ * Largest serialized `extra` a single transaction may carry, in bytes.
603
+ *
604
+ * Transactions are the highest-volume signal and they ship in BATCHED
605
+ * envelopes, so one oversized payload does not fail alone — ingest rejects the
606
+ * whole envelope past `INGEST_MAX_BODY_BYTES` (1 MiB by default) and every
607
+ * unrelated span batched with it is lost. Since the motivating use of
608
+ * transaction `extra` is request and response bodies, that is not a remote
609
+ * hazard.
610
+ */
611
+ declare const MAX_TRANSACTION_EXTRA_BYTES: number;
612
+ /**
613
+ * Cap a transaction's `extra`, substituting a marker when it is too large.
614
+ *
615
+ * Replaces the WHOLE map rather than trimming keys: a half-written JSON value
616
+ * is worse than an honest marker, and per-key trimming would make the result
617
+ * depend on key iteration order, which differs across the five SDKs. The
618
+ * marker is deliberately readable on the dashboard — `_truncated` says data
619
+ * was dropped rather than silently serving a short object that looks complete.
620
+ *
621
+ * Returns the input unchanged when it fits. A value that cannot be serialized
622
+ * at all (a cycle, a BigInt) is replaced by the same marker with `_bytes: -1`,
623
+ * because the alternative is throwing from inside `trackTransaction` — and an
624
+ * SDK that crashes the app it is measuring is worse than one that drops a
625
+ * payload.
626
+ */
627
+ declare function capTransactionExtra(extra: Record<string, unknown>, maxBytes?: number): Record<string, unknown>;
521
628
 
522
629
  /**
523
630
  * `@edraj/sauron-browser` — public API surface.
@@ -540,7 +647,16 @@ declare function captureException(err: unknown, hint?: Hint): void;
540
647
  declare function captureMessage(message: string, level?: Level, hint?: Hint): void;
541
648
  /** Record a product-analytics event, optionally with per-call tags/contexts/extra. */
542
649
  declare function track(name: string, properties?: Record<string, unknown>, options?: TrackOptions): void;
543
- /** Associate the session with a known user. */
650
+ /**
651
+ * Associate the session with a known user.
652
+ *
653
+ * The `anonymous_id` sent with the identify item is the current anon id — but
654
+ * only when it was actually used as a `distinct_id` this session, and never
655
+ * when it belongs to a different person than the last one who identified on
656
+ * this device. In that case a fresh anon id is minted first and `null` is
657
+ * sent instead, since the old one is already permanently bound to the
658
+ * previous person server-side (see `reset()`).
659
+ */
544
660
  declare function identify(id: string, traits?: Record<string, unknown>): void;
545
661
  /** Record a performance transaction (navigation, http, screen load, ...). */
546
662
  declare function trackTransaction(input: TransactionInput): void;
@@ -570,14 +686,15 @@ declare function addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: Hint): void;
570
686
  /**
571
687
  * Set (or clear, with `null`) the current user.
572
688
  *
573
- * `setUser(null)` is a logout, so it also rotates the anonymous id — otherwise
689
+ * `setUser(null)` is a logout, so it also calls `reset()` for you — otherwise
574
690
  * the next anonymous visitor on this browser inherits the previous person's
575
691
  * durable id and a later identify() aliases them together server-side.
576
692
  */
577
693
  declare function setUser(user: UserInput): void;
578
694
  /**
579
- * Forget the current person: clears the scope user and mints a fresh anonymous
580
- * id. Call this on logout.
695
+ * Forget the current person: clears the scope user, mints a fresh anonymous
696
+ * id, forgets the last identified user, and rotates the session id so a
697
+ * single session can never span two different people. Call this on logout.
581
698
  */
582
699
  declare function reset(): void;
583
700
  /** Set a single scope tag (lifted onto later errors/events). */
@@ -619,4 +736,4 @@ declare const Sauron: {
619
736
  getClient: typeof getClient;
620
737
  };
621
738
 
622
- export { type ActiveWorkflow, type AppContext, type BeforeBreadcrumb, type BeforeSend, type Breadcrumb, type BreadcrumbBatchItem, type BreadcrumbInput, type CaptureOptions, type Context, type DeviceContext, type Dsn, DsnError, type Envelope, type EnvelopeHeader, type EnvelopeItem, type ErrorItem, type EventItem, type ExceptionValue, type Frame, type Hint, type IdentifyItem, type InitOptions, type ItemType, type Level, type Mechanism, type OsContext, type ResolvedOptions, type RuntimeContext, SDK_NAME, SDK_VERSION, Sauron, SauronClient, type SdkInfo, type TrackOptions, type TransactionInput, type TransactionItem, type TransactionOp, type TransportOptions, type UserContext, type UserInput, type WorkflowResult, type WorkflowStatus, addBreadcrumb, buildEnvelope, cancelWorkflow, captureException, captureMessage, close, Sauron as default, endWorkflow, flush, getClient, getScreen, getWorkflow, identify, init, isInAppFrame, parseDsn, parseError, parseStackString, reset, setContext, setExtra, setScreen, setTag, setTags, setUser, startWorkflow, track, trackTransaction };
739
+ export { type ActiveWorkflow, type AppContext, type BeforeBreadcrumb, type BeforeSend, type Breadcrumb, type BreadcrumbBatchItem, type BreadcrumbInput, type CaptureOptions, type Context, type DeviceContext, type Dsn, DsnError, type Envelope, type EnvelopeHeader, type EnvelopeItem, type ErrorItem, type EventItem, type ExceptionValue, type Frame, type Hint, type IdentifyItem, type InitOptions, type ItemType, type Level, MAX_TRANSACTION_EXTRA_BYTES, type Mechanism, type OsContext, type ResolvedOptions, type RuntimeContext, SDK_NAME, SDK_VERSION, Sauron, SauronClient, type SdkInfo, type TrackOptions, type TransactionInput, type TransactionItem, type TransactionOp, type TransportOptions, type UserContext, type UserInput, type WorkflowResult, type WorkflowStatus, addBreadcrumb, buildEnvelope, cancelWorkflow, capTransactionExtra, captureException, captureMessage, close, Sauron as default, endWorkflow, flush, getClient, getScreen, getWorkflow, identify, init, isInAppFrame, parseDsn, parseError, parseStackString, reset, setContext, setExtra, setScreen, setTag, setTags, setUser, startWorkflow, track, trackTransaction };
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.4.0";
7
+ var SDK_VERSION = "1.5.0";
8
8
  function getGlobal() {
9
9
  return globalThis;
10
10
  }
@@ -83,11 +83,60 @@ function makeLogger(debug) {
83
83
  warn: (...args) => console.warn("[sauron]", ...args)
84
84
  };
85
85
  }
86
+ var MAX_TRANSACTION_EXTRA_BYTES = 16 * 1024;
87
+ function capTransactionExtra(extra, maxBytes = MAX_TRANSACTION_EXTRA_BYTES) {
88
+ let bytes;
89
+ try {
90
+ const json = JSON.stringify(extra);
91
+ if (json === void 0) return { _truncated: true, _bytes: -1 };
92
+ bytes = utf8Length(json);
93
+ } catch {
94
+ return { _truncated: true, _bytes: -1 };
95
+ }
96
+ if (bytes <= maxBytes) return extra;
97
+ return { _truncated: true, _bytes: bytes };
98
+ }
99
+ function utf8Length(s) {
100
+ let n = 0;
101
+ for (let i = 0; i < s.length; i++) {
102
+ const c = s.charCodeAt(i);
103
+ if (c < 128) n += 1;
104
+ else if (c < 2048) n += 2;
105
+ else if (c >= 55296 && c <= 56319) {
106
+ n += 4;
107
+ i++;
108
+ } else n += 3;
109
+ }
110
+ return n;
111
+ }
86
112
 
87
113
  // src/identity.ts
88
114
  var DEVICE_ID_KEY = "sauron.device_id";
89
115
  var SESSION_ID_KEY = "sauron.session_id";
90
116
  var ANON_ID_KEY = "sauron.anon_id";
117
+ var LAST_IDENTIFIED_KEY = "sauron.last_identified";
118
+ var LAST_IDENTIFIED_FORMAT = "v1";
119
+ function encodeLastIdentified(digest) {
120
+ return `${LAST_IDENTIFIED_FORMAT}:${digest}`;
121
+ }
122
+ function decodeLastIdentified(raw) {
123
+ if (raw === null) return null;
124
+ const sep = raw.indexOf(":");
125
+ if (sep < 0 || raw.slice(0, sep) !== LAST_IDENTIFIED_FORMAT) return null;
126
+ const digest = raw.slice(sep + 1);
127
+ return digest === "" ? null : digest;
128
+ }
129
+ function fnv1a32(s) {
130
+ let h = 2166136261;
131
+ for (let i = 0; i < s.length; i++) {
132
+ h ^= s.charCodeAt(i);
133
+ h = Math.imul(h, 16777619);
134
+ }
135
+ return (h >>> 0).toString(16).padStart(8, "0");
136
+ }
137
+ function hashIdentity(id) {
138
+ return fnv1a32(id) + fnv1a32("" + id);
139
+ }
91
140
  function webStorage(name) {
92
141
  try {
93
142
  const s = globalThis[name];
@@ -121,6 +170,7 @@ function persistentId(cached, storage, key) {
121
170
  var deviceId = null;
122
171
  var sessionId = null;
123
172
  var anonymousId = null;
173
+ var lastIdentified = null;
124
174
  function getDeviceId() {
125
175
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
126
176
  return deviceId;
@@ -129,6 +179,17 @@ function getSessionId() {
129
179
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
130
180
  return sessionId;
131
181
  }
182
+ function rotateSessionId() {
183
+ sessionId = null;
184
+ const storage = webStorage("sessionStorage");
185
+ if (storage) {
186
+ try {
187
+ storage.removeItem(SESSION_ID_KEY);
188
+ } catch {
189
+ }
190
+ }
191
+ return getSessionId();
192
+ }
132
193
  function getAnonymousId() {
133
194
  if (anonymousId) return anonymousId;
134
195
  const storage = webStorage("localStorage");
@@ -163,6 +224,37 @@ function resetAnonymousId() {
163
224
  }
164
225
  return getAnonymousId();
165
226
  }
227
+ function getLastIdentified() {
228
+ const storage = webStorage("localStorage");
229
+ if (!storage) return decodeLastIdentified(lastIdentified);
230
+ try {
231
+ const stored = storage.getItem(LAST_IDENTIFIED_KEY);
232
+ return decodeLastIdentified(stored ?? lastIdentified);
233
+ } catch {
234
+ return decodeLastIdentified(lastIdentified);
235
+ }
236
+ }
237
+ function setLastIdentified(id) {
238
+ const encoded = encodeLastIdentified(id);
239
+ lastIdentified = encoded;
240
+ const storage = webStorage("localStorage");
241
+ if (storage) {
242
+ try {
243
+ storage.setItem(LAST_IDENTIFIED_KEY, encoded);
244
+ } catch {
245
+ }
246
+ }
247
+ }
248
+ function clearLastIdentified() {
249
+ lastIdentified = null;
250
+ const storage = webStorage("localStorage");
251
+ if (storage) {
252
+ try {
253
+ storage.removeItem(LAST_IDENTIFIED_KEY);
254
+ } catch {
255
+ }
256
+ }
257
+ }
166
258
 
167
259
  // src/context.ts
168
260
  function getNavigator() {
@@ -809,13 +901,31 @@ var Scope = class {
809
901
  this.maxBreadcrumbs = Math.max(0, max);
810
902
  this.trim();
811
903
  }
904
+ /**
905
+ * Replace the scope user.
906
+ *
907
+ * `id` is coerced with `String()` for the same reason `SauronClient.
908
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
909
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
910
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
911
+ * coercion entirely, and the consequence is not cosmetic: the scope user
912
+ * lands in the envelope context, where the server's `distinct_id` is a
913
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
914
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
915
+ * is non-retryable, so every event in it is dropped for good.
916
+ *
917
+ * Rebuilding the whole object (rather than merging into the existing one)
918
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
919
+ * `email` and `traits` come from the input alone, so setting a new user
920
+ * never inherits the previous person's contact details.
921
+ */
812
922
  setUser(user) {
813
923
  if (user === null) {
814
924
  this.user = null;
815
925
  return;
816
926
  }
817
927
  this.user = {
818
- id: user.id ?? null,
928
+ id: user.id === null || user.id === void 0 ? null : String(user.id),
819
929
  email: user.email ?? null,
820
930
  traits: user.traits ?? {}
821
931
  };
@@ -916,11 +1026,12 @@ function setScreen(name) {
916
1026
  function identify(id, traits = {}) {
917
1027
  const client = getClient();
918
1028
  if (!client) return;
919
- const anonymousId2 = client.getAnonymousId();
920
- client.getScope().setUser({ id, traits });
1029
+ const distinctId = String(id);
1030
+ const anonymousId2 = client.prepareIdentify(distinctId);
1031
+ client.getScope().setUser({ id: distinctId, traits });
921
1032
  const item = {
922
1033
  type: "identify",
923
- distinct_id: id,
1034
+ distinct_id: distinctId,
924
1035
  anonymous_id: anonymousId2,
925
1036
  traits: traits ?? {}
926
1037
  };
@@ -937,7 +1048,7 @@ function normalizeOp(op) {
937
1048
  return op && TRANSACTION_OPS.includes(op) ? op : "custom";
938
1049
  }
939
1050
  function buildTransactionItem(input, distinctId, sessionId2) {
940
- return {
1051
+ const item = {
941
1052
  type: "transaction",
942
1053
  name: input.name,
943
1054
  op: normalizeOp(input.op),
@@ -950,6 +1061,11 @@ function buildTransactionItem(input, distinctId, sessionId2) {
950
1061
  session_id: sessionId2,
951
1062
  timestamp: nowIso()
952
1063
  };
1064
+ if (input.tags && Object.keys(input.tags).length > 0) item.tags = { ...input.tags };
1065
+ if (input.extra && Object.keys(input.extra).length > 0) {
1066
+ item.extra = capTransactionExtra({ ...input.extra });
1067
+ }
1068
+ return item;
953
1069
  }
954
1070
  function trackTransaction(input) {
955
1071
  const client = getClient();
@@ -1850,18 +1966,56 @@ var SauronClient = class {
1850
1966
  return this.anonUsed ? getAnonymousId() : null;
1851
1967
  }
1852
1968
  /**
1853
- * Forget the current person: clear the scope user and mint a fresh anonymous
1854
- * id.
1969
+ * Forget the current person: clear the scope user, mint a fresh anonymous
1970
+ * id, forget the last identified user, and rotate the session id.
1855
1971
  *
1856
1972
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1857
1973
  * browser reuses the persisted anon id, and a later identify() aliases their
1858
- * activity to the previous account server-side, permanently.
1974
+ * activity to the previous account server-side, permanently. Rotating the
1975
+ * session id matters too: the server's `bump_session` is last-write-wins on
1976
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
1977
+ * serially represent two different people and record only whichever wrote
1978
+ * last.
1859
1979
  */
1860
1980
  reset() {
1861
1981
  this.scope.setUser(null);
1862
1982
  resetAnonymousId();
1983
+ clearLastIdentified();
1984
+ rotateSessionId();
1863
1985
  this.anonUsed = false;
1864
1986
  }
1987
+ /**
1988
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
1989
+ *
1990
+ * When a DIFFERENT user identifies than last time, the current anon id
1991
+ * belongs to the previous person and is already burned server-side, so it is
1992
+ * replaced before anything else happens and `null` is sent instead of a
1993
+ * cross-user alias. This cannot repair events already sent under the burned
1994
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
1995
+ * window instead of every future one.
1996
+ *
1997
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
1998
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
1999
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
2000
+ * against a value that already round-tripped through storage would treat
2001
+ * the SAME numeric user as a switch on every single call. The comparison
2002
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
2003
+ * app that (unusually) identifies with `''` still has a later, different id
2004
+ * correctly detected as a real switch — a falsy string is not "no identity
2005
+ * yet". `last`/the persisted value are digests, not the raw id — see
2006
+ * `hashIdentity`.
2007
+ */
2008
+ prepareIdentify(id) {
2009
+ const digest = hashIdentity(String(id));
2010
+ const last = getLastIdentified();
2011
+ if (last !== null && last !== digest) {
2012
+ resetAnonymousId();
2013
+ rotateSessionId();
2014
+ this.anonUsed = false;
2015
+ }
2016
+ setLastIdentified(digest);
2017
+ return this.getAnonymousId();
2018
+ }
1865
2019
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1866
2020
  makeEnvelope(items) {
1867
2021
  const header = {
@@ -2174,6 +2328,6 @@ var Sauron = {
2174
2328
  };
2175
2329
  var index_default = Sauron;
2176
2330
 
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 };
2331
+ export { DsnError, MAX_TRANSACTION_EXTRA_BYTES, SDK_NAME, SDK_VERSION, Sauron, SauronClient, addBreadcrumb2 as addBreadcrumb, buildEnvelope, cancelWorkflow2 as cancelWorkflow, capTransactionExtra, 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 };
2178
2332
  //# sourceMappingURL=index.js.map
2179
2333
  //# sourceMappingURL=index.js.map