@magicfeedback/popup-sdk 1.16.1 → 1.17.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.
@@ -87,9 +87,9 @@ interface AnalyticsEnvelope {
87
87
  *
88
88
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
89
89
  * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
90
- * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
91
- * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
92
- * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
90
+ * backend agrupa los lotes por `sessionId`, que genera el SDK al abrir cada sesión. El
91
+ * ÚLTIMO lote de una sesión (cierre de página, app a background, cambio de usuario) va con
92
+ * `completed:true`: cierra el registro y el lote siguiente abre otro con un sessionId nuevo.
93
93
  * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
94
94
  *
95
95
  * Encoding:
@@ -445,6 +445,8 @@ declare class DeepdotsPopups {
445
445
  private analyticsFlushTimer;
446
446
  /** Crash & error reporting (#14–17). Null hasta init(). */
447
447
  private crashReporter;
448
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
449
+ private rnErrorUtils;
448
450
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
449
451
  private messageGuard;
450
452
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -513,8 +515,18 @@ declare class DeepdotsPopups {
513
515
  setMetric(key: string, value: string | number | boolean): void;
514
516
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
515
517
  reportError(error: unknown, options?: ReportErrorOptions): void;
516
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
518
+ /**
519
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
520
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
521
+ */
517
522
  installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
523
+ /**
524
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
525
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
526
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
527
+ * llama en `init()` y cada vez que se activa el tracking.
528
+ */
529
+ private installCrashHandlers;
518
530
  /**
519
531
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
520
532
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -87,9 +87,9 @@ interface AnalyticsEnvelope {
87
87
  *
88
88
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
89
89
  * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
90
- * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
91
- * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
92
- * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
90
+ * backend agrupa los lotes por `sessionId`, que genera el SDK al abrir cada sesión. El
91
+ * ÚLTIMO lote de una sesión (cierre de página, app a background, cambio de usuario) va con
92
+ * `completed:true`: cierra el registro y el lote siguiente abre otro con un sessionId nuevo.
93
93
  * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
94
94
  *
95
95
  * Encoding:
@@ -445,6 +445,8 @@ declare class DeepdotsPopups {
445
445
  private analyticsFlushTimer;
446
446
  /** Crash & error reporting (#14–17). Null hasta init(). */
447
447
  private crashReporter;
448
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
449
+ private rnErrorUtils;
448
450
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
449
451
  private messageGuard;
450
452
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -513,8 +515,18 @@ declare class DeepdotsPopups {
513
515
  setMetric(key: string, value: string | number | boolean): void;
514
516
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
515
517
  reportError(error: unknown, options?: ReportErrorOptions): void;
516
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
518
+ /**
519
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
520
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
521
+ */
517
522
  installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
523
+ /**
524
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
525
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
526
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
527
+ * llama en `init()` y cada vez que se activa el tracking.
528
+ */
529
+ private installCrashHandlers;
518
530
  /**
519
531
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
520
532
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -4851,97 +4851,166 @@ async function safeBodyText(res) {
4851
4851
  return "";
4852
4852
  }
4853
4853
  }
4854
+ var PENDING_MAX = 50;
4855
+ var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
4856
+ var POST_TIMEOUT_MS = 2e4;
4857
+ function pendingKey(integration) {
4858
+ return `__deepdots_analytics_pending__:${integration}`;
4859
+ }
4860
+ function generateSessionId() {
4861
+ const c = globalThis.crypto;
4862
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
4863
+ const b = new Uint8Array(16);
4864
+ if (typeof c?.getRandomValues === "function") c.getRandomValues(b);
4865
+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
4866
+ b[6] = b[6] & 15 | 64;
4867
+ b[8] = b[8] & 63 | 128;
4868
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
4869
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
4870
+ }
4854
4871
  function createFeedbackSink(options) {
4855
- let feedbackSessionId = options.initialSessionId || void 0;
4856
- let firstPostInFlight = null;
4857
- let generation = 0;
4858
4872
  const url = `${options.baseUrl}/sdk/feedback`;
4859
- const post = async (body, final, sessionEnd = false) => {
4860
- const postGeneration = generation;
4873
+ const newSessionId = options.newSessionId ?? generateSessionId;
4874
+ const now = options.now ?? (() => Date.now());
4875
+ const storageKey = pendingKey(options.keys.integration);
4876
+ let feedbackSessionId = options.initialSessionId || void 0;
4877
+ let announcedId = feedbackSessionId;
4878
+ const pending2 = loadPending();
4879
+ let draining = null;
4880
+ function loadPending() {
4881
+ if (!options.storage) return [];
4882
+ try {
4883
+ const raw = options.storage.getItem(storageKey);
4884
+ const list = raw ? JSON.parse(raw) : [];
4885
+ return Array.isArray(list) ? list.filter((p) => p?.body && now() - p.at <= PENDING_MAX_AGE_MS) : [];
4886
+ } catch {
4887
+ return [];
4888
+ }
4889
+ }
4890
+ function savePending() {
4891
+ if (!options.storage) return;
4892
+ try {
4893
+ if (pending2.length) options.storage.setItem(storageKey, JSON.stringify(pending2));
4894
+ else options.storage.removeItem(storageKey);
4895
+ } catch {
4896
+ }
4897
+ }
4898
+ function addPending(batch) {
4899
+ pending2.push(batch);
4900
+ if (pending2.length > PENDING_MAX) pending2.splice(0, pending2.length - PENDING_MAX);
4901
+ savePending();
4902
+ }
4903
+ function removePending(batch) {
4904
+ const i = pending2.indexOf(batch);
4905
+ if (i >= 0) pending2.splice(i, 1);
4906
+ savePending();
4907
+ }
4908
+ async function post(body, final) {
4861
4909
  const json = JSON.stringify(body);
4862
4910
  const small = json.length <= KEEPALIVE_MAX_BYTES;
4863
4911
  if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
4864
4912
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
4865
4913
  if (!f) {
4866
4914
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
4867
- return;
4915
+ return "drop";
4868
4916
  }
4869
- const res = await f(url, {
4870
- method: "POST",
4871
- headers: { "Content-Type": "application/json" },
4872
- body: json,
4873
- // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
4874
- // petición con credenciales fallaría el CORS. Explícito para no depender del default.
4875
- credentials: "omit",
4876
- // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
4877
- ...small ? { keepalive: true } : {}
4878
- });
4879
- if (!res.ok) {
4917
+ let timer;
4918
+ try {
4919
+ const request = f(url, {
4920
+ method: "POST",
4921
+ headers: { "Content-Type": "application/json" },
4922
+ body: json,
4923
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
4924
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
4925
+ credentials: "omit",
4926
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
4927
+ ...small ? { keepalive: true } : {}
4928
+ });
4929
+ const timeout = new Promise((_, reject) => {
4930
+ timer = setTimeout(() => reject(new Error("timeout")), POST_TIMEOUT_MS);
4931
+ });
4932
+ const res = await Promise.race([request, timeout]);
4933
+ if (res.ok) return "ok";
4880
4934
  const detail = await safeBodyText(res);
4881
4935
  if (isRetryableStatus(res.status)) {
4882
- throw new Error(`POST /sdk/feedback ${res.status} (retryable): ${detail}`);
4936
+ options.log?.(`[DeepdotsAnalytics] POST /sdk/feedback ${res.status} (retryable): ${detail}`);
4937
+ return "retry";
4883
4938
  }
4884
4939
  options.log?.(
4885
4940
  `[DeepdotsAnalytics] POST /sdk/feedback rejected with ${res.status}; batch DISCARDED:`,
4886
4941
  detail
4887
4942
  );
4888
- return;
4889
- }
4890
- if (sessionEnd) {
4891
- let closedId = body.sessionId;
4892
- try {
4893
- const data = await res.json();
4894
- closedId = data?.sessionId ?? closedId;
4895
- } catch {
4896
- }
4897
- try {
4898
- if (closedId) options.onSessionClosed?.(closedId);
4899
- } catch {
4900
- }
4901
- return;
4943
+ return "drop";
4944
+ } catch (error) {
4945
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback failed (will retry):", error);
4946
+ return "retry";
4947
+ } finally {
4948
+ clearTimeout(timer);
4902
4949
  }
4950
+ }
4951
+ function delivered(body) {
4952
+ const id = body.sessionId;
4953
+ if (!id) return;
4903
4954
  try {
4904
- const data = await res.json();
4905
- if (postGeneration !== generation) {
4906
- options.log?.("[DeepdotsAnalytics] sessionId of a closed session ignored:", data?.sessionId);
4907
- return;
4908
- }
4909
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
4910
- feedbackSessionId = data.sessionId;
4911
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cached:", feedbackSessionId);
4912
- options.onSessionId?.(feedbackSessionId);
4955
+ if (body.completed) {
4956
+ options.onSessionClosed?.(id);
4957
+ } else if (id === feedbackSessionId && id !== announcedId) {
4958
+ announcedId = id;
4959
+ options.log?.("[DeepdotsAnalytics] feedback session open:", id);
4960
+ options.onSessionId?.(id);
4913
4961
  }
4914
4962
  } catch {
4915
4963
  }
4916
- };
4964
+ }
4965
+ async function send(batch, final, alreadyPending) {
4966
+ const result = await post(batch.body, final);
4967
+ if (result === "retry") {
4968
+ if (!alreadyPending) addPending(batch);
4969
+ return;
4970
+ }
4971
+ if (alreadyPending) removePending(batch);
4972
+ if (result === "ok") delivered(batch.body);
4973
+ }
4974
+ function drain() {
4975
+ const run = async () => {
4976
+ for (let i = 0; i < pending2.length; ) {
4977
+ const batch = pending2[i];
4978
+ await send(batch, false, true);
4979
+ if (pending2[i] === batch) return;
4980
+ }
4981
+ };
4982
+ const next = draining ? draining.then(run) : run();
4983
+ draining = next;
4984
+ void next.finally(() => {
4985
+ if (draining === next) draining = null;
4986
+ });
4987
+ return next;
4988
+ }
4989
+ if (pending2.length) {
4990
+ savePending();
4991
+ setTimeout(() => void drain(), 0);
4992
+ }
4917
4993
  return async (envelope, meta) => {
4918
4994
  if (meta?.resetSession) {
4919
4995
  feedbackSessionId = void 0;
4920
- firstPostInFlight = null;
4921
- generation++;
4922
4996
  options.onSessionReset?.();
4923
4997
  return;
4924
4998
  }
4925
4999
  const final = meta?.final === true;
4926
5000
  const sessionEnd = meta?.sessionEnd === true;
4927
- if (!feedbackSessionId && firstPostInFlight && !final) {
4928
- await firstPostInFlight;
4929
- }
5001
+ if (!feedbackSessionId) feedbackSessionId = newSessionId();
4930
5002
  const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
4931
5003
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4932
- const sent = post(body, final, sessionEnd);
5004
+ const batch = { body, at: now() };
4933
5005
  if (sessionEnd) {
4934
5006
  feedbackSessionId = void 0;
4935
- firstPostInFlight = null;
4936
- generation++;
4937
5007
  options.onSessionReset?.();
4938
- } else if (!feedbackSessionId) {
4939
- firstPostInFlight = sent.then(
4940
- () => void 0,
4941
- () => void 0
4942
- );
5008
+ addPending(batch);
5009
+ await drain();
5010
+ return;
4943
5011
  }
4944
- await sent;
5012
+ const retries = pending2.length ? drain() : null;
5013
+ await Promise.all([retries, send(batch, final, false)]);
4945
5014
  };
4946
5015
  }
4947
5016
 
@@ -5361,6 +5430,10 @@ function crashRecordToParams(r) {
5361
5430
  }
5362
5431
  var CrashReporter = class {
5363
5432
  constructor(options) {
5433
+ /** Los handlers de `window` ya están puestos: `install()` es idempotente. */
5434
+ this.windowInstalled = false;
5435
+ /** ErrorUtils ya enganchados: engancharlos dos veces capturaría cada error dos veces. */
5436
+ this.hookedErrorUtils = /* @__PURE__ */ new Set();
5364
5437
  this.options = options;
5365
5438
  this.now = options.now ?? (() => Date.now());
5366
5439
  }
@@ -5436,9 +5509,11 @@ var CrashReporter = class {
5436
5509
  }
5437
5510
  return queue;
5438
5511
  }
5439
- /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
5512
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). Idempotente. */
5440
5513
  install() {
5514
+ if (this.windowInstalled) return;
5441
5515
  if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
5516
+ this.windowInstalled = true;
5442
5517
  window.addEventListener("error", (e) => {
5443
5518
  if (!this.isEnabled()) return;
5444
5519
  this.captureUnhandled(e.error ?? e.message ?? "error");
@@ -5448,8 +5523,10 @@ var CrashReporter = class {
5448
5523
  this.captureUnhandled(e.reason ?? "unhandledrejection");
5449
5524
  });
5450
5525
  }
5451
- /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
5526
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. Idempotente por instancia de ErrorUtils. */
5452
5527
  installReactNative(errorUtils) {
5528
+ if (this.hookedErrorUtils.has(errorUtils)) return;
5529
+ this.hookedErrorUtils.add(errorUtils);
5453
5530
  const previous = errorUtils.getGlobalHandler?.();
5454
5531
  errorUtils.setGlobalHandler((error, isFatal) => {
5455
5532
  if (this.isEnabled()) this.captureUnhandled(error);
@@ -5665,6 +5742,8 @@ var DeepdotsPopups = class {
5665
5742
  this.analyticsFlushTimer = void 0;
5666
5743
  /** Crash & error reporting (#14–17). Null hasta init(). */
5667
5744
  this.crashReporter = null;
5745
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
5746
+ this.rnErrorUtils = null;
5668
5747
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
5669
5748
  this.messageGuard = new MessageGuard();
5670
5749
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -5765,6 +5844,10 @@ var DeepdotsPopups = class {
5765
5844
  keys: config.analytics,
5766
5845
  log: (...a) => this.log(...a),
5767
5846
  initialSessionId: resumable?.sessionId,
5847
+ // En móvil (RN) la app puede morir con el lote de cierre sin entregar: se
5848
+ // persiste y se reenvía en el arranque siguiente. En web el cierre sale con
5849
+ // `keepalive` y varias pestañas compartirían el mismo storage.
5850
+ storage: (config.platform ?? "web") !== "web" ? storage : void 0,
5768
5851
  onSessionId: (id) => {
5769
5852
  this.analyticsFeedbackSessionId = id;
5770
5853
  this.notifyFeedbackSession(id, "open");
@@ -5800,9 +5883,7 @@ var DeepdotsPopups = class {
5800
5883
  now: () => Date.now(),
5801
5884
  enabled: () => this.tracking?.isTrackingEnabled() ?? false
5802
5885
  });
5803
- if (this.tracking?.isTrackingEnabled()) {
5804
- this.crashReporter.install();
5805
- }
5886
+ this.installCrashHandlers();
5806
5887
  this.geoEnabled = config.geolocation ?? true;
5807
5888
  this.geoWanted = this.geoEnabled && !!config.analytics;
5808
5889
  this.resolveGeo();
@@ -5871,6 +5952,7 @@ var DeepdotsPopups = class {
5871
5952
  if (!enabled) this.closeSession("tracking_disabled");
5872
5953
  this.tracking?.setTrackingEnabled(enabled);
5873
5954
  if (enabled) this.openSession("tracking_enabled");
5955
+ if (enabled) this.installCrashHandlers();
5874
5956
  if (enabled) this.resolveGeo();
5875
5957
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
5876
5958
  }
@@ -5901,10 +5983,27 @@ var DeepdotsPopups = class {
5901
5983
  if (!this.tracking?.isTrackingEnabled()) return;
5902
5984
  this.crashReporter?.reportError(error, options);
5903
5985
  }
5904
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
5986
+ /**
5987
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
5988
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
5989
+ */
5905
5990
  installReactNativeCrashHandler(errorUtils) {
5906
- if (!this.tracking?.isTrackingEnabled()) return;
5907
- this.crashReporter?.installReactNative(errorUtils);
5991
+ this.rnErrorUtils = errorUtils;
5992
+ this.installCrashHandlers();
5993
+ }
5994
+ /**
5995
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
5996
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
5997
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
5998
+ * llama en `init()` y cada vez que se activa el tracking.
5999
+ */
6000
+ installCrashHandlers() {
6001
+ if (!this.crashReporter || !this.tracking?.isTrackingEnabled()) return;
6002
+ this.crashReporter.install();
6003
+ const errorUtils = this.rnErrorUtils ?? globalThis.ErrorUtils;
6004
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
6005
+ this.crashReporter.installReactNative(errorUtils);
6006
+ }
5908
6007
  }
5909
6008
  /**
5910
6009
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el