@magicfeedback/popup-sdk 1.1.2 → 1.1.3

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
@@ -352,6 +352,85 @@ declare class ContactManager {
352
352
  setAttributes(attributes: ContactAttributes): Promise<boolean>;
353
353
  }
354
354
 
355
+ /**
356
+ * Crash & error reporting (Stability #14–17) — captura de errores GESTIONADOS.
357
+ *
358
+ * - `reportError()`: API pública para el host (app viva) → emite `deepdots_app_crash` ya.
359
+ * - handlers de `window` (errores no capturados): persisten a disco y se reenvían
360
+ * en el siguiente arranque (el proceso puede morir antes del flush normal).
361
+ *
362
+ * El contexto de device/sesión se captura EN EL MOMENTO del crash y se guarda en el
363
+ * record — no se lee del envelope en el replay (el replay puede ocurrir en otra
364
+ * app_version/OS). Las dims OS/device en Web suelen ir vacías (se derivan del UA en backend).
365
+ */
366
+
367
+ type CrashSeverity = 'fatal' | 'error' | 'warning';
368
+ interface CrashRecord {
369
+ crashedAt: number;
370
+ crashType: string;
371
+ message: string;
372
+ stack: string;
373
+ fatal: boolean;
374
+ handled: boolean;
375
+ severity: CrashSeverity;
376
+ sessionId: string | null;
377
+ appVersion?: string;
378
+ osVersion?: string;
379
+ deviceModel?: string;
380
+ context?: Record<string, string>;
381
+ }
382
+ interface ReportErrorOptions {
383
+ severity?: CrashSeverity;
384
+ handled?: boolean;
385
+ context?: Record<string, unknown>;
386
+ }
387
+ interface DeviceSnapshot {
388
+ appVersion?: string;
389
+ osVersion?: string;
390
+ deviceModel?: string;
391
+ }
392
+ /** Forma mínima de `global.ErrorUtils` de React Native. */
393
+ interface ReactNativeErrorUtils {
394
+ getGlobalHandler?(): ((error: unknown, isFatal?: boolean) => void) | undefined;
395
+ setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void;
396
+ }
397
+ interface CrashReporterOptions {
398
+ storage: KeyValueStorage;
399
+ /** Emite un evento deepdots_app_crash AHORA (app viva). */
400
+ emit: (params: Record<string, unknown>) => void;
401
+ /** Snapshot de device en el momento del crash. */
402
+ device: () => DeviceSnapshot;
403
+ /** session_id en el momento del crash. */
404
+ sessionId: () => string | null;
405
+ now?: () => number;
406
+ /** Kill-switch de consentimiento. */
407
+ enabled?: () => boolean;
408
+ }
409
+ /** Convierte un CrashRecord en los params del evento `deepdots_app_crash` (omite undefined). */
410
+ declare function crashRecordToParams(r: CrashRecord): Record<string, unknown>;
411
+ declare class CrashReporter {
412
+ private options;
413
+ private now;
414
+ constructor(options: CrashReporterOptions);
415
+ private isEnabled;
416
+ /** Construye un CrashRecord capturando contexto en el momento del crash. */
417
+ private buildRecord;
418
+ /** API pública del host: reporta un error (app viva) → emite el evento ya. */
419
+ reportError(error: unknown, options?: ReportErrorOptions): void;
420
+ /** Captura un error no manejado (fatal) → persiste a disco para replay en el siguiente arranque. */
421
+ captureUnhandled(error: unknown): void;
422
+ /** Persiste un crash no capturado a disco para reenviarlo en el siguiente arranque. */
423
+ private persist;
424
+ /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
425
+ drainPendingCrashes(): CrashRecord[];
426
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
427
+ install(): void;
428
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
429
+ installReactNative(errorUtils: ReactNativeErrorUtils): void;
430
+ /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
431
+ _persistForTest(error: unknown): void;
432
+ }
433
+
355
434
  /**
356
435
  * Main class for managing survey popups
357
436
  */
@@ -380,6 +459,8 @@ declare class DeepdotsPopups {
380
459
  private analyticsFeedbackSessionId;
381
460
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
382
461
  private analyticsFlushTimer;
462
+ /** Crash & error reporting (#14–17). Null hasta init(). */
463
+ private crashReporter;
383
464
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
384
465
  private navObserver;
385
466
  /** Tiempo activo (engagement time, #8). */
@@ -400,6 +481,10 @@ declare class DeepdotsPopups {
400
481
  track(name: string, params?: Record<string, unknown>): void;
401
482
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
402
483
  setUserAttributes(attributes: Record<string, string | number | boolean>): void;
484
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
485
+ reportError(error: unknown, options?: ReportErrorOptions): void;
486
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
487
+ installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
403
488
  /**
404
489
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
405
490
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -409,8 +494,8 @@ declare class DeepdotsPopups {
409
494
  setContactAttributes(attributes: ContactAttributes): Promise<boolean>;
410
495
  /** Marca el inicio de un mini-service; etiqueta los eventos siguientes. No-op si tracking off. */
411
496
  enterMiniService(name: string, entryPointType?: string): void;
412
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
413
- exitMiniService(): void;
497
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
498
+ exitMiniService(name: string): void;
414
499
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
415
500
  trackSearch(query: string, resultsCount: number, params?: Record<string, unknown>): void;
416
501
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
@@ -610,6 +695,8 @@ interface ReactNativeSetupDeps {
610
695
  appState?: AppStateLike | null;
611
696
  platform?: 'web' | 'android' | 'ios';
612
697
  renderer?: PopupRenderer;
698
+ /** `global.ErrorUtils` de RN (para capturar errores JS no manejados). Default: `globalThis.ErrorUtils`. */
699
+ errorUtils?: ReactNativeErrorUtils | null;
613
700
  }
614
701
  /** Adaptador KeyValueStorage (síncrono) sobre una instancia de MMKV. */
615
702
  declare function mmkvStorage(mmkv: MmkvLike): KeyValueStorage;
@@ -622,4 +709,4 @@ declare function collectRnDevice(d: DeviceInfoLike): DeviceInfo;
622
709
  */
623
710
  declare function setupReactNative(sdk: DeepdotsPopups, config: DeepdotsInitParams, deps?: ReactNativeSetupDeps): () => void;
624
711
 
625
- export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type TriggerConfig, buildAnalyticsFeedbackBody, buildSurveyHtml, collectRnDevice, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };
712
+ export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type CrashRecord, CrashReporter, type CrashSeverity, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type DeviceSnapshot, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, type ReactNativeErrorUtils, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type ReportErrorOptions, type TriggerConfig, buildAnalyticsFeedbackBody, buildSurveyHtml, collectRnDevice, crashRecordToParams, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };
package/dist/index.d.ts CHANGED
@@ -352,6 +352,85 @@ declare class ContactManager {
352
352
  setAttributes(attributes: ContactAttributes): Promise<boolean>;
353
353
  }
354
354
 
355
+ /**
356
+ * Crash & error reporting (Stability #14–17) — captura de errores GESTIONADOS.
357
+ *
358
+ * - `reportError()`: API pública para el host (app viva) → emite `deepdots_app_crash` ya.
359
+ * - handlers de `window` (errores no capturados): persisten a disco y se reenvían
360
+ * en el siguiente arranque (el proceso puede morir antes del flush normal).
361
+ *
362
+ * El contexto de device/sesión se captura EN EL MOMENTO del crash y se guarda en el
363
+ * record — no se lee del envelope en el replay (el replay puede ocurrir en otra
364
+ * app_version/OS). Las dims OS/device en Web suelen ir vacías (se derivan del UA en backend).
365
+ */
366
+
367
+ type CrashSeverity = 'fatal' | 'error' | 'warning';
368
+ interface CrashRecord {
369
+ crashedAt: number;
370
+ crashType: string;
371
+ message: string;
372
+ stack: string;
373
+ fatal: boolean;
374
+ handled: boolean;
375
+ severity: CrashSeverity;
376
+ sessionId: string | null;
377
+ appVersion?: string;
378
+ osVersion?: string;
379
+ deviceModel?: string;
380
+ context?: Record<string, string>;
381
+ }
382
+ interface ReportErrorOptions {
383
+ severity?: CrashSeverity;
384
+ handled?: boolean;
385
+ context?: Record<string, unknown>;
386
+ }
387
+ interface DeviceSnapshot {
388
+ appVersion?: string;
389
+ osVersion?: string;
390
+ deviceModel?: string;
391
+ }
392
+ /** Forma mínima de `global.ErrorUtils` de React Native. */
393
+ interface ReactNativeErrorUtils {
394
+ getGlobalHandler?(): ((error: unknown, isFatal?: boolean) => void) | undefined;
395
+ setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void;
396
+ }
397
+ interface CrashReporterOptions {
398
+ storage: KeyValueStorage;
399
+ /** Emite un evento deepdots_app_crash AHORA (app viva). */
400
+ emit: (params: Record<string, unknown>) => void;
401
+ /** Snapshot de device en el momento del crash. */
402
+ device: () => DeviceSnapshot;
403
+ /** session_id en el momento del crash. */
404
+ sessionId: () => string | null;
405
+ now?: () => number;
406
+ /** Kill-switch de consentimiento. */
407
+ enabled?: () => boolean;
408
+ }
409
+ /** Convierte un CrashRecord en los params del evento `deepdots_app_crash` (omite undefined). */
410
+ declare function crashRecordToParams(r: CrashRecord): Record<string, unknown>;
411
+ declare class CrashReporter {
412
+ private options;
413
+ private now;
414
+ constructor(options: CrashReporterOptions);
415
+ private isEnabled;
416
+ /** Construye un CrashRecord capturando contexto en el momento del crash. */
417
+ private buildRecord;
418
+ /** API pública del host: reporta un error (app viva) → emite el evento ya. */
419
+ reportError(error: unknown, options?: ReportErrorOptions): void;
420
+ /** Captura un error no manejado (fatal) → persiste a disco para replay en el siguiente arranque. */
421
+ captureUnhandled(error: unknown): void;
422
+ /** Persiste un crash no capturado a disco para reenviarlo en el siguiente arranque. */
423
+ private persist;
424
+ /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
425
+ drainPendingCrashes(): CrashRecord[];
426
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
427
+ install(): void;
428
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
429
+ installReactNative(errorUtils: ReactNativeErrorUtils): void;
430
+ /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
431
+ _persistForTest(error: unknown): void;
432
+ }
433
+
355
434
  /**
356
435
  * Main class for managing survey popups
357
436
  */
@@ -380,6 +459,8 @@ declare class DeepdotsPopups {
380
459
  private analyticsFeedbackSessionId;
381
460
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
382
461
  private analyticsFlushTimer;
462
+ /** Crash & error reporting (#14–17). Null hasta init(). */
463
+ private crashReporter;
383
464
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
384
465
  private navObserver;
385
466
  /** Tiempo activo (engagement time, #8). */
@@ -400,6 +481,10 @@ declare class DeepdotsPopups {
400
481
  track(name: string, params?: Record<string, unknown>): void;
401
482
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
402
483
  setUserAttributes(attributes: Record<string, string | number | boolean>): void;
484
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
485
+ reportError(error: unknown, options?: ReportErrorOptions): void;
486
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
487
+ installReactNativeCrashHandler(errorUtils: ReactNativeErrorUtils): void;
403
488
  /**
404
489
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
405
490
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -409,8 +494,8 @@ declare class DeepdotsPopups {
409
494
  setContactAttributes(attributes: ContactAttributes): Promise<boolean>;
410
495
  /** Marca el inicio de un mini-service; etiqueta los eventos siguientes. No-op si tracking off. */
411
496
  enterMiniService(name: string, entryPointType?: string): void;
412
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
413
- exitMiniService(): void;
497
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
498
+ exitMiniService(name: string): void;
414
499
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
415
500
  trackSearch(query: string, resultsCount: number, params?: Record<string, unknown>): void;
416
501
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
@@ -610,6 +695,8 @@ interface ReactNativeSetupDeps {
610
695
  appState?: AppStateLike | null;
611
696
  platform?: 'web' | 'android' | 'ios';
612
697
  renderer?: PopupRenderer;
698
+ /** `global.ErrorUtils` de RN (para capturar errores JS no manejados). Default: `globalThis.ErrorUtils`. */
699
+ errorUtils?: ReactNativeErrorUtils | null;
613
700
  }
614
701
  /** Adaptador KeyValueStorage (síncrono) sobre una instancia de MMKV. */
615
702
  declare function mmkvStorage(mmkv: MmkvLike): KeyValueStorage;
@@ -622,4 +709,4 @@ declare function collectRnDevice(d: DeviceInfoLike): DeviceInfo;
622
709
  */
623
710
  declare function setupReactNative(sdk: DeepdotsPopups, config: DeepdotsInitParams, deps?: ReactNativeSetupDeps): () => void;
624
711
 
625
- export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type TriggerConfig, buildAnalyticsFeedbackBody, buildSurveyHtml, collectRnDevice, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };
712
+ export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type CrashRecord, CrashReporter, type CrashSeverity, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type DeviceSnapshot, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, type ReactNativeErrorUtils, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type ReportErrorOptions, type TriggerConfig, buildAnalyticsFeedbackBody, buildSurveyHtml, collectRnDevice, crashRecordToParams, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };
package/dist/index.js CHANGED
@@ -3458,6 +3458,7 @@ var index_exports = {};
3458
3458
  __export(index_exports, {
3459
3459
  BrowserPopupRenderer: () => BrowserPopupRenderer,
3460
3460
  ContactManager: () => ContactManager,
3461
+ CrashReporter: () => CrashReporter,
3461
3462
  DeepdotsPopups: () => DeepdotsPopups,
3462
3463
  InMemoryStorage: () => InMemoryStorage,
3463
3464
  NoopPopupRenderer: () => NoopPopupRenderer,
@@ -3466,6 +3467,7 @@ __export(index_exports, {
3466
3467
  buildAnalyticsFeedbackBody: () => buildAnalyticsFeedbackBody,
3467
3468
  buildSurveyHtml: () => buildSurveyHtml,
3468
3469
  collectRnDevice: () => collectRnDevice,
3470
+ crashRecordToParams: () => crashRecordToParams,
3469
3471
  createDefaultRenderer: () => createDefaultRenderer,
3470
3472
  createFeedbackSink: () => createFeedbackSink,
3471
3473
  mmkvStorage: () => mmkvStorage,
@@ -3860,8 +3862,8 @@ var AnalyticsManager = class {
3860
3862
  constructor(options = {}) {
3861
3863
  this.events = [];
3862
3864
  this.attributes = {};
3863
- this.miniService = null;
3864
- this.miniServiceEnteredAt = 0;
3865
+ /** Mini-services activos: nombre → timestamp de entrada. El orden de inserción marca el "más reciente". */
3866
+ this.activeMiniServices = /* @__PURE__ */ new Map();
3865
3867
  this.sink = options.sink ?? dryRunSink;
3866
3868
  this.now = options.now ?? (() => Date.now());
3867
3869
  this.publicKey = options.publicKey;
@@ -3882,24 +3884,38 @@ var AnalyticsManager = class {
3882
3884
  this.attributes[k] = String(v);
3883
3885
  }
3884
3886
  }
3885
- /** Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`. */
3887
+ /**
3888
+ * Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`.
3889
+ * Admite varios activos a la vez (concurrencia); el "actual" para etiquetar es el más reciente.
3890
+ * Reentrar con un nombre ya activo refresca su orden y su tiempo de entrada.
3891
+ */
3886
3892
  enterMiniService(name, entryPointType) {
3887
- this.miniService = name;
3888
- this.miniServiceEnteredAt = this.now();
3889
- this.track("mini_service_enter", { entry_point_type: entryPointType ?? null });
3890
- }
3891
- /** Cierra el mini-service activo emitiendo `mini_service_exit` con su duración (#27). No-op si no hay ninguno. */
3892
- exitMiniService() {
3893
- const name = this.miniService;
3894
- if (name == null) return;
3895
- const durationSeconds = Math.max(0, Math.round((this.now() - this.miniServiceEnteredAt) / 1e3));
3896
- this.miniService = null;
3897
- this.track("mini_service_exit", { mini_service: name, duration_seconds: durationSeconds });
3893
+ this.activeMiniServices.delete(name);
3894
+ this.activeMiniServices.set(name, this.now());
3895
+ this.track("deepdots_mini_service_enter", { entry_point_type: entryPointType ?? null });
3896
+ }
3897
+ /**
3898
+ * Cierra el mini-service `name` emitiendo `mini_service_exit` con su duración (#27).
3899
+ * No-op si ese nombre no está activo. Cierre coherente con concurrencia.
3900
+ */
3901
+ exitMiniService(name) {
3902
+ const enteredAt = this.activeMiniServices.get(name);
3903
+ if (enteredAt === void 0) return;
3904
+ const durationSeconds = Math.max(0, Math.round((this.now() - enteredAt) / 1e3));
3905
+ this.activeMiniServices.delete(name);
3906
+ this.track("deepdots_mini_service_exit", { mini_service: name, duration_seconds: durationSeconds });
3907
+ }
3908
+ /** Cierra TODOS los mini-services activos (orden LIFO). Para el cierre por lifecycle (background/cierre). */
3909
+ exitAllMiniServices() {
3910
+ for (const name of Array.from(this.activeMiniServices.keys()).reverse()) {
3911
+ this.exitMiniService(name);
3912
+ }
3898
3913
  }
3899
3914
  /** Registra un evento de analítica (modelo GA: nombre + params). */
3900
3915
  track(name, params) {
3916
+ const current = this.getMiniService();
3901
3917
  const merged = {
3902
- ...this.miniService ? { mini_service: this.miniService } : {},
3918
+ ...current ? { mini_service: current } : {},
3903
3919
  ...params ?? {}
3904
3920
  };
3905
3921
  this.events.push({
@@ -3911,9 +3927,11 @@ var AnalyticsManager = class {
3911
3927
  this.onFlushNeeded?.();
3912
3928
  }
3913
3929
  }
3914
- /** Mini-service activo (para inyectarlo en la metadata del survey, #33). */
3930
+ /** Mini-service actual (el más reciente aún activo) para etiquetar eventos + metadata del survey (#33). */
3915
3931
  getMiniService() {
3916
- return this.miniService;
3932
+ let last = null;
3933
+ for (const name of this.activeMiniServices.keys()) last = name;
3934
+ return last;
3917
3935
  }
3918
3936
  /** Nº de eventos pendientes de flush. */
3919
3937
  pending() {
@@ -4253,6 +4271,132 @@ var ContactManager = class {
4253
4271
  }
4254
4272
  };
4255
4273
 
4274
+ // src/analytics/crash-reporter.ts
4275
+ var STACK_MAX = 8e3;
4276
+ var QUEUE_KEY = "deepdots.crash.queue";
4277
+ var MAX_QUEUED = 20;
4278
+ function crashRecordToParams(r) {
4279
+ const params = {
4280
+ crashed_at: r.crashedAt,
4281
+ crash_type: r.crashType,
4282
+ message: r.message,
4283
+ stack: r.stack,
4284
+ fatal: r.fatal,
4285
+ handled: r.handled,
4286
+ severity: r.severity
4287
+ };
4288
+ if (r.sessionId) params.crashed_session_id = r.sessionId;
4289
+ if (r.appVersion) params.crashed_app_version = r.appVersion;
4290
+ if (r.osVersion) params.crashed_os_version = r.osVersion;
4291
+ if (r.deviceModel) params.crashed_device_model = r.deviceModel;
4292
+ if (r.context) {
4293
+ for (const [k, v] of Object.entries(r.context)) params[`ctx_${k}`] = v;
4294
+ }
4295
+ return params;
4296
+ }
4297
+ var CrashReporter = class {
4298
+ constructor(options) {
4299
+ this.options = options;
4300
+ this.now = options.now ?? (() => Date.now());
4301
+ }
4302
+ isEnabled() {
4303
+ return this.options.enabled ? this.options.enabled() : true;
4304
+ }
4305
+ /** Construye un CrashRecord capturando contexto en el momento del crash. */
4306
+ buildRecord(error, severity, handled, fatal, context) {
4307
+ const err = error;
4308
+ const isErr = error instanceof Error;
4309
+ const dev = this.options.device();
4310
+ const ctx = context ? Object.fromEntries(Object.entries(context).map(([k, v]) => [k, String(v)])) : void 0;
4311
+ return {
4312
+ crashedAt: this.now(),
4313
+ crashType: isErr && err.name || "Error",
4314
+ message: isErr ? String(err.message ?? "") : String(error),
4315
+ stack: (isErr && typeof err.stack === "string" ? err.stack : "").slice(0, STACK_MAX),
4316
+ fatal,
4317
+ handled,
4318
+ severity,
4319
+ sessionId: this.options.sessionId(),
4320
+ appVersion: dev.appVersion,
4321
+ osVersion: dev.osVersion,
4322
+ deviceModel: dev.deviceModel,
4323
+ context: ctx
4324
+ };
4325
+ }
4326
+ /** API pública del host: reporta un error (app viva) → emite el evento ya. */
4327
+ reportError(error, options = {}) {
4328
+ if (!this.isEnabled()) return;
4329
+ const severity = options.severity ?? "error";
4330
+ const handled = options.handled ?? true;
4331
+ const fatal = severity === "fatal";
4332
+ const record = this.buildRecord(error, severity, handled, fatal, options.context);
4333
+ this.options.emit(crashRecordToParams(record));
4334
+ }
4335
+ /** Captura un error no manejado (fatal) → persiste a disco para replay en el siguiente arranque. */
4336
+ captureUnhandled(error) {
4337
+ this.persist(this.buildRecord(error, "fatal", false, true));
4338
+ }
4339
+ /** Persiste un crash no capturado a disco para reenviarlo en el siguiente arranque. */
4340
+ persist(record) {
4341
+ let queue = [];
4342
+ try {
4343
+ const raw = this.options.storage.getItem(QUEUE_KEY);
4344
+ if (raw) queue = JSON.parse(raw);
4345
+ if (!Array.isArray(queue)) queue = [];
4346
+ } catch {
4347
+ queue = [];
4348
+ }
4349
+ queue.push(record);
4350
+ if (queue.length > MAX_QUEUED) queue = queue.slice(queue.length - MAX_QUEUED);
4351
+ try {
4352
+ this.options.storage.setItem(QUEUE_KEY, JSON.stringify(queue));
4353
+ } catch {
4354
+ }
4355
+ }
4356
+ /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
4357
+ drainPendingCrashes() {
4358
+ let queue = [];
4359
+ try {
4360
+ const raw = this.options.storage.getItem(QUEUE_KEY);
4361
+ if (raw) queue = JSON.parse(raw);
4362
+ if (!Array.isArray(queue)) queue = [];
4363
+ } catch {
4364
+ queue = [];
4365
+ }
4366
+ if (queue.length) {
4367
+ try {
4368
+ this.options.storage.removeItem(QUEUE_KEY);
4369
+ } catch {
4370
+ }
4371
+ }
4372
+ return queue;
4373
+ }
4374
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
4375
+ install() {
4376
+ if (typeof window === "undefined") return;
4377
+ window.addEventListener("error", (e) => {
4378
+ if (!this.isEnabled()) return;
4379
+ this.captureUnhandled(e.error ?? e.message ?? "error");
4380
+ });
4381
+ window.addEventListener("unhandledrejection", (e) => {
4382
+ if (!this.isEnabled()) return;
4383
+ this.captureUnhandled(e.reason ?? "unhandledrejection");
4384
+ });
4385
+ }
4386
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
4387
+ installReactNative(errorUtils) {
4388
+ const previous = errorUtils.getGlobalHandler?.();
4389
+ errorUtils.setGlobalHandler((error, isFatal) => {
4390
+ if (this.isEnabled()) this.captureUnhandled(error);
4391
+ previous?.(error, isFatal);
4392
+ });
4393
+ }
4394
+ /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
4395
+ _persistForTest(error) {
4396
+ this.captureUnhandled(error);
4397
+ }
4398
+ };
4399
+
4256
4400
  // src/core/deepdots-popups.ts
4257
4401
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
4258
4402
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
@@ -4287,6 +4431,8 @@ var DeepdotsPopups = class {
4287
4431
  this.analyticsFeedbackSessionId = void 0;
4288
4432
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
4289
4433
  this.analyticsFlushTimer = void 0;
4434
+ /** Crash & error reporting (#14–17). Null hasta init(). */
4435
+ this.crashReporter = null;
4290
4436
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
4291
4437
  this.navObserver = null;
4292
4438
  /** Tiempo activo (engagement time, #8). */
@@ -4338,25 +4484,44 @@ var DeepdotsPopups = class {
4338
4484
  this.analyticsFeedbackSessionId = id;
4339
4485
  }
4340
4486
  }) : void 0;
4487
+ const device = config.device ?? collectDeviceInfo(config.appVersion);
4341
4488
  this.analytics = new AnalyticsManager({
4342
4489
  sink: analyticsSink,
4343
4490
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
4344
4491
  language: typeof navigator !== "undefined" ? navigator.language : void 0,
4345
4492
  platform: config.platform ?? "web",
4346
- device: config.device ?? collectDeviceInfo(config.appVersion),
4493
+ device,
4347
4494
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
4348
4495
  onFlushNeeded: () => this.flushAnalytics()
4349
4496
  });
4497
+ this.crashReporter = new CrashReporter({
4498
+ storage,
4499
+ emit: (params) => this.track("deepdots_app_crash", params),
4500
+ device: () => ({ appVersion: device.app_version, osVersion: device.os_version, deviceModel: device.device_model }),
4501
+ sessionId: () => this.tracking?.getSessionId() ?? null,
4502
+ now: () => Date.now(),
4503
+ enabled: () => this.tracking?.isTrackingEnabled() ?? false
4504
+ });
4505
+ if (this.tracking?.isTrackingEnabled()) {
4506
+ this.crashReporter.install();
4507
+ }
4350
4508
  collectGeoInfo().then((geo) => {
4351
4509
  if (geo) this.analytics?.updateDevice(geo);
4352
4510
  }).catch(() => {
4353
4511
  });
4354
4512
  this.navObserver = new NavigationObserver();
4355
- this.navObserver.onVisit((v) => this.track("page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
4513
+ this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
4356
4514
  this.navObserver.install();
4357
4515
  this.engagement = new EngagementTracker();
4358
4516
  this.engagement.resume();
4359
4517
  this.setupAnalyticsFlush();
4518
+ this.track("deepdots_session_start", {});
4519
+ const pendingCrashes = this.crashReporter.drainPendingCrashes();
4520
+ if (this.tracking?.isTrackingEnabled()) {
4521
+ for (const rec of pendingCrashes) {
4522
+ this.track("deepdots_app_crash", crashRecordToParams(rec));
4523
+ }
4524
+ }
4360
4525
  this.initialized = true;
4361
4526
  this.log("SDK initialized", this.config);
4362
4527
  if (this.renderer.init) this.renderer.init();
@@ -4397,6 +4562,16 @@ var DeepdotsPopups = class {
4397
4562
  if (!this.tracking?.isTrackingEnabled()) return;
4398
4563
  this.analytics?.setUserAttributes(attributes);
4399
4564
  }
4565
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
4566
+ reportError(error, options) {
4567
+ if (!this.tracking?.isTrackingEnabled()) return;
4568
+ this.crashReporter?.reportError(error, options);
4569
+ }
4570
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
4571
+ installReactNativeCrashHandler(errorUtils) {
4572
+ if (!this.tracking?.isTrackingEnabled()) return;
4573
+ this.crashReporter?.installReactNative(errorUtils);
4574
+ }
4400
4575
  /**
4401
4576
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
4402
4577
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -4414,23 +4589,23 @@ var DeepdotsPopups = class {
4414
4589
  this.analytics?.enterMiniService(name, entryPointType);
4415
4590
  this.log("analytics \xB7 enterMiniService:", name, entryPointType ?? "");
4416
4591
  }
4417
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
4418
- exitMiniService() {
4592
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
4593
+ exitMiniService(name) {
4419
4594
  if (!this.tracking?.isTrackingEnabled()) return;
4420
- this.analytics?.exitMiniService();
4421
- this.log("analytics \xB7 exitMiniService");
4595
+ this.analytics?.exitMiniService(name);
4596
+ this.log("analytics \xB7 exitMiniService:", name);
4422
4597
  }
4423
4598
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
4424
4599
  trackSearch(query, resultsCount, params) {
4425
- this.track("search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
4600
+ this.track("deepdots_search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
4426
4601
  }
4427
4602
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
4428
4603
  trackFindabilityFriction(frictionTopic, params) {
4429
- this.track("findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
4604
+ this.track("deepdots_findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
4430
4605
  }
4431
4606
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
4432
4607
  trackFunnelStep(funnel, step, taskId, params) {
4433
- this.track("funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
4608
+ this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
4434
4609
  }
4435
4610
  /**
4436
4611
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
@@ -4456,7 +4631,7 @@ var DeepdotsPopups = class {
4456
4631
  onBackground() {
4457
4632
  this.navObserver?.stop();
4458
4633
  this.navStarted = false;
4459
- this.exitMiniService();
4634
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4460
4635
  this.flushEngagement();
4461
4636
  this.engagement?.pause();
4462
4637
  this.flushAnalytics();
@@ -4479,7 +4654,7 @@ var DeepdotsPopups = class {
4479
4654
  flushEngagement() {
4480
4655
  if (!this.tracking?.isTrackingEnabled()) return;
4481
4656
  const ms = this.engagement?.consume() ?? 0;
4482
- if (ms > 0) this.track("user_engagement", { engagement_time_msec: ms });
4657
+ if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
4483
4658
  }
4484
4659
  analyticsIdentity() {
4485
4660
  return {
@@ -4494,7 +4669,7 @@ var DeepdotsPopups = class {
4494
4669
  window.addEventListener("pagehide", () => {
4495
4670
  clearInterval(this.analyticsFlushTimer);
4496
4671
  this.navObserver?.stop();
4497
- this.exitMiniService();
4672
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4498
4673
  this.flushEngagement();
4499
4674
  this.flushAnalytics();
4500
4675
  });
@@ -5163,6 +5338,10 @@ function setupReactNative(sdk, config, deps = {}) {
5163
5338
  const device = deps.deviceInfo ? collectRnDevice(deps.deviceInfo) : config.device;
5164
5339
  const platform = deps.platform ?? config.platform;
5165
5340
  sdk.init({ ...config, storage, device, platform });
5341
+ const errorUtils = deps.errorUtils ?? globalThis.ErrorUtils;
5342
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
5343
+ sdk.installReactNativeCrashHandler(errorUtils);
5344
+ }
5166
5345
  let subscription;
5167
5346
  if (deps.appState?.addEventListener) {
5168
5347
  subscription = deps.appState.addEventListener("change", (state) => {