@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.
@@ -392,8 +392,8 @@ var AnalyticsManager = class {
392
392
  constructor(options = {}) {
393
393
  this.events = [];
394
394
  this.attributes = {};
395
- this.miniService = null;
396
- this.miniServiceEnteredAt = 0;
395
+ /** Mini-services activos: nombre → timestamp de entrada. El orden de inserción marca el "más reciente". */
396
+ this.activeMiniServices = /* @__PURE__ */ new Map();
397
397
  this.sink = options.sink ?? dryRunSink;
398
398
  this.now = options.now ?? (() => Date.now());
399
399
  this.publicKey = options.publicKey;
@@ -414,24 +414,38 @@ var AnalyticsManager = class {
414
414
  this.attributes[k] = String(v);
415
415
  }
416
416
  }
417
- /** Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`. */
417
+ /**
418
+ * Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`.
419
+ * Admite varios activos a la vez (concurrencia); el "actual" para etiquetar es el más reciente.
420
+ * Reentrar con un nombre ya activo refresca su orden y su tiempo de entrada.
421
+ */
418
422
  enterMiniService(name, entryPointType) {
419
- this.miniService = name;
420
- this.miniServiceEnteredAt = this.now();
421
- this.track("mini_service_enter", { entry_point_type: entryPointType ?? null });
422
- }
423
- /** Cierra el mini-service activo emitiendo `mini_service_exit` con su duración (#27). No-op si no hay ninguno. */
424
- exitMiniService() {
425
- const name = this.miniService;
426
- if (name == null) return;
427
- const durationSeconds = Math.max(0, Math.round((this.now() - this.miniServiceEnteredAt) / 1e3));
428
- this.miniService = null;
429
- this.track("mini_service_exit", { mini_service: name, duration_seconds: durationSeconds });
423
+ this.activeMiniServices.delete(name);
424
+ this.activeMiniServices.set(name, this.now());
425
+ this.track("deepdots_mini_service_enter", { entry_point_type: entryPointType ?? null });
426
+ }
427
+ /**
428
+ * Cierra el mini-service `name` emitiendo `mini_service_exit` con su duración (#27).
429
+ * No-op si ese nombre no está activo. Cierre coherente con concurrencia.
430
+ */
431
+ exitMiniService(name) {
432
+ const enteredAt = this.activeMiniServices.get(name);
433
+ if (enteredAt === void 0) return;
434
+ const durationSeconds = Math.max(0, Math.round((this.now() - enteredAt) / 1e3));
435
+ this.activeMiniServices.delete(name);
436
+ this.track("deepdots_mini_service_exit", { mini_service: name, duration_seconds: durationSeconds });
437
+ }
438
+ /** Cierra TODOS los mini-services activos (orden LIFO). Para el cierre por lifecycle (background/cierre). */
439
+ exitAllMiniServices() {
440
+ for (const name of Array.from(this.activeMiniServices.keys()).reverse()) {
441
+ this.exitMiniService(name);
442
+ }
430
443
  }
431
444
  /** Registra un evento de analítica (modelo GA: nombre + params). */
432
445
  track(name, params) {
446
+ const current = this.getMiniService();
433
447
  const merged = {
434
- ...this.miniService ? { mini_service: this.miniService } : {},
448
+ ...current ? { mini_service: current } : {},
435
449
  ...params ?? {}
436
450
  };
437
451
  this.events.push({
@@ -443,9 +457,11 @@ var AnalyticsManager = class {
443
457
  this.onFlushNeeded?.();
444
458
  }
445
459
  }
446
- /** Mini-service activo (para inyectarlo en la metadata del survey, #33). */
460
+ /** Mini-service actual (el más reciente aún activo) para etiquetar eventos + metadata del survey (#33). */
447
461
  getMiniService() {
448
- return this.miniService;
462
+ let last = null;
463
+ for (const name of this.activeMiniServices.keys()) last = name;
464
+ return last;
449
465
  }
450
466
  /** Nº de eventos pendientes de flush. */
451
467
  pending() {
@@ -784,6 +800,132 @@ var ContactManager = class {
784
800
  }
785
801
  };
786
802
 
803
+ // src/analytics/crash-reporter.ts
804
+ var STACK_MAX = 8e3;
805
+ var QUEUE_KEY = "deepdots.crash.queue";
806
+ var MAX_QUEUED = 20;
807
+ function crashRecordToParams(r) {
808
+ const params = {
809
+ crashed_at: r.crashedAt,
810
+ crash_type: r.crashType,
811
+ message: r.message,
812
+ stack: r.stack,
813
+ fatal: r.fatal,
814
+ handled: r.handled,
815
+ severity: r.severity
816
+ };
817
+ if (r.sessionId) params.crashed_session_id = r.sessionId;
818
+ if (r.appVersion) params.crashed_app_version = r.appVersion;
819
+ if (r.osVersion) params.crashed_os_version = r.osVersion;
820
+ if (r.deviceModel) params.crashed_device_model = r.deviceModel;
821
+ if (r.context) {
822
+ for (const [k, v] of Object.entries(r.context)) params[`ctx_${k}`] = v;
823
+ }
824
+ return params;
825
+ }
826
+ var CrashReporter = class {
827
+ constructor(options) {
828
+ this.options = options;
829
+ this.now = options.now ?? (() => Date.now());
830
+ }
831
+ isEnabled() {
832
+ return this.options.enabled ? this.options.enabled() : true;
833
+ }
834
+ /** Construye un CrashRecord capturando contexto en el momento del crash. */
835
+ buildRecord(error, severity, handled, fatal, context) {
836
+ const err = error;
837
+ const isErr = error instanceof Error;
838
+ const dev = this.options.device();
839
+ const ctx = context ? Object.fromEntries(Object.entries(context).map(([k, v]) => [k, String(v)])) : void 0;
840
+ return {
841
+ crashedAt: this.now(),
842
+ crashType: isErr && err.name || "Error",
843
+ message: isErr ? String(err.message ?? "") : String(error),
844
+ stack: (isErr && typeof err.stack === "string" ? err.stack : "").slice(0, STACK_MAX),
845
+ fatal,
846
+ handled,
847
+ severity,
848
+ sessionId: this.options.sessionId(),
849
+ appVersion: dev.appVersion,
850
+ osVersion: dev.osVersion,
851
+ deviceModel: dev.deviceModel,
852
+ context: ctx
853
+ };
854
+ }
855
+ /** API pública del host: reporta un error (app viva) → emite el evento ya. */
856
+ reportError(error, options = {}) {
857
+ if (!this.isEnabled()) return;
858
+ const severity = options.severity ?? "error";
859
+ const handled = options.handled ?? true;
860
+ const fatal = severity === "fatal";
861
+ const record = this.buildRecord(error, severity, handled, fatal, options.context);
862
+ this.options.emit(crashRecordToParams(record));
863
+ }
864
+ /** Captura un error no manejado (fatal) → persiste a disco para replay en el siguiente arranque. */
865
+ captureUnhandled(error) {
866
+ this.persist(this.buildRecord(error, "fatal", false, true));
867
+ }
868
+ /** Persiste un crash no capturado a disco para reenviarlo en el siguiente arranque. */
869
+ persist(record) {
870
+ let queue = [];
871
+ try {
872
+ const raw = this.options.storage.getItem(QUEUE_KEY);
873
+ if (raw) queue = JSON.parse(raw);
874
+ if (!Array.isArray(queue)) queue = [];
875
+ } catch {
876
+ queue = [];
877
+ }
878
+ queue.push(record);
879
+ if (queue.length > MAX_QUEUED) queue = queue.slice(queue.length - MAX_QUEUED);
880
+ try {
881
+ this.options.storage.setItem(QUEUE_KEY, JSON.stringify(queue));
882
+ } catch {
883
+ }
884
+ }
885
+ /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
886
+ drainPendingCrashes() {
887
+ let queue = [];
888
+ try {
889
+ const raw = this.options.storage.getItem(QUEUE_KEY);
890
+ if (raw) queue = JSON.parse(raw);
891
+ if (!Array.isArray(queue)) queue = [];
892
+ } catch {
893
+ queue = [];
894
+ }
895
+ if (queue.length) {
896
+ try {
897
+ this.options.storage.removeItem(QUEUE_KEY);
898
+ } catch {
899
+ }
900
+ }
901
+ return queue;
902
+ }
903
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
904
+ install() {
905
+ if (typeof window === "undefined") return;
906
+ window.addEventListener("error", (e) => {
907
+ if (!this.isEnabled()) return;
908
+ this.captureUnhandled(e.error ?? e.message ?? "error");
909
+ });
910
+ window.addEventListener("unhandledrejection", (e) => {
911
+ if (!this.isEnabled()) return;
912
+ this.captureUnhandled(e.reason ?? "unhandledrejection");
913
+ });
914
+ }
915
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
916
+ installReactNative(errorUtils) {
917
+ const previous = errorUtils.getGlobalHandler?.();
918
+ errorUtils.setGlobalHandler((error, isFatal) => {
919
+ if (this.isEnabled()) this.captureUnhandled(error);
920
+ previous?.(error, isFatal);
921
+ });
922
+ }
923
+ /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
924
+ _persistForTest(error) {
925
+ this.captureUnhandled(error);
926
+ }
927
+ };
928
+
787
929
  // src/core/deepdots-popups.ts
788
930
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
789
931
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
@@ -818,6 +960,8 @@ var DeepdotsPopups = class {
818
960
  this.analyticsFeedbackSessionId = void 0;
819
961
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
820
962
  this.analyticsFlushTimer = void 0;
963
+ /** Crash & error reporting (#14–17). Null hasta init(). */
964
+ this.crashReporter = null;
821
965
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
822
966
  this.navObserver = null;
823
967
  /** Tiempo activo (engagement time, #8). */
@@ -869,25 +1013,44 @@ var DeepdotsPopups = class {
869
1013
  this.analyticsFeedbackSessionId = id;
870
1014
  }
871
1015
  }) : void 0;
1016
+ const device = config.device ?? collectDeviceInfo(config.appVersion);
872
1017
  this.analytics = new AnalyticsManager({
873
1018
  sink: analyticsSink,
874
1019
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
875
1020
  language: typeof navigator !== "undefined" ? navigator.language : void 0,
876
1021
  platform: config.platform ?? "web",
877
- device: config.device ?? collectDeviceInfo(config.appVersion),
1022
+ device,
878
1023
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
879
1024
  onFlushNeeded: () => this.flushAnalytics()
880
1025
  });
1026
+ this.crashReporter = new CrashReporter({
1027
+ storage,
1028
+ emit: (params) => this.track("deepdots_app_crash", params),
1029
+ device: () => ({ appVersion: device.app_version, osVersion: device.os_version, deviceModel: device.device_model }),
1030
+ sessionId: () => this.tracking?.getSessionId() ?? null,
1031
+ now: () => Date.now(),
1032
+ enabled: () => this.tracking?.isTrackingEnabled() ?? false
1033
+ });
1034
+ if (this.tracking?.isTrackingEnabled()) {
1035
+ this.crashReporter.install();
1036
+ }
881
1037
  collectGeoInfo().then((geo) => {
882
1038
  if (geo) this.analytics?.updateDevice(geo);
883
1039
  }).catch(() => {
884
1040
  });
885
1041
  this.navObserver = new NavigationObserver();
886
- this.navObserver.onVisit((v) => this.track("page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
1042
+ this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
887
1043
  this.navObserver.install();
888
1044
  this.engagement = new EngagementTracker();
889
1045
  this.engagement.resume();
890
1046
  this.setupAnalyticsFlush();
1047
+ this.track("deepdots_session_start", {});
1048
+ const pendingCrashes = this.crashReporter.drainPendingCrashes();
1049
+ if (this.tracking?.isTrackingEnabled()) {
1050
+ for (const rec of pendingCrashes) {
1051
+ this.track("deepdots_app_crash", crashRecordToParams(rec));
1052
+ }
1053
+ }
891
1054
  this.initialized = true;
892
1055
  this.log("SDK initialized", this.config);
893
1056
  if (this.renderer.init) this.renderer.init();
@@ -928,6 +1091,16 @@ var DeepdotsPopups = class {
928
1091
  if (!this.tracking?.isTrackingEnabled()) return;
929
1092
  this.analytics?.setUserAttributes(attributes);
930
1093
  }
1094
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
1095
+ reportError(error, options) {
1096
+ if (!this.tracking?.isTrackingEnabled()) return;
1097
+ this.crashReporter?.reportError(error, options);
1098
+ }
1099
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
1100
+ installReactNativeCrashHandler(errorUtils) {
1101
+ if (!this.tracking?.isTrackingEnabled()) return;
1102
+ this.crashReporter?.installReactNative(errorUtils);
1103
+ }
931
1104
  /**
932
1105
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
933
1106
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -945,23 +1118,23 @@ var DeepdotsPopups = class {
945
1118
  this.analytics?.enterMiniService(name, entryPointType);
946
1119
  this.log("analytics \xB7 enterMiniService:", name, entryPointType ?? "");
947
1120
  }
948
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
949
- exitMiniService() {
1121
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
1122
+ exitMiniService(name) {
950
1123
  if (!this.tracking?.isTrackingEnabled()) return;
951
- this.analytics?.exitMiniService();
952
- this.log("analytics \xB7 exitMiniService");
1124
+ this.analytics?.exitMiniService(name);
1125
+ this.log("analytics \xB7 exitMiniService:", name);
953
1126
  }
954
1127
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
955
1128
  trackSearch(query, resultsCount, params) {
956
- this.track("search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
1129
+ this.track("deepdots_search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
957
1130
  }
958
1131
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
959
1132
  trackFindabilityFriction(frictionTopic, params) {
960
- this.track("findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
1133
+ this.track("deepdots_findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
961
1134
  }
962
1135
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
963
1136
  trackFunnelStep(funnel, step, taskId, params) {
964
- this.track("funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1137
+ this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
965
1138
  }
966
1139
  /**
967
1140
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
@@ -987,7 +1160,7 @@ var DeepdotsPopups = class {
987
1160
  onBackground() {
988
1161
  this.navObserver?.stop();
989
1162
  this.navStarted = false;
990
- this.exitMiniService();
1163
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
991
1164
  this.flushEngagement();
992
1165
  this.engagement?.pause();
993
1166
  this.flushAnalytics();
@@ -1010,7 +1183,7 @@ var DeepdotsPopups = class {
1010
1183
  flushEngagement() {
1011
1184
  if (!this.tracking?.isTrackingEnabled()) return;
1012
1185
  const ms = this.engagement?.consume() ?? 0;
1013
- if (ms > 0) this.track("user_engagement", { engagement_time_msec: ms });
1186
+ if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
1014
1187
  }
1015
1188
  analyticsIdentity() {
1016
1189
  return {
@@ -1025,7 +1198,7 @@ var DeepdotsPopups = class {
1025
1198
  window.addEventListener("pagehide", () => {
1026
1199
  clearInterval(this.analyticsFlushTimer);
1027
1200
  this.navObserver?.stop();
1028
- this.exitMiniService();
1201
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1029
1202
  this.flushEngagement();
1030
1203
  this.flushAnalytics();
1031
1204
  });
@@ -1691,6 +1864,10 @@ function setupReactNative(sdk2, config, deps = {}) {
1691
1864
  const device = deps.deviceInfo ? collectRnDevice(deps.deviceInfo) : config.device;
1692
1865
  const platform = deps.platform ?? config.platform;
1693
1866
  sdk2.init({ ...config, storage, device, platform });
1867
+ const errorUtils = deps.errorUtils ?? globalThis.ErrorUtils;
1868
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
1869
+ sdk2.installReactNativeCrashHandler(errorUtils);
1870
+ }
1694
1871
  let subscription;
1695
1872
  if (deps.appState?.addEventListener) {
1696
1873
  subscription = deps.appState.addEventListener("change", (state) => {