@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.
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,11 +795,153 @@ 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
+
924
+ // src/analytics/messaging.ts
925
+ function buildMessageParams(stage, o) {
926
+ const p = {
927
+ stage,
928
+ message_id: o.id,
929
+ message_title: o.title,
930
+ channel: o.channel
931
+ };
932
+ if (o.campaign) p.campaign = o.campaign;
933
+ if (o.value !== void 0) p.value = o.value;
934
+ if (o.currency) p.currency = o.currency;
935
+ return { ...p, ...o.params ?? {} };
936
+ }
937
+
782
938
  // src/core/deepdots-popups.ts
783
939
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
784
940
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
785
941
  var ANALYTICS_MAX_BATCH_SIZE = 20;
786
942
  var ANALYTICS_FLUSH_INTERVAL_MS = 3e4;
943
+ var RESERVED_EVENT_PREFIX = "deepdots_";
944
+ var CUSTOM_EVENT_PREFIX = "deepdots_event_";
787
945
  var DeepdotsPopups = class {
788
946
  constructor() {
789
947
  this.config = null;
@@ -813,6 +971,8 @@ var DeepdotsPopups = class {
813
971
  this.analyticsFeedbackSessionId = void 0;
814
972
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
815
973
  this.analyticsFlushTimer = void 0;
974
+ /** Crash & error reporting (#14–17). Null hasta init(). */
975
+ this.crashReporter = null;
816
976
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
817
977
  this.navObserver = null;
818
978
  /** Tiempo activo (engagement time, #8). */
@@ -864,25 +1024,44 @@ var DeepdotsPopups = class {
864
1024
  this.analyticsFeedbackSessionId = id;
865
1025
  }
866
1026
  }) : void 0;
1027
+ const device = config.device ?? collectDeviceInfo(config.appVersion);
867
1028
  this.analytics = new AnalyticsManager({
868
1029
  sink: analyticsSink,
869
1030
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
870
1031
  language: typeof navigator !== "undefined" ? navigator.language : void 0,
871
1032
  platform: config.platform ?? "web",
872
- device: config.device ?? collectDeviceInfo(config.appVersion),
1033
+ device,
873
1034
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
874
1035
  onFlushNeeded: () => this.flushAnalytics()
875
1036
  });
1037
+ this.crashReporter = new CrashReporter({
1038
+ storage,
1039
+ emit: (params) => this.track("deepdots_app_crash", params),
1040
+ device: () => ({ appVersion: device.app_version, osVersion: device.os_version, deviceModel: device.device_model }),
1041
+ sessionId: () => this.tracking?.getSessionId() ?? null,
1042
+ now: () => Date.now(),
1043
+ enabled: () => this.tracking?.isTrackingEnabled() ?? false
1044
+ });
1045
+ if (this.tracking?.isTrackingEnabled()) {
1046
+ this.crashReporter.install();
1047
+ }
876
1048
  collectGeoInfo().then((geo) => {
877
1049
  if (geo) this.analytics?.updateDevice(geo);
878
1050
  }).catch(() => {
879
1051
  });
880
1052
  this.navObserver = new NavigationObserver();
881
- this.navObserver.onVisit((v) => this.track("page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
1053
+ this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
882
1054
  this.navObserver.install();
883
1055
  this.engagement = new EngagementTracker();
884
1056
  this.engagement.resume();
885
1057
  this.setupAnalyticsFlush();
1058
+ this.track("deepdots_session_start", {});
1059
+ const pendingCrashes = this.crashReporter.drainPendingCrashes();
1060
+ if (this.tracking?.isTrackingEnabled()) {
1061
+ for (const rec of pendingCrashes) {
1062
+ this.track("deepdots_app_crash", crashRecordToParams(rec));
1063
+ }
1064
+ }
886
1065
  this.initialized = true;
887
1066
  this.log("SDK initialized", this.config);
888
1067
  if (this.renderer.init) this.renderer.init();
@@ -915,14 +1094,25 @@ var DeepdotsPopups = class {
915
1094
  /** Registra un evento de analítica (modelo GA: nombre + params). No-op si tracking off. */
916
1095
  track(name, params) {
917
1096
  if (!this.tracking?.isTrackingEnabled()) return;
918
- this.analytics?.track(name, params);
919
- this.log("analytics \xB7 track:", name, params ?? {});
1097
+ const eventName = name.startsWith(RESERVED_EVENT_PREFIX) ? name : `${CUSTOM_EVENT_PREFIX}${name}`;
1098
+ this.analytics?.track(eventName, params);
1099
+ this.log("analytics \xB7 track:", eventName, params ?? {});
920
1100
  }
921
1101
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
922
1102
  setUserAttributes(attributes) {
923
1103
  if (!this.tracking?.isTrackingEnabled()) return;
924
1104
  this.analytics?.setUserAttributes(attributes);
925
1105
  }
1106
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
1107
+ reportError(error, options) {
1108
+ if (!this.tracking?.isTrackingEnabled()) return;
1109
+ this.crashReporter?.reportError(error, options);
1110
+ }
1111
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
1112
+ installReactNativeCrashHandler(errorUtils) {
1113
+ if (!this.tracking?.isTrackingEnabled()) return;
1114
+ this.crashReporter?.installReactNative(errorUtils);
1115
+ }
926
1116
  /**
927
1117
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
928
1118
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -940,23 +1130,27 @@ var DeepdotsPopups = class {
940
1130
  this.analytics?.enterMiniService(name, entryPointType);
941
1131
  this.log("analytics \xB7 enterMiniService:", name, entryPointType ?? "");
942
1132
  }
943
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
944
- exitMiniService() {
1133
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
1134
+ exitMiniService(name) {
945
1135
  if (!this.tracking?.isTrackingEnabled()) return;
946
- this.analytics?.exitMiniService();
947
- this.log("analytics \xB7 exitMiniService");
1136
+ this.analytics?.exitMiniService(name);
1137
+ this.log("analytics \xB7 exitMiniService:", name);
948
1138
  }
949
1139
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
950
1140
  trackSearch(query, resultsCount, params) {
951
- this.track("search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
1141
+ this.track("deepdots_search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
952
1142
  }
953
1143
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
954
1144
  trackFindabilityFriction(frictionTopic, params) {
955
- this.track("findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
1145
+ this.track("deepdots_findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
956
1146
  }
957
1147
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
958
1148
  trackFunnelStep(funnel, step, taskId, params) {
959
- this.track("funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1149
+ this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1150
+ }
1151
+ /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
1152
+ trackMessage(stage, options) {
1153
+ this.track("deepdots_message", buildMessageParams(stage, options));
960
1154
  }
961
1155
  /**
962
1156
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
@@ -982,7 +1176,7 @@ var DeepdotsPopups = class {
982
1176
  onBackground() {
983
1177
  this.navObserver?.stop();
984
1178
  this.navStarted = false;
985
- this.exitMiniService();
1179
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
986
1180
  this.flushEngagement();
987
1181
  this.engagement?.pause();
988
1182
  this.flushAnalytics();
@@ -1005,7 +1199,7 @@ var DeepdotsPopups = class {
1005
1199
  flushEngagement() {
1006
1200
  if (!this.tracking?.isTrackingEnabled()) return;
1007
1201
  const ms = this.engagement?.consume() ?? 0;
1008
- if (ms > 0) this.track("user_engagement", { engagement_time_msec: ms });
1202
+ if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
1009
1203
  }
1010
1204
  analyticsIdentity() {
1011
1205
  return {
@@ -1020,7 +1214,7 @@ var DeepdotsPopups = class {
1020
1214
  window.addEventListener("pagehide", () => {
1021
1215
  clearInterval(this.analyticsFlushTimer);
1022
1216
  this.navObserver?.stop();
1023
- this.exitMiniService();
1217
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1024
1218
  this.flushEngagement();
1025
1219
  this.flushAnalytics();
1026
1220
  });
@@ -1686,6 +1880,10 @@ function setupReactNative(sdk, config, deps = {}) {
1686
1880
  const device = deps.deviceInfo ? collectRnDevice(deps.deviceInfo) : config.device;
1687
1881
  const platform = deps.platform ?? config.platform;
1688
1882
  sdk.init({ ...config, storage, device, platform });
1883
+ const errorUtils = deps.errorUtils ?? globalThis.ErrorUtils;
1884
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
1885
+ sdk.installReactNativeCrashHandler(errorUtils);
1886
+ }
1689
1887
  let subscription;
1690
1888
  if (deps.appState?.addEventListener) {
1691
1889
  subscription = deps.appState.addEventListener("change", (state) => {
@@ -1700,14 +1898,17 @@ function setupReactNative(sdk, config, deps = {}) {
1700
1898
  export {
1701
1899
  BrowserPopupRenderer,
1702
1900
  ContactManager,
1901
+ CrashReporter,
1703
1902
  DeepdotsPopups,
1704
1903
  InMemoryStorage,
1705
1904
  NoopPopupRenderer,
1706
1905
  POPUP_TRIGGER_CONDITION_STATUSES,
1707
1906
  ReactNativePopupRenderer,
1708
1907
  buildAnalyticsFeedbackBody,
1908
+ buildMessageParams,
1709
1909
  buildSurveyHtml,
1710
1910
  collectRnDevice,
1911
+ crashRecordToParams,
1711
1912
  createDefaultRenderer,
1712
1913
  createFeedbackSink,
1713
1914
  mmkvStorage,