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