@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.js CHANGED
@@ -3458,14 +3458,17 @@ 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,
3464
3465
  POPUP_TRIGGER_CONDITION_STATUSES: () => POPUP_TRIGGER_CONDITION_STATUSES,
3465
3466
  ReactNativePopupRenderer: () => ReactNativePopupRenderer,
3466
3467
  buildAnalyticsFeedbackBody: () => buildAnalyticsFeedbackBody,
3468
+ buildMessageParams: () => buildMessageParams,
3467
3469
  buildSurveyHtml: () => buildSurveyHtml,
3468
3470
  collectRnDevice: () => collectRnDevice,
3471
+ crashRecordToParams: () => crashRecordToParams,
3469
3472
  createDefaultRenderer: () => createDefaultRenderer,
3470
3473
  createFeedbackSink: () => createFeedbackSink,
3471
3474
  mmkvStorage: () => mmkvStorage,
@@ -3860,8 +3863,8 @@ var AnalyticsManager = class {
3860
3863
  constructor(options = {}) {
3861
3864
  this.events = [];
3862
3865
  this.attributes = {};
3863
- this.miniService = null;
3864
- this.miniServiceEnteredAt = 0;
3866
+ /** Mini-services activos: nombre → timestamp de entrada. El orden de inserción marca el "más reciente". */
3867
+ this.activeMiniServices = /* @__PURE__ */ new Map();
3865
3868
  this.sink = options.sink ?? dryRunSink;
3866
3869
  this.now = options.now ?? (() => Date.now());
3867
3870
  this.publicKey = options.publicKey;
@@ -3882,24 +3885,38 @@ var AnalyticsManager = class {
3882
3885
  this.attributes[k] = String(v);
3883
3886
  }
3884
3887
  }
3885
- /** Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`. */
3888
+ /**
3889
+ * Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`.
3890
+ * Admite varios activos a la vez (concurrencia); el "actual" para etiquetar es el más reciente.
3891
+ * Reentrar con un nombre ya activo refresca su orden y su tiempo de entrada.
3892
+ */
3886
3893
  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 });
3894
+ this.activeMiniServices.delete(name);
3895
+ this.activeMiniServices.set(name, this.now());
3896
+ this.track("deepdots_mini_service_enter", { entry_point_type: entryPointType ?? null });
3897
+ }
3898
+ /**
3899
+ * Cierra el mini-service `name` emitiendo `mini_service_exit` con su duración (#27).
3900
+ * No-op si ese nombre no está activo. Cierre coherente con concurrencia.
3901
+ */
3902
+ exitMiniService(name) {
3903
+ const enteredAt = this.activeMiniServices.get(name);
3904
+ if (enteredAt === void 0) return;
3905
+ const durationSeconds = Math.max(0, Math.round((this.now() - enteredAt) / 1e3));
3906
+ this.activeMiniServices.delete(name);
3907
+ this.track("deepdots_mini_service_exit", { mini_service: name, duration_seconds: durationSeconds });
3908
+ }
3909
+ /** Cierra TODOS los mini-services activos (orden LIFO). Para el cierre por lifecycle (background/cierre). */
3910
+ exitAllMiniServices() {
3911
+ for (const name of Array.from(this.activeMiniServices.keys()).reverse()) {
3912
+ this.exitMiniService(name);
3913
+ }
3898
3914
  }
3899
3915
  /** Registra un evento de analítica (modelo GA: nombre + params). */
3900
3916
  track(name, params) {
3917
+ const current = this.getMiniService();
3901
3918
  const merged = {
3902
- ...this.miniService ? { mini_service: this.miniService } : {},
3919
+ ...current ? { mini_service: current } : {},
3903
3920
  ...params ?? {}
3904
3921
  };
3905
3922
  this.events.push({
@@ -3911,9 +3928,11 @@ var AnalyticsManager = class {
3911
3928
  this.onFlushNeeded?.();
3912
3929
  }
3913
3930
  }
3914
- /** Mini-service activo (para inyectarlo en la metadata del survey, #33). */
3931
+ /** Mini-service actual (el más reciente aún activo) para etiquetar eventos + metadata del survey (#33). */
3915
3932
  getMiniService() {
3916
- return this.miniService;
3933
+ let last = null;
3934
+ for (const name of this.activeMiniServices.keys()) last = name;
3935
+ return last;
3917
3936
  }
3918
3937
  /** Nº de eventos pendientes de flush. */
3919
3938
  pending() {
@@ -4253,11 +4272,153 @@ var ContactManager = class {
4253
4272
  }
4254
4273
  };
4255
4274
 
4275
+ // src/analytics/crash-reporter.ts
4276
+ var STACK_MAX = 8e3;
4277
+ var QUEUE_KEY = "deepdots.crash.queue";
4278
+ var MAX_QUEUED = 20;
4279
+ function crashRecordToParams(r) {
4280
+ const params = {
4281
+ crashed_at: r.crashedAt,
4282
+ crash_type: r.crashType,
4283
+ message: r.message,
4284
+ stack: r.stack,
4285
+ fatal: r.fatal,
4286
+ handled: r.handled,
4287
+ severity: r.severity
4288
+ };
4289
+ if (r.sessionId) params.crashed_session_id = r.sessionId;
4290
+ if (r.appVersion) params.crashed_app_version = r.appVersion;
4291
+ if (r.osVersion) params.crashed_os_version = r.osVersion;
4292
+ if (r.deviceModel) params.crashed_device_model = r.deviceModel;
4293
+ if (r.context) {
4294
+ for (const [k, v] of Object.entries(r.context)) params[`ctx_${k}`] = v;
4295
+ }
4296
+ return params;
4297
+ }
4298
+ var CrashReporter = class {
4299
+ constructor(options) {
4300
+ this.options = options;
4301
+ this.now = options.now ?? (() => Date.now());
4302
+ }
4303
+ isEnabled() {
4304
+ return this.options.enabled ? this.options.enabled() : true;
4305
+ }
4306
+ /** Construye un CrashRecord capturando contexto en el momento del crash. */
4307
+ buildRecord(error, severity, handled, fatal, context) {
4308
+ const err = error;
4309
+ const isErr = error instanceof Error;
4310
+ const dev = this.options.device();
4311
+ const ctx = context ? Object.fromEntries(Object.entries(context).map(([k, v]) => [k, String(v)])) : void 0;
4312
+ return {
4313
+ crashedAt: this.now(),
4314
+ crashType: isErr && err.name || "Error",
4315
+ message: isErr ? String(err.message ?? "") : String(error),
4316
+ stack: (isErr && typeof err.stack === "string" ? err.stack : "").slice(0, STACK_MAX),
4317
+ fatal,
4318
+ handled,
4319
+ severity,
4320
+ sessionId: this.options.sessionId(),
4321
+ appVersion: dev.appVersion,
4322
+ osVersion: dev.osVersion,
4323
+ deviceModel: dev.deviceModel,
4324
+ context: ctx
4325
+ };
4326
+ }
4327
+ /** API pública del host: reporta un error (app viva) → emite el evento ya. */
4328
+ reportError(error, options = {}) {
4329
+ if (!this.isEnabled()) return;
4330
+ const severity = options.severity ?? "error";
4331
+ const handled = options.handled ?? true;
4332
+ const fatal = severity === "fatal";
4333
+ const record = this.buildRecord(error, severity, handled, fatal, options.context);
4334
+ this.options.emit(crashRecordToParams(record));
4335
+ }
4336
+ /** Captura un error no manejado (fatal) → persiste a disco para replay en el siguiente arranque. */
4337
+ captureUnhandled(error) {
4338
+ this.persist(this.buildRecord(error, "fatal", false, true));
4339
+ }
4340
+ /** Persiste un crash no capturado a disco para reenviarlo en el siguiente arranque. */
4341
+ persist(record) {
4342
+ let queue = [];
4343
+ try {
4344
+ const raw = this.options.storage.getItem(QUEUE_KEY);
4345
+ if (raw) queue = JSON.parse(raw);
4346
+ if (!Array.isArray(queue)) queue = [];
4347
+ } catch {
4348
+ queue = [];
4349
+ }
4350
+ queue.push(record);
4351
+ if (queue.length > MAX_QUEUED) queue = queue.slice(queue.length - MAX_QUEUED);
4352
+ try {
4353
+ this.options.storage.setItem(QUEUE_KEY, JSON.stringify(queue));
4354
+ } catch {
4355
+ }
4356
+ }
4357
+ /** Lee y vacía la cola de crashes pendientes (llamado en init para el replay). */
4358
+ drainPendingCrashes() {
4359
+ let queue = [];
4360
+ try {
4361
+ const raw = this.options.storage.getItem(QUEUE_KEY);
4362
+ if (raw) queue = JSON.parse(raw);
4363
+ if (!Array.isArray(queue)) queue = [];
4364
+ } catch {
4365
+ queue = [];
4366
+ }
4367
+ if (queue.length) {
4368
+ try {
4369
+ this.options.storage.removeItem(QUEUE_KEY);
4370
+ } catch {
4371
+ }
4372
+ }
4373
+ return queue;
4374
+ }
4375
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
4376
+ install() {
4377
+ if (typeof window === "undefined") return;
4378
+ window.addEventListener("error", (e) => {
4379
+ if (!this.isEnabled()) return;
4380
+ this.captureUnhandled(e.error ?? e.message ?? "error");
4381
+ });
4382
+ window.addEventListener("unhandledrejection", (e) => {
4383
+ if (!this.isEnabled()) return;
4384
+ this.captureUnhandled(e.reason ?? "unhandledrejection");
4385
+ });
4386
+ }
4387
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
4388
+ installReactNative(errorUtils) {
4389
+ const previous = errorUtils.getGlobalHandler?.();
4390
+ errorUtils.setGlobalHandler((error, isFatal) => {
4391
+ if (this.isEnabled()) this.captureUnhandled(error);
4392
+ previous?.(error, isFatal);
4393
+ });
4394
+ }
4395
+ /** @internal Test seam: ejercita el camino de persistencia (window handlers no son testeables en vitest). */
4396
+ _persistForTest(error) {
4397
+ this.captureUnhandled(error);
4398
+ }
4399
+ };
4400
+
4401
+ // src/analytics/messaging.ts
4402
+ function buildMessageParams(stage, o) {
4403
+ const p = {
4404
+ stage,
4405
+ message_id: o.id,
4406
+ message_title: o.title,
4407
+ channel: o.channel
4408
+ };
4409
+ if (o.campaign) p.campaign = o.campaign;
4410
+ if (o.value !== void 0) p.value = o.value;
4411
+ if (o.currency) p.currency = o.currency;
4412
+ return { ...p, ...o.params ?? {} };
4413
+ }
4414
+
4256
4415
  // src/core/deepdots-popups.ts
4257
4416
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
4258
4417
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
4259
4418
  var ANALYTICS_MAX_BATCH_SIZE = 20;
4260
4419
  var ANALYTICS_FLUSH_INTERVAL_MS = 3e4;
4420
+ var RESERVED_EVENT_PREFIX = "deepdots_";
4421
+ var CUSTOM_EVENT_PREFIX = "deepdots_event_";
4261
4422
  var DeepdotsPopups = class {
4262
4423
  constructor() {
4263
4424
  this.config = null;
@@ -4287,6 +4448,8 @@ var DeepdotsPopups = class {
4287
4448
  this.analyticsFeedbackSessionId = void 0;
4288
4449
  /** Timer del flush periódico de analytics (cada ANALYTICS_FLUSH_INTERVAL_MS). */
4289
4450
  this.analyticsFlushTimer = void 0;
4451
+ /** Crash & error reporting (#14–17). Null hasta init(). */
4452
+ this.crashReporter = null;
4290
4453
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
4291
4454
  this.navObserver = null;
4292
4455
  /** Tiempo activo (engagement time, #8). */
@@ -4338,25 +4501,44 @@ var DeepdotsPopups = class {
4338
4501
  this.analyticsFeedbackSessionId = id;
4339
4502
  }
4340
4503
  }) : void 0;
4504
+ const device = config.device ?? collectDeviceInfo(config.appVersion);
4341
4505
  this.analytics = new AnalyticsManager({
4342
4506
  sink: analyticsSink,
4343
4507
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
4344
4508
  language: typeof navigator !== "undefined" ? navigator.language : void 0,
4345
4509
  platform: config.platform ?? "web",
4346
- device: config.device ?? collectDeviceInfo(config.appVersion),
4510
+ device,
4347
4511
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
4348
4512
  onFlushNeeded: () => this.flushAnalytics()
4349
4513
  });
4514
+ this.crashReporter = new CrashReporter({
4515
+ storage,
4516
+ emit: (params) => this.track("deepdots_app_crash", params),
4517
+ device: () => ({ appVersion: device.app_version, osVersion: device.os_version, deviceModel: device.device_model }),
4518
+ sessionId: () => this.tracking?.getSessionId() ?? null,
4519
+ now: () => Date.now(),
4520
+ enabled: () => this.tracking?.isTrackingEnabled() ?? false
4521
+ });
4522
+ if (this.tracking?.isTrackingEnabled()) {
4523
+ this.crashReporter.install();
4524
+ }
4350
4525
  collectGeoInfo().then((geo) => {
4351
4526
  if (geo) this.analytics?.updateDevice(geo);
4352
4527
  }).catch(() => {
4353
4528
  });
4354
4529
  this.navObserver = new NavigationObserver();
4355
- this.navObserver.onVisit((v) => this.track("page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
4530
+ this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
4356
4531
  this.navObserver.install();
4357
4532
  this.engagement = new EngagementTracker();
4358
4533
  this.engagement.resume();
4359
4534
  this.setupAnalyticsFlush();
4535
+ this.track("deepdots_session_start", {});
4536
+ const pendingCrashes = this.crashReporter.drainPendingCrashes();
4537
+ if (this.tracking?.isTrackingEnabled()) {
4538
+ for (const rec of pendingCrashes) {
4539
+ this.track("deepdots_app_crash", crashRecordToParams(rec));
4540
+ }
4541
+ }
4360
4542
  this.initialized = true;
4361
4543
  this.log("SDK initialized", this.config);
4362
4544
  if (this.renderer.init) this.renderer.init();
@@ -4389,14 +4571,25 @@ var DeepdotsPopups = class {
4389
4571
  /** Registra un evento de analítica (modelo GA: nombre + params). No-op si tracking off. */
4390
4572
  track(name, params) {
4391
4573
  if (!this.tracking?.isTrackingEnabled()) return;
4392
- this.analytics?.track(name, params);
4393
- this.log("analytics \xB7 track:", name, params ?? {});
4574
+ const eventName = name.startsWith(RESERVED_EVENT_PREFIX) ? name : `${CUSTOM_EVENT_PREFIX}${name}`;
4575
+ this.analytics?.track(eventName, params);
4576
+ this.log("analytics \xB7 track:", eventName, params ?? {});
4394
4577
  }
4395
4578
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
4396
4579
  setUserAttributes(attributes) {
4397
4580
  if (!this.tracking?.isTrackingEnabled()) return;
4398
4581
  this.analytics?.setUserAttributes(attributes);
4399
4582
  }
4583
+ /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
4584
+ reportError(error, options) {
4585
+ if (!this.tracking?.isTrackingEnabled()) return;
4586
+ this.crashReporter?.reportError(error, options);
4587
+ }
4588
+ /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
4589
+ installReactNativeCrashHandler(errorUtils) {
4590
+ if (!this.tracking?.isTrackingEnabled()) return;
4591
+ this.crashReporter?.installReactNative(errorUtils);
4592
+ }
4400
4593
  /**
4401
4594
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
4402
4595
  * Contact del backend para segmentación/targeting de popups. Identificada por el `userId` del
@@ -4414,23 +4607,27 @@ var DeepdotsPopups = class {
4414
4607
  this.analytics?.enterMiniService(name, entryPointType);
4415
4608
  this.log("analytics \xB7 enterMiniService:", name, entryPointType ?? "");
4416
4609
  }
4417
- /** Cierra el mini-service activo (emite `mini_service_exit` con duración, #27). No-op si tracking off. */
4418
- exitMiniService() {
4610
+ /** Cierra el mini-service `name` (emite `mini_service_exit` con duración, #27). No-op si tracking off o si ese no está activo. */
4611
+ exitMiniService(name) {
4419
4612
  if (!this.tracking?.isTrackingEnabled()) return;
4420
- this.analytics?.exitMiniService();
4421
- this.log("analytics \xB7 exitMiniService");
4613
+ this.analytics?.exitMiniService(name);
4614
+ this.log("analytics \xB7 exitMiniService:", name);
4422
4615
  }
4423
4616
  /** Findability (#31/#35): registra una búsqueda. `has_results` se deriva de `resultsCount`. */
4424
4617
  trackSearch(query, resultsCount, params) {
4425
- this.track("search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
4618
+ this.track("deepdots_search", { query, results_count: resultsCount, has_results: resultsCount > 0, ...params ?? {} });
4426
4619
  }
4427
4620
  /** Findability friction (#34/#35): señal de fricción con su `friction_topic`. */
4428
4621
  trackFindabilityFriction(frictionTopic, params) {
4429
- this.track("findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
4622
+ this.track("deepdots_findability_friction", { friction_topic: frictionTopic, ...params ?? {} });
4430
4623
  }
4431
4624
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
4432
4625
  trackFunnelStep(funnel, step, taskId, params) {
4433
- this.track("funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
4626
+ this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
4627
+ }
4628
+ /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
4629
+ trackMessage(stage, options) {
4630
+ this.track("deepdots_message", buildMessageParams(stage, options));
4434
4631
  }
4435
4632
  /**
4436
4633
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
@@ -4456,7 +4653,7 @@ var DeepdotsPopups = class {
4456
4653
  onBackground() {
4457
4654
  this.navObserver?.stop();
4458
4655
  this.navStarted = false;
4459
- this.exitMiniService();
4656
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4460
4657
  this.flushEngagement();
4461
4658
  this.engagement?.pause();
4462
4659
  this.flushAnalytics();
@@ -4479,7 +4676,7 @@ var DeepdotsPopups = class {
4479
4676
  flushEngagement() {
4480
4677
  if (!this.tracking?.isTrackingEnabled()) return;
4481
4678
  const ms = this.engagement?.consume() ?? 0;
4482
- if (ms > 0) this.track("user_engagement", { engagement_time_msec: ms });
4679
+ if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
4483
4680
  }
4484
4681
  analyticsIdentity() {
4485
4682
  return {
@@ -4494,7 +4691,7 @@ var DeepdotsPopups = class {
4494
4691
  window.addEventListener("pagehide", () => {
4495
4692
  clearInterval(this.analyticsFlushTimer);
4496
4693
  this.navObserver?.stop();
4497
- this.exitMiniService();
4694
+ if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4498
4695
  this.flushEngagement();
4499
4696
  this.flushAnalytics();
4500
4697
  });
@@ -5163,6 +5360,10 @@ function setupReactNative(sdk, config, deps = {}) {
5163
5360
  const device = deps.deviceInfo ? collectRnDevice(deps.deviceInfo) : config.device;
5164
5361
  const platform = deps.platform ?? config.platform;
5165
5362
  sdk.init({ ...config, storage, device, platform });
5363
+ const errorUtils = deps.errorUtils ?? globalThis.ErrorUtils;
5364
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
5365
+ sdk.installReactNativeCrashHandler(errorUtils);
5366
+ }
5166
5367
  let subscription;
5167
5368
  if (deps.appState?.addEventListener) {
5168
5369
  subscription = deps.appState.addEventListener("change", (state) => {