@magicfeedback/popup-sdk 1.1.7 → 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.
@@ -410,6 +410,7 @@ var AnalyticsManager = class {
410
410
  this.device = options.device;
411
411
  this.platform = options.platform ?? "web";
412
412
  this.maxBatchSize = options.maxBatchSize ?? 20;
413
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
413
414
  this.onFlushNeeded = options.onFlushNeeded;
414
415
  }
415
416
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -470,6 +471,7 @@ var AnalyticsManager = class {
470
471
  timestamp: this.now(),
471
472
  params: Object.keys(merged).length ? merged : void 0
472
473
  });
474
+ this.enforceBufferCap();
473
475
  if (this.events.length >= this.maxBatchSize) {
474
476
  this.onFlushNeeded?.();
475
477
  }
@@ -500,14 +502,43 @@ var AnalyticsManager = class {
500
502
  events: [...this.events]
501
503
  };
502
504
  }
503
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
504
- 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) {
505
513
  if (this.events.length === 0) return null;
514
+ const batch = this.events;
506
515
  const payload = this.buildPayload(identity);
507
- this.sink(payload);
508
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
+ }
509
527
  return payload;
510
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
+ }
511
542
  };
512
543
 
513
544
  // src/analytics/feedback-payload.ts
@@ -570,33 +601,80 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
570
601
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
571
602
  };
572
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
+ }
573
615
  function createFeedbackSink(options) {
574
616
  let feedbackSessionId;
575
- return (envelope) => {
576
- 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
+ }
577
630
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
578
631
  if (!f) {
579
632
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
580
633
  return;
581
634
  }
582
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
583
- f(`${options.baseUrl}/sdk/feedback`, {
635
+ const res = await f(url, {
584
636
  method: "POST",
585
637
  headers: { "Content-Type": "application/json" },
586
- body: JSON.stringify(body)
587
- }).then(async (res) => {
588
- if (res.ok) {
589
- try {
590
- const data = await res.json();
591
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
592
- feedbackSessionId = data.sessionId;
593
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
594
- options.onSessionId?.(feedbackSessionId);
595
- }
596
- } catch {
597
- }
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}`);
646
+ }
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);
598
659
  }
599
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
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;
600
678
  };
601
679
  }
602
680
 
@@ -1086,6 +1164,11 @@ var DeepdotsPopups = class {
1086
1164
  this.contact = null;
1087
1165
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
1088
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;
1089
1172
  }
1090
1173
  /** Initialize the SDK with configuration */
1091
1174
  init(config) {
@@ -1127,19 +1210,22 @@ var DeepdotsPopups = class {
1127
1210
  baseUrl: this.baseUrl,
1128
1211
  keys: config.analytics,
1129
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,
1130
1215
  onSessionId: (id) => {
1131
1216
  this.analyticsFeedbackSessionId = id;
1132
1217
  }
1133
1218
  }) : createDryRunSink((...a) => this.logger.log(...a));
1134
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
+ });
1135
1225
  this.analytics = new AnalyticsManager({
1136
1226
  sink: analyticsSink,
1137
1227
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
1138
- language: resolveLanguage({
1139
- explicit: config.language,
1140
- navigator: typeof navigator !== "undefined" ? navigator : void 0,
1141
- intl: typeof Intl !== "undefined" ? Intl : void 0
1142
- }),
1228
+ language: this.language,
1143
1229
  platform: config.platform ?? "web",
1144
1230
  device,
1145
1231
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -1315,10 +1401,14 @@ var DeepdotsPopups = class {
1315
1401
  events: []
1316
1402
  };
1317
1403
  }
1318
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
1319
- 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) {
1320
1410
  if (!this.tracking?.isTrackingEnabled()) return;
1321
- this.analytics?.flush(this.analyticsIdentity());
1411
+ this.analytics?.flush(this.analyticsIdentity(), options);
1322
1412
  }
1323
1413
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
1324
1414
  flushEngagement() {
@@ -1341,7 +1431,7 @@ var DeepdotsPopups = class {
1341
1431
  this.navObserver?.stop();
1342
1432
  if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1343
1433
  this.flushEngagement();
1344
- this.flushAnalytics();
1434
+ this.flushAnalytics({ final: true });
1345
1435
  });
1346
1436
  document.addEventListener("visibilitychange", () => {
1347
1437
  if (document.visibilityState === "hidden") {
@@ -1462,14 +1552,16 @@ var DeepdotsPopups = class {
1462
1552
  matchesSegmentsLang(def) {
1463
1553
  const langs = def.segments?.lang;
1464
1554
  if (!langs || langs.length === 0) return true;
1465
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
1466
- if (!browserLang) {
1467
- 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 });
1468
1558
  return true;
1469
1559
  }
1470
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
1560
+ const matches = langs.some(
1561
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
1562
+ );
1471
1563
  if (!matches) {
1472
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
1564
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
1473
1565
  }
1474
1566
  return matches;
1475
1567
  }