@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.
package/dist/index.d.mts CHANGED
@@ -124,9 +124,9 @@ type AnalyticsSink = (payload: AnalyticsEnvelope, meta?: AnalyticsFlushMeta) =>
124
124
  *
125
125
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
126
126
  * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
127
- * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
128
- * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
129
- * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
127
+ * backend agrupa los lotes por `sessionId`, que genera el SDK al abrir cada sesión. El
128
+ * ÚLTIMO lote de una sesión (cierre de página, app a background, cambio de usuario) va con
129
+ * `completed:true`: cierra el registro y el lote siguiente abre otro con un sessionId nuevo.
130
130
  * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
131
131
  *
132
132
  * Encoding:
@@ -160,14 +160,14 @@ interface AnalyticsFeedbackBody {
160
160
  integration: string;
161
161
  completed: boolean;
162
162
  finished?: boolean;
163
- /** sessionId devuelto por el primer POST; permite que el backend agrupe todos los eventos en un solo registro. */
163
+ /** sessionId de la sesión (lo genera el SDK): el backend agrupa todos sus lotes en un solo registro. */
164
164
  sessionId?: string;
165
165
  }
166
166
  interface BuildBodyOptions {
167
167
  /**
168
168
  * `true` en el último lote de la sesión → `completed:true` (cierra el registro en backend).
169
- * El body SÍ lleva el `sessionId` actual (es el registro que se cierra); es el lote
170
- * SIGUIENTE el que lo omite.
169
+ * El body lleva el `sessionId` de la sesión que se cierra; el lote siguiente abre otro
170
+ * registro con un sessionId nuevo.
171
171
  */
172
172
  sessionEnd?: boolean;
173
173
  }
@@ -193,32 +193,49 @@ interface FeedbackSinkOptions {
193
193
  * `pagehide` y reanudada tras recargar la página (ver `suspended-session.ts`).
194
194
  */
195
195
  initialSessionId?: string;
196
- /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
196
+ /**
197
+ * Genera el sessionId de cada sesión (default: UUID v4). Inyectable para tests.
198
+ */
199
+ newSessionId?: () => string;
200
+ /**
201
+ * Persistencia de los lotes pendientes (React Native: MMKV). Con él, el lote de cierre se
202
+ * guarda antes de enviarse y cualquier lote que falle sobrevive a que la app muera: el sink
203
+ * del arranque siguiente lo reenvía a su registro. Sin él, los pendientes viven en memoria.
204
+ */
205
+ storage?: KeyValueStorage;
206
+ /** Reloj inyectable (tests). */
207
+ now?: () => number;
208
+ /** Callback invocado cuando la API acepta el primer lote de una sesión (registro abierto). */
197
209
  onSessionId?: (id: string) => void;
198
210
  /** Callback invocado al cerrar la sesión (`completed:true`): el sessionId cacheado se olvida. */
199
211
  onSessionReset?: () => void;
200
212
  /**
201
213
  * Callback invocado cuando el backend acepta el lote de cierre, con el sessionId del
202
- * registro cerrado: el que llevaba el lote o, en una sesión de un solo lote, el que
203
- * devuelve la respuesta. Ese registro se convierte en Feedback en segundos.
214
+ * registro cerrado. Ese registro se convierte en Feedback en segundos.
204
215
  */
205
216
  onSessionClosed?: (id: string) => void;
206
217
  }
207
218
  /**
208
219
  * Sink real: transforma el envelope y hace `POST {baseUrl}/sdk/feedback`.
209
220
  *
210
- * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta y lo
211
- * envía en los siguientes POSTs para que el backend agrupe todos los eventos en un único
212
- * registro de feedback.
221
+ * El `sessionId` de cada sesión lo genera el SDK (UUID) y va en TODOS sus lotes, desde el
222
+ * primero. La API guarda cada lote bajo el sessionId que recibe, así que una sesión es un
223
+ * registro aunque no llegue ninguna respuesta. Antes lo asignaba la API en la respuesta del
224
+ * primer POST: si esa respuesta se perdía (la app pasaba a background con el POST en vuelo),
225
+ * el resto de la sesión acababa en otro registro.
213
226
  *
214
227
  * Garantías de entrega:
215
- * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
216
- * respuesta): dos POSTs a la vez sin `sessionId` crearían dos registros y partirían los datos;
228
+ * - un lote que falla de forma transitoria (red, 5xx, 408, 429, sin respuesta en
229
+ * POST_TIMEOUT_MS) se guarda TAL CUAL —con su sessionId y su `completed`— y se reenvía
230
+ * antes del lote siguiente. Nunca vuelve al buffer del manager: allí se mezclaba con la
231
+ * sesión siguiente y viajaba con su registro. La promesa del sink no rechaza;
232
+ * - con `storage` (React Native) los pendientes sobreviven a que la app muera: el lote de
233
+ * cierre se guarda ANTES de enviarse y el sink del arranque siguiente lo reenvía. La API
234
+ * descarta los eventos repetidos de una misma sesión al ensamblarla;
217
235
  * - `keepalive` para que el navegador no cancele el POST al navegar fuera ni al cerrar la
218
- * página, también en el flush final (sin `sendBeacon`: ver `sendBeaconImpl`);
236
+ * página (sin `sendBeacon`: ver `sendBeaconImpl`);
219
237
  * - sin credenciales, que es lo único que pasa el CORS de la API (`Allow-Origin: *`);
220
- * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
221
- * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
238
+ * - un 4xx se loguea y se descarta.
222
239
  */
223
240
  declare function createFeedbackSink(options: FeedbackSinkOptions): AnalyticsSink;
224
241
 
@@ -657,6 +674,10 @@ declare function crashRecordToParams(r: CrashRecord): Record<string, unknown>;
657
674
  declare class CrashReporter {
658
675
  private options;
659
676
  private now;
677
+ /** Los handlers de `window` ya están puestos: `install()` es idempotente. */
678
+ private windowInstalled;
679
+ /** ErrorUtils ya enganchados: engancharlos dos veces capturaría cada error dos veces. */
680
+ private hookedErrorUtils;
660
681
  constructor(options: CrashReporterOptions);
661
682
  private isEnabled;
662
683
  /** Construye un CrashRecord capturando contexto en el momento del crash. */
@@ -669,9 +690,9 @@ declare class CrashReporter {
669
690
  private persist;
670
691
  /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
671
692
  drainPendingCrashes(): CrashRecord[];
672
- /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
693
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). Idempotente. */
673
694
  install(): void;
674
- /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
695
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. Idempotente por instancia de ErrorUtils. */
675
696
  installReactNative(errorUtils: ReactNativeErrorUtils): void;
676
697
  /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
677
698
  _persistForTest(error: unknown): void;
@@ -784,6 +805,8 @@ declare class DeepdotsPopups {
784
805
  private analyticsFlushTimer;
785
806
  /** Crash & error reporting (#14–17). Null hasta init(). */
786
807
  private crashReporter;
808
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
809
+ private rnErrorUtils;
787
810
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
788
811
  private messageGuard;
789
812
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -852,8 +875,18 @@ declare class DeepdotsPopups {
852
875
  setMetric(key: string, value: string | number | boolean): void;
853
876
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
854
877
  reportError(error: unknown, options?: ReportErrorOptions): void;
855
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
878
+ /**
879
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
880
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
881
+ */
856
882
  installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
883
+ /**
884
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
885
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
886
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
887
+ * llama en `init()` y cada vez que se activa el tracking.
888
+ */
889
+ private installCrashHandlers;
857
890
  /**
858
891
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
859
892
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
package/dist/index.d.ts CHANGED
@@ -124,9 +124,9 @@ type AnalyticsSink = (payload: AnalyticsEnvelope, meta?: AnalyticsFlushMeta) =>
124
124
  *
125
125
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
126
126
  * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
127
- * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
128
- * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
129
- * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
127
+ * backend agrupa los lotes por `sessionId`, que genera el SDK al abrir cada sesión. El
128
+ * ÚLTIMO lote de una sesión (cierre de página, app a background, cambio de usuario) va con
129
+ * `completed:true`: cierra el registro y el lote siguiente abre otro con un sessionId nuevo.
130
130
  * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
131
131
  *
132
132
  * Encoding:
@@ -160,14 +160,14 @@ interface AnalyticsFeedbackBody {
160
160
  integration: string;
161
161
  completed: boolean;
162
162
  finished?: boolean;
163
- /** sessionId devuelto por el primer POST; permite que el backend agrupe todos los eventos en un solo registro. */
163
+ /** sessionId de la sesión (lo genera el SDK): el backend agrupa todos sus lotes en un solo registro. */
164
164
  sessionId?: string;
165
165
  }
166
166
  interface BuildBodyOptions {
167
167
  /**
168
168
  * `true` en el último lote de la sesión → `completed:true` (cierra el registro en backend).
169
- * El body SÍ lleva el `sessionId` actual (es el registro que se cierra); es el lote
170
- * SIGUIENTE el que lo omite.
169
+ * El body lleva el `sessionId` de la sesión que se cierra; el lote siguiente abre otro
170
+ * registro con un sessionId nuevo.
171
171
  */
172
172
  sessionEnd?: boolean;
173
173
  }
@@ -193,32 +193,49 @@ interface FeedbackSinkOptions {
193
193
  * `pagehide` y reanudada tras recargar la página (ver `suspended-session.ts`).
194
194
  */
195
195
  initialSessionId?: string;
196
- /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
196
+ /**
197
+ * Genera el sessionId de cada sesión (default: UUID v4). Inyectable para tests.
198
+ */
199
+ newSessionId?: () => string;
200
+ /**
201
+ * Persistencia de los lotes pendientes (React Native: MMKV). Con él, el lote de cierre se
202
+ * guarda antes de enviarse y cualquier lote que falle sobrevive a que la app muera: el sink
203
+ * del arranque siguiente lo reenvía a su registro. Sin él, los pendientes viven en memoria.
204
+ */
205
+ storage?: KeyValueStorage;
206
+ /** Reloj inyectable (tests). */
207
+ now?: () => number;
208
+ /** Callback invocado cuando la API acepta el primer lote de una sesión (registro abierto). */
197
209
  onSessionId?: (id: string) => void;
198
210
  /** Callback invocado al cerrar la sesión (`completed:true`): el sessionId cacheado se olvida. */
199
211
  onSessionReset?: () => void;
200
212
  /**
201
213
  * Callback invocado cuando el backend acepta el lote de cierre, con el sessionId del
202
- * registro cerrado: el que llevaba el lote o, en una sesión de un solo lote, el que
203
- * devuelve la respuesta. Ese registro se convierte en Feedback en segundos.
214
+ * registro cerrado. Ese registro se convierte en Feedback en segundos.
204
215
  */
205
216
  onSessionClosed?: (id: string) => void;
206
217
  }
207
218
  /**
208
219
  * Sink real: transforma el envelope y hace `POST {baseUrl}/sdk/feedback`.
209
220
  *
210
- * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta y lo
211
- * envía en los siguientes POSTs para que el backend agrupe todos los eventos en un único
212
- * registro de feedback.
221
+ * El `sessionId` de cada sesión lo genera el SDK (UUID) y va en TODOS sus lotes, desde el
222
+ * primero. La API guarda cada lote bajo el sessionId que recibe, así que una sesión es un
223
+ * registro aunque no llegue ninguna respuesta. Antes lo asignaba la API en la respuesta del
224
+ * primer POST: si esa respuesta se perdía (la app pasaba a background con el POST en vuelo),
225
+ * el resto de la sesión acababa en otro registro.
213
226
  *
214
227
  * Garantías de entrega:
215
- * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
216
- * respuesta): dos POSTs a la vez sin `sessionId` crearían dos registros y partirían los datos;
228
+ * - un lote que falla de forma transitoria (red, 5xx, 408, 429, sin respuesta en
229
+ * POST_TIMEOUT_MS) se guarda TAL CUAL —con su sessionId y su `completed`— y se reenvía
230
+ * antes del lote siguiente. Nunca vuelve al buffer del manager: allí se mezclaba con la
231
+ * sesión siguiente y viajaba con su registro. La promesa del sink no rechaza;
232
+ * - con `storage` (React Native) los pendientes sobreviven a que la app muera: el lote de
233
+ * cierre se guarda ANTES de enviarse y el sink del arranque siguiente lo reenvía. La API
234
+ * descarta los eventos repetidos de una misma sesión al ensamblarla;
217
235
  * - `keepalive` para que el navegador no cancele el POST al navegar fuera ni al cerrar la
218
- * página, también en el flush final (sin `sendBeacon`: ver `sendBeaconImpl`);
236
+ * página (sin `sendBeacon`: ver `sendBeaconImpl`);
219
237
  * - sin credenciales, que es lo único que pasa el CORS de la API (`Allow-Origin: *`);
220
- * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
221
- * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
238
+ * - un 4xx se loguea y se descarta.
222
239
  */
223
240
  declare function createFeedbackSink(options: FeedbackSinkOptions): AnalyticsSink;
224
241
 
@@ -657,6 +674,10 @@ declare function crashRecordToParams(r: CrashRecord): Record<string, unknown>;
657
674
  declare class CrashReporter {
658
675
  private options;
659
676
  private now;
677
+ /** Los handlers de `window` ya están puestos: `install()` es idempotente. */
678
+ private windowInstalled;
679
+ /** ErrorUtils ya enganchados: engancharlos dos veces capturaría cada error dos veces. */
680
+ private hookedErrorUtils;
660
681
  constructor(options: CrashReporterOptions);
661
682
  private isEnabled;
662
683
  /** Construye un CrashRecord capturando contexto en el momento del crash. */
@@ -669,9 +690,9 @@ declare class CrashReporter {
669
690
  private persist;
670
691
  /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
671
692
  drainPendingCrashes(): CrashRecord[];
672
- /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
693
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). Idempotente. */
673
694
  install(): void;
674
- /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
695
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. Idempotente por instancia de ErrorUtils. */
675
696
  installReactNative(errorUtils: ReactNativeErrorUtils): void;
676
697
  /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
677
698
  _persistForTest(error: unknown): void;
@@ -784,6 +805,8 @@ declare class DeepdotsPopups {
784
805
  private analyticsFlushTimer;
785
806
  /** Crash & error reporting (#14–17). Null hasta init(). */
786
807
  private crashReporter;
808
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
809
+ private rnErrorUtils;
787
810
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
788
811
  private messageGuard;
789
812
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -852,8 +875,18 @@ declare class DeepdotsPopups {
852
875
  setMetric(key: string, value: string | number | boolean): void;
853
876
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
854
877
  reportError(error: unknown, options?: ReportErrorOptions): void;
855
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
878
+ /**
879
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
880
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
881
+ */
856
882
  installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
883
+ /**
884
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
885
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
886
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
887
+ * llama en `init()` y cada vez que se activa el tracking.
888
+ */
889
+ private installCrashHandlers;
857
890
  /**
858
891
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
859
892
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
package/dist/index.js CHANGED
@@ -7706,97 +7706,166 @@ async function safeBodyText(res) {
7706
7706
  return "";
7707
7707
  }
7708
7708
  }
7709
+ var PENDING_MAX = 50;
7710
+ var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
7711
+ var POST_TIMEOUT_MS = 2e4;
7712
+ function pendingKey(integration) {
7713
+ return `__deepdots_analytics_pending__:${integration}`;
7714
+ }
7715
+ function generateSessionId() {
7716
+ const c = globalThis.crypto;
7717
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
7718
+ const b = new Uint8Array(16);
7719
+ if (typeof c?.getRandomValues === "function") c.getRandomValues(b);
7720
+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
7721
+ b[6] = b[6] & 15 | 64;
7722
+ b[8] = b[8] & 63 | 128;
7723
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
7724
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
7725
+ }
7709
7726
  function createFeedbackSink(options) {
7710
- let feedbackSessionId = options.initialSessionId || void 0;
7711
- let firstPostInFlight = null;
7712
- let generation = 0;
7713
7727
  const url = `${options.baseUrl}/sdk/feedback`;
7714
- const post = async (body, final, sessionEnd = false) => {
7715
- const postGeneration = generation;
7728
+ const newSessionId = options.newSessionId ?? generateSessionId;
7729
+ const now = options.now ?? (() => Date.now());
7730
+ const storageKey = pendingKey(options.keys.integration);
7731
+ let feedbackSessionId = options.initialSessionId || void 0;
7732
+ let announcedId = feedbackSessionId;
7733
+ const pending2 = loadPending();
7734
+ let draining = null;
7735
+ function loadPending() {
7736
+ if (!options.storage) return [];
7737
+ try {
7738
+ const raw = options.storage.getItem(storageKey);
7739
+ const list = raw ? JSON.parse(raw) : [];
7740
+ return Array.isArray(list) ? list.filter((p) => p?.body && now() - p.at <= PENDING_MAX_AGE_MS) : [];
7741
+ } catch {
7742
+ return [];
7743
+ }
7744
+ }
7745
+ function savePending() {
7746
+ if (!options.storage) return;
7747
+ try {
7748
+ if (pending2.length) options.storage.setItem(storageKey, JSON.stringify(pending2));
7749
+ else options.storage.removeItem(storageKey);
7750
+ } catch {
7751
+ }
7752
+ }
7753
+ function addPending(batch) {
7754
+ pending2.push(batch);
7755
+ if (pending2.length > PENDING_MAX) pending2.splice(0, pending2.length - PENDING_MAX);
7756
+ savePending();
7757
+ }
7758
+ function removePending(batch) {
7759
+ const i = pending2.indexOf(batch);
7760
+ if (i >= 0) pending2.splice(i, 1);
7761
+ savePending();
7762
+ }
7763
+ async function post(body, final) {
7716
7764
  const json = JSON.stringify(body);
7717
7765
  const small = json.length <= KEEPALIVE_MAX_BYTES;
7718
7766
  if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
7719
7767
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
7720
7768
  if (!f) {
7721
7769
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
7722
- return;
7770
+ return "drop";
7723
7771
  }
7724
- const res = await f(url, {
7725
- method: "POST",
7726
- headers: { "Content-Type": "application/json" },
7727
- body: json,
7728
- // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
7729
- // petición con credenciales fallaría el CORS. Explícito para no depender del default.
7730
- credentials: "omit",
7731
- // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
7732
- ...small ? { keepalive: true } : {}
7733
- });
7734
- if (!res.ok) {
7772
+ let timer;
7773
+ try {
7774
+ const request = f(url, {
7775
+ method: "POST",
7776
+ headers: { "Content-Type": "application/json" },
7777
+ body: json,
7778
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
7779
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
7780
+ credentials: "omit",
7781
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
7782
+ ...small ? { keepalive: true } : {}
7783
+ });
7784
+ const timeout = new Promise((_, reject) => {
7785
+ timer = setTimeout(() => reject(new Error("timeout")), POST_TIMEOUT_MS);
7786
+ });
7787
+ const res = await Promise.race([request, timeout]);
7788
+ if (res.ok) return "ok";
7735
7789
  const detail = await safeBodyText(res);
7736
7790
  if (isRetryableStatus(res.status)) {
7737
- throw new Error(`POST /sdk/feedback ${res.status} (retryable): ${detail}`);
7791
+ options.log?.(`[DeepdotsAnalytics] POST /sdk/feedback ${res.status} (retryable): ${detail}`);
7792
+ return "retry";
7738
7793
  }
7739
7794
  options.log?.(
7740
7795
  `[DeepdotsAnalytics] POST /sdk/feedback rejected with ${res.status}; batch DISCARDED:`,
7741
7796
  detail
7742
7797
  );
7743
- return;
7744
- }
7745
- if (sessionEnd) {
7746
- let closedId = body.sessionId;
7747
- try {
7748
- const data = await res.json();
7749
- closedId = data?.sessionId ?? closedId;
7750
- } catch {
7751
- }
7752
- try {
7753
- if (closedId) options.onSessionClosed?.(closedId);
7754
- } catch {
7755
- }
7756
- return;
7798
+ return "drop";
7799
+ } catch (error) {
7800
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback failed (will retry):", error);
7801
+ return "retry";
7802
+ } finally {
7803
+ clearTimeout(timer);
7757
7804
  }
7805
+ }
7806
+ function delivered(body) {
7807
+ const id = body.sessionId;
7808
+ if (!id) return;
7758
7809
  try {
7759
- const data = await res.json();
7760
- if (postGeneration !== generation) {
7761
- options.log?.("[DeepdotsAnalytics] sessionId of a closed session ignored:", data?.sessionId);
7762
- return;
7763
- }
7764
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
7765
- feedbackSessionId = data.sessionId;
7766
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cached:", feedbackSessionId);
7767
- options.onSessionId?.(feedbackSessionId);
7810
+ if (body.completed) {
7811
+ options.onSessionClosed?.(id);
7812
+ } else if (id === feedbackSessionId && id !== announcedId) {
7813
+ announcedId = id;
7814
+ options.log?.("[DeepdotsAnalytics] feedback session open:", id);
7815
+ options.onSessionId?.(id);
7768
7816
  }
7769
7817
  } catch {
7770
7818
  }
7771
- };
7819
+ }
7820
+ async function send(batch, final, alreadyPending) {
7821
+ const result = await post(batch.body, final);
7822
+ if (result === "retry") {
7823
+ if (!alreadyPending) addPending(batch);
7824
+ return;
7825
+ }
7826
+ if (alreadyPending) removePending(batch);
7827
+ if (result === "ok") delivered(batch.body);
7828
+ }
7829
+ function drain() {
7830
+ const run = async () => {
7831
+ for (let i = 0; i < pending2.length; ) {
7832
+ const batch = pending2[i];
7833
+ await send(batch, false, true);
7834
+ if (pending2[i] === batch) return;
7835
+ }
7836
+ };
7837
+ const next = draining ? draining.then(run) : run();
7838
+ draining = next;
7839
+ void next.finally(() => {
7840
+ if (draining === next) draining = null;
7841
+ });
7842
+ return next;
7843
+ }
7844
+ if (pending2.length) {
7845
+ savePending();
7846
+ setTimeout(() => void drain(), 0);
7847
+ }
7772
7848
  return async (envelope, meta) => {
7773
7849
  if (meta?.resetSession) {
7774
7850
  feedbackSessionId = void 0;
7775
- firstPostInFlight = null;
7776
- generation++;
7777
7851
  options.onSessionReset?.();
7778
7852
  return;
7779
7853
  }
7780
7854
  const final = meta?.final === true;
7781
7855
  const sessionEnd = meta?.sessionEnd === true;
7782
- if (!feedbackSessionId && firstPostInFlight && !final) {
7783
- await firstPostInFlight;
7784
- }
7856
+ if (!feedbackSessionId) feedbackSessionId = newSessionId();
7785
7857
  const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
7786
7858
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
7787
- const sent = post(body, final, sessionEnd);
7859
+ const batch = { body, at: now() };
7788
7860
  if (sessionEnd) {
7789
7861
  feedbackSessionId = void 0;
7790
- firstPostInFlight = null;
7791
- generation++;
7792
7862
  options.onSessionReset?.();
7793
- } else if (!feedbackSessionId) {
7794
- firstPostInFlight = sent.then(
7795
- () => void 0,
7796
- () => void 0
7797
- );
7863
+ addPending(batch);
7864
+ await drain();
7865
+ return;
7798
7866
  }
7799
- await sent;
7867
+ const retries = pending2.length ? drain() : null;
7868
+ await Promise.all([retries, send(batch, final, false)]);
7800
7869
  };
7801
7870
  }
7802
7871
 
@@ -8216,6 +8285,10 @@ function crashRecordToParams(r) {
8216
8285
  }
8217
8286
  var CrashReporter = class {
8218
8287
  constructor(options) {
8288
+ /** Los handlers de `window` ya están puestos: `install()` es idempotente. */
8289
+ this.windowInstalled = false;
8290
+ /** ErrorUtils ya enganchados: engancharlos dos veces capturaría cada error dos veces. */
8291
+ this.hookedErrorUtils = /* @__PURE__ */ new Set();
8219
8292
  this.options = options;
8220
8293
  this.now = options.now ?? (() => Date.now());
8221
8294
  }
@@ -8291,9 +8364,11 @@ var CrashReporter = class {
8291
8364
  }
8292
8365
  return queue;
8293
8366
  }
8294
- /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
8367
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). Idempotente. */
8295
8368
  install() {
8369
+ if (this.windowInstalled) return;
8296
8370
  if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
8371
+ this.windowInstalled = true;
8297
8372
  window.addEventListener("error", (e) => {
8298
8373
  if (!this.isEnabled()) return;
8299
8374
  this.captureUnhandled(e.error ?? e.message ?? "error");
@@ -8303,8 +8378,10 @@ var CrashReporter = class {
8303
8378
  this.captureUnhandled(e.reason ?? "unhandledrejection");
8304
8379
  });
8305
8380
  }
8306
- /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
8381
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. Idempotente por instancia de ErrorUtils. */
8307
8382
  installReactNative(errorUtils) {
8383
+ if (this.hookedErrorUtils.has(errorUtils)) return;
8384
+ this.hookedErrorUtils.add(errorUtils);
8308
8385
  const previous = errorUtils.getGlobalHandler?.();
8309
8386
  errorUtils.setGlobalHandler((error, isFatal) => {
8310
8387
  if (this.isEnabled()) this.captureUnhandled(error);
@@ -8520,6 +8597,8 @@ var DeepdotsPopups = class {
8520
8597
  this.analyticsFlushTimer = void 0;
8521
8598
  /** Crash & error reporting (#14–17). Null hasta init(). */
8522
8599
  this.crashReporter = null;
8600
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
8601
+ this.rnErrorUtils = null;
8523
8602
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
8524
8603
  this.messageGuard = new MessageGuard();
8525
8604
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -8620,6 +8699,10 @@ var DeepdotsPopups = class {
8620
8699
  keys: config.analytics,
8621
8700
  log: (...a) => this.log(...a),
8622
8701
  initialSessionId: resumable?.sessionId,
8702
+ // En móvil (RN) la app puede morir con el lote de cierre sin entregar: se
8703
+ // persiste y se reenvía en el arranque siguiente. En web el cierre sale con
8704
+ // `keepalive` y varias pestañas compartirían el mismo storage.
8705
+ storage: (config.platform ?? "web") !== "web" ? storage : void 0,
8623
8706
  onSessionId: (id) => {
8624
8707
  this.analyticsFeedbackSessionId = id;
8625
8708
  this.notifyFeedbackSession(id, "open");
@@ -8655,9 +8738,7 @@ var DeepdotsPopups = class {
8655
8738
  now: () => Date.now(),
8656
8739
  enabled: () => this.tracking?.isTrackingEnabled() ?? false
8657
8740
  });
8658
- if (this.tracking?.isTrackingEnabled()) {
8659
- this.crashReporter.install();
8660
- }
8741
+ this.installCrashHandlers();
8661
8742
  this.geoEnabled = config.geolocation ?? true;
8662
8743
  this.geoWanted = this.geoEnabled && !!config.analytics;
8663
8744
  this.resolveGeo();
@@ -8726,6 +8807,7 @@ var DeepdotsPopups = class {
8726
8807
  if (!enabled) this.closeSession("tracking_disabled");
8727
8808
  this.tracking?.setTrackingEnabled(enabled);
8728
8809
  if (enabled) this.openSession("tracking_enabled");
8810
+ if (enabled) this.installCrashHandlers();
8729
8811
  if (enabled) this.resolveGeo();
8730
8812
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
8731
8813
  }
@@ -8756,10 +8838,27 @@ var DeepdotsPopups = class {
8756
8838
  if (!this.tracking?.isTrackingEnabled()) return;
8757
8839
  this.crashReporter?.reportError(error, options);
8758
8840
  }
8759
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
8841
+ /**
8842
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
8843
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
8844
+ */
8760
8845
  installReactNativeCrashHandler(errorUtils) {
8761
- if (!this.tracking?.isTrackingEnabled()) return;
8762
- this.crashReporter?.installReactNative(errorUtils);
8846
+ this.rnErrorUtils = errorUtils;
8847
+ this.installCrashHandlers();
8848
+ }
8849
+ /**
8850
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
8851
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
8852
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
8853
+ * llama en `init()` y cada vez que se activa el tracking.
8854
+ */
8855
+ installCrashHandlers() {
8856
+ if (!this.crashReporter || !this.tracking?.isTrackingEnabled()) return;
8857
+ this.crashReporter.install();
8858
+ const errorUtils = this.rnErrorUtils ?? globalThis.ErrorUtils;
8859
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
8860
+ this.crashReporter.installReactNative(errorUtils);
8861
+ }
8763
8862
  }
8764
8863
  /**
8765
8864
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el