@magicfeedback/popup-sdk 1.1.6 → 1.1.8

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.
@@ -399,6 +399,8 @@ var AnalyticsManager = class {
399
399
  constructor(options = {}) {
400
400
  this.events = [];
401
401
  this.attributes = {};
402
+ /** Métricas del host (valores medibles persistentes). Sobrescriben por key; se reenvían en cada flush. */
403
+ this.metrics = {};
402
404
  /** Mini-services activos: nombre → timestamp de entrada. El orden de inserción marca el "más reciente". */
403
405
  this.activeMiniServices = /* @__PURE__ */ new Map();
404
406
  this.sink = options.sink ?? dryRunSink;
@@ -408,6 +410,7 @@ var AnalyticsManager = class {
408
410
  this.device = options.device;
409
411
  this.platform = options.platform ?? "web";
410
412
  this.maxBatchSize = options.maxBatchSize ?? 20;
413
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
411
414
  this.onFlushNeeded = options.onFlushNeeded;
412
415
  }
413
416
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -421,6 +424,14 @@ var AnalyticsManager = class {
421
424
  this.attributes[k] = String(v);
422
425
  }
423
426
  }
427
+ /**
428
+ * Registra/actualiza una métrica (valor medible). Persistente: se reenvía en cada flush.
429
+ * Repetir la misma key SOBRESCRIBE; el valor se coerciona a string; ignora key vacía.
430
+ */
431
+ setMetric(key, value) {
432
+ if (!key) return;
433
+ this.metrics[key] = String(value);
434
+ }
424
435
  /**
425
436
  * Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`.
426
437
  * Admite varios activos a la vez (concurrencia); el "actual" para etiquetar es el más reciente.
@@ -460,6 +471,7 @@ var AnalyticsManager = class {
460
471
  timestamp: this.now(),
461
472
  params: Object.keys(merged).length ? merged : void 0
462
473
  });
474
+ this.enforceBufferCap();
463
475
  if (this.events.length >= this.maxBatchSize) {
464
476
  this.onFlushNeeded?.();
465
477
  }
@@ -484,19 +496,49 @@ var AnalyticsManager = class {
484
496
  platform: this.platform,
485
497
  language: this.language,
486
498
  device: this.device,
487
- attributes: { ...this.attributes }
499
+ attributes: { ...this.attributes },
500
+ metrics: { ...this.metrics }
488
501
  },
489
502
  events: [...this.events]
490
503
  };
491
504
  }
492
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
493
- flush(identity) {
505
+ /**
506
+ * Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos.
507
+ *
508
+ * Si el sink falla de forma transitoria (promesa rechazada o excepción síncrona) el lote
509
+ * se **re-encola** al principio del buffer y se reintenta en el siguiente flush: un error
510
+ * de red o un 5xx no debe hacer desaparecer los eventos.
511
+ */
512
+ flush(identity, meta) {
494
513
  if (this.events.length === 0) return null;
514
+ const batch = this.events;
495
515
  const payload = this.buildPayload(identity);
496
- this.sink(payload);
497
516
  this.events = [];
517
+ let result;
518
+ try {
519
+ result = meta ? this.sink(payload, meta) : this.sink(payload);
520
+ } catch {
521
+ this.requeue(batch);
522
+ return payload;
523
+ }
524
+ if (result && typeof result.then === "function") {
525
+ result.catch(() => this.requeue(batch));
526
+ }
498
527
  return payload;
499
528
  }
529
+ /**
530
+ * Devuelve un lote fallido al buffer, delante de lo que haya llegado mientras se enviaba
531
+ * (orden cronológico). Con el buffer lleno se descartan los eventos más antiguos.
532
+ */
533
+ requeue(batch) {
534
+ this.events = [...batch, ...this.events];
535
+ this.enforceBufferCap();
536
+ }
537
+ /** Techo del buffer: si el envío lleva rato fallando, se sacrifica lo más antiguo. */
538
+ enforceBufferCap() {
539
+ const overflow = this.events.length - this.maxBufferedEvents;
540
+ if (overflow > 0) this.events.splice(0, overflow);
541
+ }
500
542
  };
501
543
 
502
544
  // src/analytics/feedback-payload.ts
@@ -540,10 +582,15 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
540
582
  value: [JSON.stringify({ timestamp: e.timestamp, ...e.params ?? {} })]
541
583
  });
542
584
  }
585
+ const metrics = [];
586
+ for (const [k, v] of Object.entries(context.metrics ?? {})) {
587
+ pushKV(metrics, k, v);
588
+ }
543
589
  return {
544
590
  feedback: {
545
591
  text: "",
546
592
  answers: [],
593
+ metrics,
547
594
  metadata,
548
595
  profile,
549
596
  finished: false
@@ -554,33 +601,80 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
554
601
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
555
602
  };
556
603
  }
604
+ var KEEPALIVE_MAX_BYTES = 6e4;
605
+ function isRetryableStatus(status) {
606
+ return status >= 500 || status === 408 || status === 429;
607
+ }
608
+ async function safeBodyText(res) {
609
+ try {
610
+ return (await res.text()).slice(0, 500);
611
+ } catch {
612
+ return "";
613
+ }
614
+ }
557
615
  function createFeedbackSink(options) {
558
616
  let feedbackSessionId;
559
- return (envelope) => {
560
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
617
+ let firstPostInFlight = null;
618
+ const url = `${options.baseUrl}/sdk/feedback`;
619
+ const post = async (body, final) => {
620
+ const json = JSON.stringify(body);
621
+ const small = json.length <= KEEPALIVE_MAX_BYTES;
622
+ if (final && options.sendBeaconImpl && small) {
623
+ const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
624
+ if (options.sendBeaconImpl(url, payload)) {
625
+ options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
626
+ return;
627
+ }
628
+ options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
629
+ }
561
630
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
562
631
  if (!f) {
563
632
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
564
633
  return;
565
634
  }
566
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
567
- f(`${options.baseUrl}/sdk/feedback`, {
635
+ const res = await f(url, {
568
636
  method: "POST",
569
637
  headers: { "Content-Type": "application/json" },
570
- body: JSON.stringify(body)
571
- }).then(async (res) => {
572
- if (res.ok) {
573
- try {
574
- const data = await res.json();
575
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
576
- feedbackSessionId = data.sessionId;
577
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
578
- options.onSessionId?.(feedbackSessionId);
579
- }
580
- } catch {
581
- }
638
+ body: json,
639
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
640
+ ...small ? { keepalive: true } : {}
641
+ });
642
+ if (!res.ok) {
643
+ const detail = await safeBodyText(res);
644
+ if (isRetryableStatus(res.status)) {
645
+ throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
582
646
  }
583
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
647
+ options.log?.(
648
+ `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
649
+ detail
650
+ );
651
+ return;
652
+ }
653
+ try {
654
+ const data = await res.json();
655
+ if (data?.sessionId && data.sessionId !== feedbackSessionId) {
656
+ feedbackSessionId = data.sessionId;
657
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
658
+ options.onSessionId?.(feedbackSessionId);
659
+ }
660
+ } catch {
661
+ }
662
+ };
663
+ return async (envelope, meta) => {
664
+ const final = meta?.final === true;
665
+ if (!feedbackSessionId && firstPostInFlight && !final) {
666
+ await firstPostInFlight;
667
+ }
668
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
669
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
670
+ const sent = post(body, final);
671
+ if (!feedbackSessionId) {
672
+ firstPostInFlight = sent.then(
673
+ () => void 0,
674
+ () => void 0
675
+ );
676
+ }
677
+ await sent;
584
678
  };
585
679
  }
586
680
 
@@ -724,20 +818,95 @@ function collectDeviceInfo(appVersion) {
724
818
  }
725
819
 
726
820
  // src/analytics/geo-info.ts
727
- async function collectGeoInfo(fetchImpl) {
728
- const f = fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
729
- if (!f) return null;
821
+ var GEO_STORAGE_KEY = "deepdots.geo";
822
+ var GEO_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
823
+ var GEO_TIMEOUT_MS = 3e3;
824
+ function readCachedGeo(storage, now) {
825
+ try {
826
+ const raw = storage.getItem(GEO_STORAGE_KEY);
827
+ if (!raw) return null;
828
+ const cached = JSON.parse(raw);
829
+ if (typeof cached.ts !== "number" || now - cached.ts > GEO_TTL_MS) return null;
830
+ const geo = {};
831
+ if (cached.country) geo.country = cached.country;
832
+ if (cached.city) geo.city = cached.city;
833
+ return geo.country || geo.city ? geo : null;
834
+ } catch {
835
+ return null;
836
+ }
837
+ }
838
+ function writeCachedGeo(storage, geo, now) {
839
+ try {
840
+ const payload = { ...geo, ts: now };
841
+ storage.setItem(GEO_STORAGE_KEY, JSON.stringify(payload));
842
+ } catch {
843
+ }
844
+ }
845
+ var DEFAULT_GEO_PROVIDERS = [
846
+ {
847
+ // https://ipapi.co/json/
848
+ url: "https://ipapi.co/json/",
849
+ parse: (d) => geoFrom(d.country_code, d.city)
850
+ },
851
+ {
852
+ // https://ipwho.is/ (devuelve success:false con 200 en errores)
853
+ url: "https://ipwho.is/",
854
+ parse: (d) => d.success === false ? null : geoFrom(d.country_code, d.city)
855
+ },
856
+ {
857
+ // https://ipinfo.io/json (country = código ISO)
858
+ url: "https://ipinfo.io/json",
859
+ parse: (d) => geoFrom(d.country, d.city)
860
+ }
861
+ ];
862
+ function geoFrom(country, city) {
863
+ const c = typeof country === "string" && country ? country : void 0;
864
+ const t = typeof city === "string" && city ? city : void 0;
865
+ if (!c && !t) return null;
866
+ return { country: c, city: t };
867
+ }
868
+ async function fetchOne(provider, f, timeoutMs) {
869
+ const controller = new AbortController();
870
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
730
871
  try {
731
- const res = await f("https://ipapi.co/json/");
872
+ const res = await f(provider.url, { signal: controller.signal });
732
873
  if (!res.ok) return null;
733
874
  const data = await res.json();
734
- const country = data.country_code || void 0;
735
- const city = data.city || void 0;
736
- if (!country && !city) return null;
737
- return { country, city };
875
+ return provider.parse(data);
738
876
  } catch {
739
877
  return null;
878
+ } finally {
879
+ clearTimeout(timer);
880
+ }
881
+ }
882
+ async function fetchGeo(options = {}) {
883
+ const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
884
+ if (!f) return null;
885
+ const providers = options.providers ?? DEFAULT_GEO_PROVIDERS;
886
+ const timeoutMs = options.timeoutMs ?? GEO_TIMEOUT_MS;
887
+ for (const provider of providers) {
888
+ const geo = await fetchOne(provider, f, timeoutMs);
889
+ if (geo) return geo;
890
+ }
891
+ return null;
892
+ }
893
+
894
+ // src/analytics/language.ts
895
+ function clean(value) {
896
+ const trimmed = value?.trim();
897
+ return trimmed ? trimmed : void 0;
898
+ }
899
+ function resolveLanguage(sources) {
900
+ const explicit = clean(sources.explicit);
901
+ if (explicit) return explicit;
902
+ const navLang = clean(sources.navigator?.language);
903
+ if (navLang) return navLang;
904
+ try {
905
+ const locale = clean(sources.intl?.DateTimeFormat().resolvedOptions().locale);
906
+ if (locale) return locale;
907
+ } catch {
740
908
  }
909
+ return void 0;
741
910
  }
742
911
 
743
912
  // src/analytics/engagement-tracker.ts
@@ -995,6 +1164,11 @@ var DeepdotsPopups = class {
995
1164
  this.contact = null;
996
1165
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
997
1166
  this.navStarted = false;
1167
+ /**
1168
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1169
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
1170
+ */
1171
+ this.language = void 0;
998
1172
  }
999
1173
  /** Initialize the SDK with configuration */
1000
1174
  init(config) {
@@ -1036,15 +1210,22 @@ var DeepdotsPopups = class {
1036
1210
  baseUrl: this.baseUrl,
1037
1211
  keys: config.analytics,
1038
1212
  log: (...a) => this.log(...a),
1213
+ // sendBeacon: único transporte que sobrevive al cierre de la página.
1214
+ sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
1039
1215
  onSessionId: (id) => {
1040
1216
  this.analyticsFeedbackSessionId = id;
1041
1217
  }
1042
1218
  }) : createDryRunSink((...a) => this.logger.log(...a));
1043
1219
  const device = config.device ?? collectDeviceInfo(config.appVersion);
1220
+ this.language = resolveLanguage({
1221
+ explicit: config.language,
1222
+ navigator: typeof navigator !== "undefined" ? navigator : void 0,
1223
+ intl: typeof Intl !== "undefined" ? Intl : void 0
1224
+ });
1044
1225
  this.analytics = new AnalyticsManager({
1045
1226
  sink: analyticsSink,
1046
1227
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
1047
- language: typeof navigator !== "undefined" ? navigator.language : void 0,
1228
+ language: this.language,
1048
1229
  platform: config.platform ?? "web",
1049
1230
  device,
1050
1231
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -1061,8 +1242,13 @@ var DeepdotsPopups = class {
1061
1242
  if (this.tracking?.isTrackingEnabled()) {
1062
1243
  this.crashReporter.install();
1063
1244
  }
1064
- collectGeoInfo().then((geo) => {
1065
- if (geo) this.analytics?.updateDevice(geo);
1245
+ const cachedGeo = readCachedGeo(storage, Date.now());
1246
+ if (cachedGeo) this.analytics.updateDevice(cachedGeo);
1247
+ fetchGeo().then((geo) => {
1248
+ if (geo) {
1249
+ this.analytics?.updateDevice(geo);
1250
+ writeCachedGeo(storage, geo, Date.now());
1251
+ }
1066
1252
  }).catch(() => {
1067
1253
  });
1068
1254
  this.navObserver = new NavigationObserver();
@@ -1119,6 +1305,15 @@ var DeepdotsPopups = class {
1119
1305
  if (!this.tracking?.isTrackingEnabled()) return;
1120
1306
  this.analytics?.setUserAttributes(attributes);
1121
1307
  }
1308
+ /**
1309
+ * Registra/actualiza una métrica (valor medible) → `feedback.metrics` del body.
1310
+ * Persistente y sobrescribe por key. No-op si tracking off.
1311
+ */
1312
+ setMetric(key, value) {
1313
+ if (!this.tracking?.isTrackingEnabled()) return;
1314
+ this.analytics?.setMetric(key, value);
1315
+ this.log("analytics \xB7 setMetric:", key, value);
1316
+ }
1122
1317
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
1123
1318
  reportError(error, options) {
1124
1319
  if (!this.tracking?.isTrackingEnabled()) return;
@@ -1206,10 +1401,14 @@ var DeepdotsPopups = class {
1206
1401
  events: []
1207
1402
  };
1208
1403
  }
1209
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
1210
- flushAnalytics() {
1404
+ /**
1405
+ * Envía el lote acumulado de analytics y vacía el buffer.
1406
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
1407
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
1408
+ */
1409
+ flushAnalytics(options) {
1211
1410
  if (!this.tracking?.isTrackingEnabled()) return;
1212
- this.analytics?.flush(this.analyticsIdentity());
1411
+ this.analytics?.flush(this.analyticsIdentity(), options);
1213
1412
  }
1214
1413
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
1215
1414
  flushEngagement() {
@@ -1232,7 +1431,7 @@ var DeepdotsPopups = class {
1232
1431
  this.navObserver?.stop();
1233
1432
  if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1234
1433
  this.flushEngagement();
1235
- this.flushAnalytics();
1434
+ this.flushAnalytics({ final: true });
1236
1435
  });
1237
1436
  document.addEventListener("visibilitychange", () => {
1238
1437
  if (document.visibilityState === "hidden") {
@@ -1353,14 +1552,16 @@ var DeepdotsPopups = class {
1353
1552
  matchesSegmentsLang(def) {
1354
1553
  const langs = def.segments?.lang;
1355
1554
  if (!langs || langs.length === 0) return true;
1356
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
1357
- if (!browserLang) {
1358
- this.debug("No navigator.language available for lang comparison", { popupId: def.id, langs });
1555
+ const currentLang = (this.language ?? "").toLowerCase();
1556
+ if (!currentLang) {
1557
+ this.debug("No language available for lang comparison", { popupId: def.id, langs });
1359
1558
  return true;
1360
1559
  }
1361
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
1560
+ const matches = langs.some(
1561
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
1562
+ );
1362
1563
  if (!matches) {
1363
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
1564
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
1364
1565
  }
1365
1566
  return matches;
1366
1567
  }