@magicfeedback/popup-sdk 1.1.2 → 1.1.4

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.
@@ -216,6 +216,52 @@ interface PopupRenderer {
216
216
  type ContactAttributeValue = string | number | boolean;
217
217
  type ContactAttributes = Record<string, ContactAttributeValue>;
218
218
 
219
+ /**
220
+ * Crash & error reporting (Stability #14–17) — captura de errores GESTIONADOS.
221
+ *
222
+ * - `reportError()`: API pública para el host (app viva) → emite `deepdots_app_crash` ya.
223
+ * - handlers de `window` (errores no capturados): persisten a disco y se reenvían
224
+ * en el siguiente arranque (el proceso puede morir antes del flush normal).
225
+ *
226
+ * El contexto de device/sesión se captura EN EL MOMENTO del crash y se guarda en el
227
+ * record — no se lee del envelope en el replay (el replay puede ocurrir en otra
228
+ * app_version/OS). Las dims OS/device en Web suelen ir vacías (se derivan del UA en backend).
229
+ */
230
+
231
+ type CrashSeverity = 'fatal' | 'error' | 'warning';
232
+ interface ReportErrorOptions {
233
+ severity?: CrashSeverity;
234
+ handled?: boolean;
235
+ context?: Record<string, unknown>;
236
+ }
237
+ /** Forma mínima de `global.ErrorUtils` de React Native. */
238
+ interface ReactNativeErrorUtils {
239
+ getGlobalHandler?(): ((error: unknown, isFatal?: boolean) => void) | undefined;
240
+ setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void;
241
+ }
242
+
243
+ /**
244
+ * Messaging (#18–22): tracking de notificaciones del host (push / in-app).
245
+ * Un único evento reservado `deepdots_message` con un campo `stage` discriminador;
246
+ * el funnel se correlaciona por `message_id` y se agrupa por `message_title`.
247
+ */
248
+ type MessageStage = 'delivered' | 'clicked' | 'converted';
249
+ interface TrackMessageOptions {
250
+ /** Identificador del mensaje/notificación — correlaciona el funnel delivered→clicked→converted. */
251
+ id: string;
252
+ /** Título del mensaje — dimensión de agrupación de #18–22. */
253
+ title: string;
254
+ /** Canal de entrega. */
255
+ channel: 'push' | 'in_app';
256
+ /** Nombre de la campaña (opcional). */
257
+ campaign?: string;
258
+ /** Valor de conversión (opcional, típico en `stage: 'converted'`). */
259
+ value?: number;
260
+ currency?: string;
261
+ /** Parámetros libres adicionales. */
262
+ params?: Record<string, unknown>;
263
+ }
264
+
219
265
  /**
220
266
  * Main class for managing survey popups
221
267
  */
@@ -244,6 +290,8 @@ declare class DeepdotsPopups {
244
290
  private analyticsFeedbackSessionId;
245
291
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
246
292
  private analyticsFlushTimer;
293
+ /** Crash & error reporting (#14–17). Null hasta init(). */
294
+ private crashReporter;
247
295
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
248
296
  private navObserver;
249
297
  /** Tiempo activo (engagement time, #8). */
@@ -264,6 +312,10 @@ declare class DeepdotsPopups {
264
312
  track(name: string, params?: Record<string, unknown>): void;
265
313
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
266
314
  setUserAttributes(attributes: Record<string, string | number | boolean>): void;
315
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
316
+ reportError(error: unknown, options?: ReportErrorOptions): void;
317
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
318
+ installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
267
319
  /**
268
320
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
269
321
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -273,14 +325,16 @@ declare class DeepdotsPopups {
273
325
  setContactAttributes(attributes: ContactAttributes): Promise<boolean>;
274
326
  /** Marca el inicio de un mini-service; etiqueta los eventos siguientes. No-op si tracking off. */
275
327
  enterMiniService(name: string, entryPointType?: string): void;
276
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
277
- exitMiniService(): void;
328
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
329
+ exitMiniService(name: string): void;
278
330
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
279
331
  trackSearch(query: string, resultsCount: number, params?: Record<string, unknown>): void;
280
332
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
281
333
  trackFindabilityFriction(frictionTopic: string, params?: Record<string, unknown>): void;
282
334
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
283
335
  trackFunnelStep(funnel: string, step: string, taskId: string, params?: Record<string, unknown>): void;
336
+ /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
337
+ trackMessage(stage: MessageStage, options: TrackMessageOptions): void;
284
338
  /**
285
339
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
286
340
  * El host la llama en cada cambio de pantalla; emite `page_view` al salir de la anterior.
@@ -406,6 +460,8 @@ interface ReactNativeSetupDeps {
406
460
  appState?: AppStateLike | null;
407
461
  platform?: 'web' | 'android' | 'ios';
408
462
  renderer?: PopupRenderer;
463
+ /** `global.ErrorUtils` de RN (para capturar errores JS no manejados). Default: `globalThis.ErrorUtils`. */
464
+ errorUtils?: ReactNativeErrorUtils | null;
409
465
  }
410
466
  /**
411
467
  * Configura el SDK para React Native en una sola llamada: storage persistente (MMKV),
@@ -216,6 +216,52 @@ interface PopupRenderer {
216
216
  type ContactAttributeValue = string | number | boolean;
217
217
  type ContactAttributes = Record<string, ContactAttributeValue>;
218
218
 
219
+ /**
220
+ * Crash & error reporting (Stability #14–17) — captura de errores GESTIONADOS.
221
+ *
222
+ * - `reportError()`: API pública para el host (app viva) → emite `deepdots_app_crash` ya.
223
+ * - handlers de `window` (errores no capturados): persisten a disco y se reenvían
224
+ * en el siguiente arranque (el proceso puede morir antes del flush normal).
225
+ *
226
+ * El contexto de device/sesión se captura EN EL MOMENTO del crash y se guarda en el
227
+ * record — no se lee del envelope en el replay (el replay puede ocurrir en otra
228
+ * app_version/OS). Las dims OS/device en Web suelen ir vacías (se derivan del UA en backend).
229
+ */
230
+
231
+ type CrashSeverity = 'fatal' | 'error' | 'warning';
232
+ interface ReportErrorOptions {
233
+ severity?: CrashSeverity;
234
+ handled?: boolean;
235
+ context?: Record<string, unknown>;
236
+ }
237
+ /** Forma mínima de `global.ErrorUtils` de React Native. */
238
+ interface ReactNativeErrorUtils {
239
+ getGlobalHandler?(): ((error: unknown, isFatal?: boolean) => void) | undefined;
240
+ setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void;
241
+ }
242
+
243
+ /**
244
+ * Messaging (#18–22): tracking de notificaciones del host (push / in-app).
245
+ * Un único evento reservado `deepdots_message` con un campo `stage` discriminador;
246
+ * el funnel se correlaciona por `message_id` y se agrupa por `message_title`.
247
+ */
248
+ type MessageStage = 'delivered' | 'clicked' | 'converted';
249
+ interface TrackMessageOptions {
250
+ /** Identificador del mensaje/notificación — correlaciona el funnel delivered→clicked→converted. */
251
+ id: string;
252
+ /** Título del mensaje — dimensión de agrupación de #18–22. */
253
+ title: string;
254
+ /** Canal de entrega. */
255
+ channel: 'push' | 'in_app';
256
+ /** Nombre de la campaña (opcional). */
257
+ campaign?: string;
258
+ /** Valor de conversión (opcional, típico en `stage: 'converted'`). */
259
+ value?: number;
260
+ currency?: string;
261
+ /** Parámetros libres adicionales. */
262
+ params?: Record<string, unknown>;
263
+ }
264
+
219
265
  /**
220
266
  * Main class for managing survey popups
221
267
  */
@@ -244,6 +290,8 @@ declare class DeepdotsPopups {
244
290
  private analyticsFeedbackSessionId;
245
291
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
246
292
  private analyticsFlushTimer;
293
+ /** Crash & error reporting (#14–17). Null hasta init(). */
294
+ private crashReporter;
247
295
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
248
296
  private navObserver;
249
297
  /** Tiempo activo (engagement time, #8). */
@@ -264,6 +312,10 @@ declare class DeepdotsPopups {
264
312
  track(name: string, params?: Record<string, unknown>): void;
265
313
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
266
314
  setUserAttributes(attributes: Record<string, string | number | boolean>): void;
315
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
316
+ reportError(error: unknown, options?: ReportErrorOptions): void;
317
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
318
+ installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
267
319
  /**
268
320
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
269
321
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -273,14 +325,16 @@ declare class DeepdotsPopups {
273
325
  setContactAttributes(attributes: ContactAttributes): Promise<boolean>;
274
326
  /** Marca el inicio de un mini-service; etiqueta los eventos siguientes. No-op si tracking off. */
275
327
  enterMiniService(name: string, entryPointType?: string): void;
276
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
277
- exitMiniService(): void;
328
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
329
+ exitMiniService(name: string): void;
278
330
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
279
331
  trackSearch(query: string, resultsCount: number, params?: Record<string, unknown>): void;
280
332
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
281
333
  trackFindabilityFriction(frictionTopic: string, params?: Record<string, unknown>): void;
282
334
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
283
335
  trackFunnelStep(funnel: string, step: string, taskId: string, params?: Record<string, unknown>): void;
336
+ /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
337
+ trackMessage(stage: MessageStage, options: TrackMessageOptions): void;
284
338
  /**
285
339
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
286
340
  * El host la llama en cada cambio de pantalla; emite `page_view` al salir de la anterior.
@@ -406,6 +460,8 @@ interface ReactNativeSetupDeps {
406
460
  appState?: AppStateLike | null;
407
461
  platform?: 'web' | 'android' | 'ios';
408
462
  renderer?: PopupRenderer;
463
+ /** `global.ErrorUtils` de RN (para capturar errores JS no manejados). Default: `globalThis.ErrorUtils`. */
464
+ errorUtils?: ReactNativeErrorUtils | null;
409
465
  }
410
466
  /**
411
467
  * Configura el SDK para React Native en una sola llamada: storage persistente (MMKV),
@@ -1925,8 +1925,8 @@ var AnalyticsManager = class {
1925
1925
  constructor(options = {}) {
1926
1926
  this.events = [];
1927
1927
  this.attributes = {};
1928
- this.miniService = null;
1929
- this.miniServiceEnteredAt = 0;
1928
+ /** Mini-services activos: nombre → timestamp de entrada. El orden de inserción marca el "más reciente". */
1929
+ this.activeMiniServices = /* @__PURE__ */ new Map();
1930
1930
  this.sink = options.sink ?? dryRunSink;
1931
1931
  this.now = options.now ?? (() => Date.now());
1932
1932
  this.publicKey = options.publicKey;
@@ -1947,24 +1947,38 @@ var AnalyticsManager = class {
1947
1947
  this.attributes[k] = String(v);
1948
1948
  }
1949
1949
  }
1950
- /** Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`. */
1950
+ /**
1951
+ * Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`.
1952
+ * Admite varios activos a la vez (concurrencia); el "actual" para etiquetar es el más reciente.
1953
+ * Reentrar con un nombre ya activo refresca su orden y su tiempo de entrada.
1954
+ */
1951
1955
  enterMiniService(name, entryPointType) {
1952
- this.miniService = name;
1953
- this.miniServiceEnteredAt = this.now();
1954
- this.track("mini_service_enter", { entry_point_type: entryPointType ?? null });
1955
- }
1956
- /** Cierra el mini-service activo emitiendo `mini_service_exit` con su duración (#27). No-op si no hay ninguno. */
1957
- exitMiniService() {
1958
- const name = this.miniService;
1959
- if (name == null) return;
1960
- const durationSeconds = Math.max(0, Math.round((this.now() - this.miniServiceEnteredAt) / 1e3));
1961
- this.miniService = null;
1962
- this.track("mini_service_exit", { mini_service: name, duration_seconds: durationSeconds });
1956
+ this.activeMiniServices.delete(name);
1957
+ this.activeMiniServices.set(name, this.now());
1958
+ this.track("deepdots_mini_service_enter", { entry_point_type: entryPointType ?? null });
1959
+ }
1960
+ /**
1961
+ * Cierra el mini-service `name` emitiendo `mini_service_exit` con su duración (#27).
1962
+ * No-op si ese nombre no está activo. Cierre coherente con concurrencia.
1963
+ */
1964
+ exitMiniService(name) {
1965
+ const enteredAt = this.activeMiniServices.get(name);
1966
+ if (enteredAt === void 0) return;
1967
+ const durationSeconds = Math.max(0, Math.round((this.now() - enteredAt) / 1e3));
1968
+ this.activeMiniServices.delete(name);
1969
+ this.track("deepdots_mini_service_exit", { mini_service: name, duration_seconds: durationSeconds });
1970
+ }
1971
+ /** Cierra TODOS los mini-services activos (orden LIFO). Para el cierre por lifecycle (background/cierre). */
1972
+ exitAllMiniServices() {
1973
+ for (const name of Array.from(this.activeMiniServices.keys()).reverse()) {
1974
+ this.exitMiniService(name);
1975
+ }
1963
1976
  }
1964
1977
  /** Registra un evento de analítica (modelo GA: nombre + params). */
1965
1978
  track(name, params) {
1979
+ const current = this.getMiniService();
1966
1980
  const merged = {
1967
- ...this.miniService ? { mini_service: this.miniService } : {},
1981
+ ...current ? { mini_service: current } : {},
1968
1982
  ...params ?? {}
1969
1983
  };
1970
1984
  this.events.push({
@@ -1976,9 +1990,11 @@ var AnalyticsManager = class {
1976
1990
  this.onFlushNeeded?.();
1977
1991
  }
1978
1992
  }
1979
- /** Mini-service activo (para inyectarlo en la metadata del survey, #33). */
1993
+ /** Mini-service actual (el más reciente aún activo) para etiquetar eventos + metadata del survey (#33). */
1980
1994
  getMiniService() {
1981
- return this.miniService;
1995
+ let last = null;
1996
+ for (const name of this.activeMiniServices.keys()) last = name;
1997
+ return last;
1982
1998
  }
1983
1999
  /** Nº de eventos pendientes de flush. */
1984
2000
  pending() {
@@ -2318,11 +2334,153 @@ var ContactManager = class {
2318
2334
  }
2319
2335
  };
2320
2336
 
2337
+ // src/analytics/crash-reporter.ts
2338
+ var STACK_MAX = 8e3;
2339
+ var QUEUE_KEY = "deepdots.crash.queue";
2340
+ var MAX_QUEUED = 20;
2341
+ function crashRecordToParams(r) {
2342
+ const params = {
2343
+ crashed_at: r.crashedAt,
2344
+ crash_type: r.crashType,
2345
+ message: r.message,
2346
+ stack: r.stack,
2347
+ fatal: r.fatal,
2348
+ handled: r.handled,
2349
+ severity: r.severity
2350
+ };
2351
+ if (r.sessionId) params.crashed_session_id = r.sessionId;
2352
+ if (r.appVersion) params.crashed_app_version = r.appVersion;
2353
+ if (r.osVersion) params.crashed_os_version = r.osVersion;
2354
+ if (r.deviceModel) params.crashed_device_model = r.deviceModel;
2355
+ if (r.context) {
2356
+ for (const [k, v] of Object.entries(r.context)) params[`ctx_${k}`] = v;
2357
+ }
2358
+ return params;
2359
+ }
2360
+ var CrashReporter = class {
2361
+ constructor(options) {
2362
+ this.options = options;
2363
+ this.now = options.now ?? (() => Date.now());
2364
+ }
2365
+ isEnabled() {
2366
+ return this.options.enabled ? this.options.enabled() : true;
2367
+ }
2368
+ /** Construye un CrashRecord capturando contexto en el momento del crash. */
2369
+ buildRecord(error, severity, handled, fatal, context) {
2370
+ const err = error;
2371
+ const isErr = error instanceof Error;
2372
+ const dev = this.options.device();
2373
+ const ctx = context ? Object.fromEntries(Object.entries(context).map(([k, v]) => [k, String(v)])) : void 0;
2374
+ return {
2375
+ crashedAt: this.now(),
2376
+ crashType: isErr && err.name || "Error",
2377
+ message: isErr ? String(err.message ?? "") : String(error),
2378
+ stack: (isErr && typeof err.stack === "string" ? err.stack : "").slice(0, STACK_MAX),
2379
+ fatal,
2380
+ handled,
2381
+ severity,
2382
+ sessionId: this.options.sessionId(),
2383
+ appVersion: dev.appVersion,
2384
+ osVersion: dev.osVersion,
2385
+ deviceModel: dev.deviceModel,
2386
+ context: ctx
2387
+ };
2388
+ }
2389
+ /** API pública del host: reporta un error (app viva) → emite el evento ya. */
2390
+ reportError(error, options = {}) {
2391
+ if (!this.isEnabled()) return;
2392
+ const severity = options.severity ?? "error";
2393
+ const handled = options.handled ?? true;
2394
+ const fatal = severity === "fatal";
2395
+ const record = this.buildRecord(error, severity, handled, fatal, options.context);
2396
+ this.options.emit(crashRecordToParams(record));
2397
+ }
2398
+ /** Captura un error no manejado (fatal) → persiste a disco para replay en el siguiente arranque. */
2399
+ captureUnhandled(error) {
2400
+ this.persist(this.buildRecord(error, "fatal", false, true));
2401
+ }
2402
+ /** Persiste un crash no capturado a disco para reenviarlo en el siguiente arranque. */
2403
+ persist(record) {
2404
+ let queue = [];
2405
+ try {
2406
+ const raw = this.options.storage.getItem(QUEUE_KEY);
2407
+ if (raw) queue = JSON.parse(raw);
2408
+ if (!Array.isArray(queue)) queue = [];
2409
+ } catch {
2410
+ queue = [];
2411
+ }
2412
+ queue.push(record);
2413
+ if (queue.length > MAX_QUEUED) queue = queue.slice(queue.length - MAX_QUEUED);
2414
+ try {
2415
+ this.options.storage.setItem(QUEUE_KEY, JSON.stringify(queue));
2416
+ } catch {
2417
+ }
2418
+ }
2419
+ /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
2420
+ drainPendingCrashes() {
2421
+ let queue = [];
2422
+ try {
2423
+ const raw = this.options.storage.getItem(QUEUE_KEY);
2424
+ if (raw) queue = JSON.parse(raw);
2425
+ if (!Array.isArray(queue)) queue = [];
2426
+ } catch {
2427
+ queue = [];
2428
+ }
2429
+ if (queue.length) {
2430
+ try {
2431
+ this.options.storage.removeItem(QUEUE_KEY);
2432
+ } catch {
2433
+ }
2434
+ }
2435
+ return queue;
2436
+ }
2437
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
2438
+ install() {
2439
+ if (typeof window === "undefined") return;
2440
+ window.addEventListener("error", (e) => {
2441
+ if (!this.isEnabled()) return;
2442
+ this.captureUnhandled(e.error ?? e.message ?? "error");
2443
+ });
2444
+ window.addEventListener("unhandledrejection", (e) => {
2445
+ if (!this.isEnabled()) return;
2446
+ this.captureUnhandled(e.reason ?? "unhandledrejection");
2447
+ });
2448
+ }
2449
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
2450
+ installReactNative(errorUtils) {
2451
+ const previous = errorUtils.getGlobalHandler?.();
2452
+ errorUtils.setGlobalHandler((error, isFatal) => {
2453
+ if (this.isEnabled()) this.captureUnhandled(error);
2454
+ previous?.(error, isFatal);
2455
+ });
2456
+ }
2457
+ /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
2458
+ _persistForTest(error) {
2459
+ this.captureUnhandled(error);
2460
+ }
2461
+ };
2462
+
2463
+ // src/analytics/messaging.ts
2464
+ function buildMessageParams(stage, o) {
2465
+ const p = {
2466
+ stage,
2467
+ message_id: o.id,
2468
+ message_title: o.title,
2469
+ channel: o.channel
2470
+ };
2471
+ if (o.campaign) p.campaign = o.campaign;
2472
+ if (o.value !== void 0) p.value = o.value;
2473
+ if (o.currency) p.currency = o.currency;
2474
+ return { ...p, ...o.params ?? {} };
2475
+ }
2476
+
2321
2477
  // src/core/deepdots-popups.ts
2322
2478
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
2323
2479
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
2324
2480
  var ANALYTICS_MAX_BATCH_SIZE = 20;
2325
2481
  var ANALYTICS_FLUSH_INTERVAL_MS = 3e4;
2482
+ var RESERVED_EVENT_PREFIX = "deepdots_";
2483
+ var CUSTOM_EVENT_PREFIX = "deepdots_event_";
2326
2484
  var DeepdotsPopups = class {
2327
2485
  constructor() {
2328
2486
  this.config = null;
@@ -2352,6 +2510,8 @@ var DeepdotsPopups = class {
2352
2510
  this.analyticsFeedbackSessionId = void 0;
2353
2511
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
2354
2512
  this.analyticsFlushTimer = void 0;
2513
+ /** Crash & error reporting (#14–17). Null hasta init(). */
2514
+ this.crashReporter = null;
2355
2515
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
2356
2516
  this.navObserver = null;
2357
2517
  /** Tiempo activo (engagement time, #8). */
@@ -2403,25 +2563,44 @@ var DeepdotsPopups = class {
2403
2563
  this.analyticsFeedbackSessionId = id;
2404
2564
  }
2405
2565
  }) : void 0;
2566
+ const device = config.device ?? collectDeviceInfo(config.appVersion);
2406
2567
  this.analytics = new AnalyticsManager({
2407
2568
  sink: analyticsSink,
2408
2569
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
2409
2570
  language: typeof navigator !== "undefined" ? navigator.language : void 0,
2410
2571
  platform: config.platform ?? "web",
2411
- device: config.device ?? collectDeviceInfo(config.appVersion),
2572
+ device,
2412
2573
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
2413
2574
  onFlushNeeded: () => this.flushAnalytics()
2414
2575
  });
2576
+ this.crashReporter = new CrashReporter({
2577
+ storage,
2578
+ emit: (params) => this.track("deepdots_app_crash", params),
2579
+ device: () => ({ appVersion: device.app_version, osVersion: device.os_version, deviceModel: device.device_model }),
2580
+ sessionId: () => this.tracking?.getSessionId() ?? null,
2581
+ now: () => Date.now(),
2582
+ enabled: () => this.tracking?.isTrackingEnabled() ?? false
2583
+ });
2584
+ if (this.tracking?.isTrackingEnabled()) {
2585
+ this.crashReporter.install();
2586
+ }
2415
2587
  collectGeoInfo().then((geo) => {
2416
2588
  if (geo) this.analytics?.updateDevice(geo);
2417
2589
  }).catch(() => {
2418
2590
  });
2419
2591
  this.navObserver = new NavigationObserver();
2420
- this.navObserver.onVisit((v) => this.track("page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
2592
+ this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
2421
2593
  this.navObserver.install();
2422
2594
  this.engagement = new EngagementTracker();
2423
2595
  this.engagement.resume();
2424
2596
  this.setupAnalyticsFlush();
2597
+ this.track("deepdots_session_start", {});
2598
+ const pendingCrashes = this.crashReporter.drainPendingCrashes();
2599
+ if (this.tracking?.isTrackingEnabled()) {
2600
+ for (const rec of pendingCrashes) {
2601
+ this.track("deepdots_app_crash", crashRecordToParams(rec));
2602
+ }
2603
+ }
2425
2604
  this.initialized = true;
2426
2605
  this.log("SDK initialized", this.config);
2427
2606
  if (this.renderer.init) this.renderer.init();
@@ -2454,14 +2633,25 @@ var DeepdotsPopups = class {
2454
2633
  /** Registra un evento de analítica (modelo GA: nombre + params). No-op si tracking off. */
2455
2634
  track(name, params) {
2456
2635
  if (!this.tracking?.isTrackingEnabled()) return;
2457
- this.analytics?.track(name, params);
2458
- this.log("analytics \xB7 track:", name, params ?? {});
2636
+ const eventName = name.startsWith(RESERVED_EVENT_PREFIX) ? name : `${CUSTOM_EVENT_PREFIX}${name}`;
2637
+ this.analytics?.track(eventName, params);
2638
+ this.log("analytics \xB7 track:", eventName, params ?? {});
2459
2639
  }
2460
2640
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
2461
2641
  setUserAttributes(attributes) {
2462
2642
  if (!this.tracking?.isTrackingEnabled()) return;
2463
2643
  this.analytics?.setUserAttributes(attributes);
2464
2644
  }
2645
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
2646
+ reportError(error, options) {
2647
+ if (!this.tracking?.isTrackingEnabled()) return;
2648
+ this.crashReporter?.reportError(error, options);
2649
+ }
2650
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
2651
+ installReactNativeCrashHandler(errorUtils) {
2652
+ if (!this.tracking?.isTrackingEnabled()) return;
2653
+ this.crashReporter?.installReactNative(errorUtils);
2654
+ }
2465
2655
  /**
2466
2656
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
2467
2657
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -2479,23 +2669,27 @@ var DeepdotsPopups = class {
2479
2669
  this.analytics?.enterMiniService(name, entryPointType);
2480
2670
  this.log("analytics \xB7 enterMiniService:", name, entryPointType ?? "");
2481
2671
  }
2482
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
2483
- exitMiniService() {
2672
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
2673
+ exitMiniService(name) {
2484
2674
  if (!this.tracking?.isTrackingEnabled()) return;
2485
- this.analytics?.exitMiniService();
2486
- this.log("analytics \xB7 exitMiniService");
2675
+ this.analytics?.exitMiniService(name);
2676
+ this.log("analytics \xB7 exitMiniService:", name);
2487
2677
  }
2488
2678
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
2489
2679
  trackSearch(query, resultsCount, params) {
2490
- this.track("search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
2680
+ this.track("deepdots_search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
2491
2681
  }
2492
2682
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
2493
2683
  trackFindabilityFriction(frictionTopic, params) {
2494
- this.track("findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
2684
+ this.track("deepdots_findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
2495
2685
  }
2496
2686
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
2497
2687
  trackFunnelStep(funnel, step, taskId, params) {
2498
- this.track("funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
2688
+ this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
2689
+ }
2690
+ /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
2691
+ trackMessage(stage, options) {
2692
+ this.track("deepdots_message", buildMessageParams(stage, options));
2499
2693
  }
2500
2694
  /**
2501
2695
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
@@ -2521,7 +2715,7 @@ var DeepdotsPopups = class {
2521
2715
  onBackground() {
2522
2716
  this.navObserver?.stop();
2523
2717
  this.navStarted = false;
2524
- this.exitMiniService();
2718
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
2525
2719
  this.flushEngagement();
2526
2720
  this.engagement?.pause();
2527
2721
  this.flushAnalytics();
@@ -2544,7 +2738,7 @@ var DeepdotsPopups = class {
2544
2738
  flushEngagement() {
2545
2739
  if (!this.tracking?.isTrackingEnabled()) return;
2546
2740
  const ms = this.engagement?.consume() ?? 0;
2547
- if (ms > 0) this.track("user_engagement", { engagement_time_msec: ms });
2741
+ if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
2548
2742
  }
2549
2743
  analyticsIdentity() {
2550
2744
  return {
@@ -2559,7 +2753,7 @@ var DeepdotsPopups = class {
2559
2753
  window.addEventListener("pagehide", () => {
2560
2754
  clearInterval(this.analyticsFlushTimer);
2561
2755
  this.navObserver?.stop();
2562
- this.exitMiniService();
2756
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
2563
2757
  this.flushEngagement();
2564
2758
  this.flushAnalytics();
2565
2759
  });
@@ -3225,6 +3419,10 @@ function setupReactNative(sdk2, config, deps = {}) {
3225
3419
  const device = deps.deviceInfo ? collectRnDevice(deps.deviceInfo) : config.device;
3226
3420
  const platform = deps.platform ?? config.platform;
3227
3421
  sdk2.init({ ...config, storage, device, platform });
3422
+ const errorUtils = deps.errorUtils ?? globalThis.ErrorUtils;
3423
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
3424
+ sdk2.installReactNativeCrashHandler(errorUtils);
3425
+ }
3228
3426
  let subscription;
3229
3427
  if (deps.appState?.addEventListener) {
3230
3428
  subscription = deps.appState.addEventListener("change", (state) => {