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