@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.
@@ -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,11 +800,153 @@ 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
+
929
+ // src/analytics/messaging.ts
930
+ function buildMessageParams(stage, o) {
931
+ const p = {
932
+ stage,
933
+ message_id: o.id,
934
+ message_title: o.title,
935
+ channel: o.channel
936
+ };
937
+ if (o.campaign) p.campaign = o.campaign;
938
+ if (o.value !== void 0) p.value = o.value;
939
+ if (o.currency) p.currency = o.currency;
940
+ return { ...p, ...o.params ?? {} };
941
+ }
942
+
787
943
  // src/core/deepdots-popups.ts
788
944
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
789
945
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
790
946
  var ANALYTICS_MAX_BATCH_SIZE = 20;
791
947
  var ANALYTICS_FLUSH_INTERVAL_MS = 3e4;
948
+ var RESERVED_EVENT_PREFIX = "deepdots_";
949
+ var CUSTOM_EVENT_PREFIX = "deepdots_event_";
792
950
  var DeepdotsPopups = class {
793
951
  constructor() {
794
952
  this.config = null;
@@ -818,6 +976,8 @@ var DeepdotsPopups = class {
818
976
  this.analyticsFeedbackSessionId = void 0;
819
977
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
820
978
  this.analyticsFlushTimer = void 0;
979
+ /** Crash & error reporting (#14–17). Null hasta init(). */
980
+ this.crashReporter = null;
821
981
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
822
982
  this.navObserver = null;
823
983
  /** Tiempo activo (engagement time, #8). */
@@ -869,25 +1029,44 @@ var DeepdotsPopups = class {
869
1029
  this.analyticsFeedbackSessionId = id;
870
1030
  }
871
1031
  }) : void 0;
1032
+ const device = config.device ?? collectDeviceInfo(config.appVersion);
872
1033
  this.analytics = new AnalyticsManager({
873
1034
  sink: analyticsSink,
874
1035
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
875
1036
  language: typeof navigator !== "undefined" ? navigator.language : void 0,
876
1037
  platform: config.platform ?? "web",
877
- device: config.device ?? collectDeviceInfo(config.appVersion),
1038
+ device,
878
1039
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
879
1040
  onFlushNeeded: () => this.flushAnalytics()
880
1041
  });
1042
+ this.crashReporter = new CrashReporter({
1043
+ storage,
1044
+ emit: (params) => this.track("deepdots_app_crash", params),
1045
+ device: () => ({ appVersion: device.app_version, osVersion: device.os_version, deviceModel: device.device_model }),
1046
+ sessionId: () => this.tracking?.getSessionId() ?? null,
1047
+ now: () => Date.now(),
1048
+ enabled: () => this.tracking?.isTrackingEnabled() ?? false
1049
+ });
1050
+ if (this.tracking?.isTrackingEnabled()) {
1051
+ this.crashReporter.install();
1052
+ }
881
1053
  collectGeoInfo().then((geo) => {
882
1054
  if (geo) this.analytics?.updateDevice(geo);
883
1055
  }).catch(() => {
884
1056
  });
885
1057
  this.navObserver = new NavigationObserver();
886
- this.navObserver.onVisit((v) => this.track("page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
1058
+ this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
887
1059
  this.navObserver.install();
888
1060
  this.engagement = new EngagementTracker();
889
1061
  this.engagement.resume();
890
1062
  this.setupAnalyticsFlush();
1063
+ this.track("deepdots_session_start", {});
1064
+ const pendingCrashes = this.crashReporter.drainPendingCrashes();
1065
+ if (this.tracking?.isTrackingEnabled()) {
1066
+ for (const rec of pendingCrashes) {
1067
+ this.track("deepdots_app_crash", crashRecordToParams(rec));
1068
+ }
1069
+ }
891
1070
  this.initialized = true;
892
1071
  this.log("SDK initialized", this.config);
893
1072
  if (this.renderer.init) this.renderer.init();
@@ -920,14 +1099,25 @@ var DeepdotsPopups = class {
920
1099
  /** Registra un evento de analítica (modelo GA: nombre + params). No-op si tracking off. */
921
1100
  track(name, params) {
922
1101
  if (!this.tracking?.isTrackingEnabled()) return;
923
- this.analytics?.track(name, params);
924
- this.log("analytics \xB7 track:", name, params ?? {});
1102
+ const eventName = name.startsWith(RESERVED_EVENT_PREFIX) ? name : `${CUSTOM_EVENT_PREFIX}${name}`;
1103
+ this.analytics?.track(eventName, params);
1104
+ this.log("analytics \xB7 track:", eventName, params ?? {});
925
1105
  }
926
1106
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
927
1107
  setUserAttributes(attributes) {
928
1108
  if (!this.tracking?.isTrackingEnabled()) return;
929
1109
  this.analytics?.setUserAttributes(attributes);
930
1110
  }
1111
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
1112
+ reportError(error, options) {
1113
+ if (!this.tracking?.isTrackingEnabled()) return;
1114
+ this.crashReporter?.reportError(error, options);
1115
+ }
1116
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
1117
+ installReactNativeCrashHandler(errorUtils) {
1118
+ if (!this.tracking?.isTrackingEnabled()) return;
1119
+ this.crashReporter?.installReactNative(errorUtils);
1120
+ }
931
1121
  /**
932
1122
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
933
1123
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -945,23 +1135,27 @@ var DeepdotsPopups = class {
945
1135
  this.analytics?.enterMiniService(name, entryPointType);
946
1136
  this.log("analytics \xB7 enterMiniService:", name, entryPointType ?? "");
947
1137
  }
948
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
949
- exitMiniService() {
1138
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
1139
+ exitMiniService(name) {
950
1140
  if (!this.tracking?.isTrackingEnabled()) return;
951
- this.analytics?.exitMiniService();
952
- this.log("analytics \xB7 exitMiniService");
1141
+ this.analytics?.exitMiniService(name);
1142
+ this.log("analytics \xB7 exitMiniService:", name);
953
1143
  }
954
1144
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
955
1145
  trackSearch(query, resultsCount, params) {
956
- this.track("search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
1146
+ this.track("deepdots_search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
957
1147
  }
958
1148
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
959
1149
  trackFindabilityFriction(frictionTopic, params) {
960
- this.track("findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
1150
+ this.track("deepdots_findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
961
1151
  }
962
1152
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
963
1153
  trackFunnelStep(funnel, step, taskId, params) {
964
- this.track("funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1154
+ this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1155
+ }
1156
+ /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
1157
+ trackMessage(stage, options) {
1158
+ this.track("deepdots_message", buildMessageParams(stage, options));
965
1159
  }
966
1160
  /**
967
1161
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
@@ -987,7 +1181,7 @@ var DeepdotsPopups = class {
987
1181
  onBackground() {
988
1182
  this.navObserver?.stop();
989
1183
  this.navStarted = false;
990
- this.exitMiniService();
1184
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
991
1185
  this.flushEngagement();
992
1186
  this.engagement?.pause();
993
1187
  this.flushAnalytics();
@@ -1010,7 +1204,7 @@ var DeepdotsPopups = class {
1010
1204
  flushEngagement() {
1011
1205
  if (!this.tracking?.isTrackingEnabled()) return;
1012
1206
  const ms = this.engagement?.consume() ?? 0;
1013
- if (ms > 0) this.track("user_engagement", { engagement_time_msec: ms });
1207
+ if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
1014
1208
  }
1015
1209
  analyticsIdentity() {
1016
1210
  return {
@@ -1025,7 +1219,7 @@ var DeepdotsPopups = class {
1025
1219
  window.addEventListener("pagehide", () => {
1026
1220
  clearInterval(this.analyticsFlushTimer);
1027
1221
  this.navObserver?.stop();
1028
- this.exitMiniService();
1222
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1029
1223
  this.flushEngagement();
1030
1224
  this.flushAnalytics();
1031
1225
  });
@@ -1691,6 +1885,10 @@ function setupReactNative(sdk2, config, deps = {}) {
1691
1885
  const device = deps.deviceInfo ? collectRnDevice(deps.deviceInfo) : config.device;
1692
1886
  const platform = deps.platform ?? config.platform;
1693
1887
  sdk2.init({ ...config, storage, device, platform });
1888
+ const errorUtils = deps.errorUtils ?? globalThis.ErrorUtils;
1889
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
1890
+ sdk2.installReactNativeCrashHandler(errorUtils);
1891
+ }
1694
1892
  let subscription;
1695
1893
  if (deps.appState?.addEventListener) {
1696
1894
  subscription = deps.appState.addEventListener("change", (state) => {