@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.
package/dist/index.mjs CHANGED
@@ -405,6 +405,7 @@ var AnalyticsManager = class {
405
405
  this.device = options.device;
406
406
  this.platform = options.platform ?? "web";
407
407
  this.maxBatchSize = options.maxBatchSize ?? 20;
408
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
408
409
  this.onFlushNeeded = options.onFlushNeeded;
409
410
  }
410
411
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -465,6 +466,7 @@ var AnalyticsManager = class {
465
466
  timestamp: this.now(),
466
467
  params: Object.keys(merged).length ? merged : void 0
467
468
  });
469
+ this.enforceBufferCap();
468
470
  if (this.events.length >= this.maxBatchSize) {
469
471
  this.onFlushNeeded?.();
470
472
  }
@@ -495,14 +497,43 @@ var AnalyticsManager = class {
495
497
  events: [...this.events]
496
498
  };
497
499
  }
498
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
499
- 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) {
500
508
  if (this.events.length === 0) return null;
509
+ const batch = this.events;
501
510
  const payload = this.buildPayload(identity);
502
- this.sink(payload);
503
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
+ }
504
522
  return payload;
505
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
+ }
506
537
  };
507
538
 
508
539
  // src/analytics/feedback-payload.ts
@@ -565,33 +596,80 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
565
596
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
566
597
  };
567
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
+ }
568
610
  function createFeedbackSink(options) {
569
611
  let feedbackSessionId;
570
- return (envelope) => {
571
- 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
+ }
572
625
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
573
626
  if (!f) {
574
627
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
575
628
  return;
576
629
  }
577
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
578
- f(`${options.baseUrl}/sdk/feedback`, {
630
+ const res = await f(url, {
579
631
  method: "POST",
580
632
  headers: { "Content-Type": "application/json" },
581
- body: JSON.stringify(body)
582
- }).then(async (res) => {
583
- if (res.ok) {
584
- try {
585
- const data = await res.json();
586
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
587
- feedbackSessionId = data.sessionId;
588
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
589
- options.onSessionId?.(feedbackSessionId);
590
- }
591
- } catch {
592
- }
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}`);
641
+ }
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);
593
654
  }
594
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
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;
595
673
  };
596
674
  }
597
675
 
@@ -1081,6 +1159,11 @@ var DeepdotsPopups = class {
1081
1159
  this.contact = null;
1082
1160
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
1083
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;
1084
1167
  }
1085
1168
  /** Initialize the SDK with configuration */
1086
1169
  init(config) {
@@ -1122,19 +1205,22 @@ var DeepdotsPopups = class {
1122
1205
  baseUrl: this.baseUrl,
1123
1206
  keys: config.analytics,
1124
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,
1125
1210
  onSessionId: (id) => {
1126
1211
  this.analyticsFeedbackSessionId = id;
1127
1212
  }
1128
1213
  }) : createDryRunSink((...a) => this.logger.log(...a));
1129
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
+ });
1130
1220
  this.analytics = new AnalyticsManager({
1131
1221
  sink: analyticsSink,
1132
1222
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
1133
- language: resolveLanguage({
1134
- explicit: config.language,
1135
- navigator: typeof navigator !== "undefined" ? navigator : void 0,
1136
- intl: typeof Intl !== "undefined" ? Intl : void 0
1137
- }),
1223
+ language: this.language,
1138
1224
  platform: config.platform ?? "web",
1139
1225
  device,
1140
1226
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -1310,10 +1396,14 @@ var DeepdotsPopups = class {
1310
1396
  events: []
1311
1397
  };
1312
1398
  }
1313
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
1314
- 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) {
1315
1405
  if (!this.tracking?.isTrackingEnabled()) return;
1316
- this.analytics?.flush(this.analyticsIdentity());
1406
+ this.analytics?.flush(this.analyticsIdentity(), options);
1317
1407
  }
1318
1408
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
1319
1409
  flushEngagement() {
@@ -1336,7 +1426,7 @@ var DeepdotsPopups = class {
1336
1426
  this.navObserver?.stop();
1337
1427
  if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1338
1428
  this.flushEngagement();
1339
- this.flushAnalytics();
1429
+ this.flushAnalytics({ final: true });
1340
1430
  });
1341
1431
  document.addEventListener("visibilitychange", () => {
1342
1432
  if (document.visibilityState === "hidden") {
@@ -1457,14 +1547,16 @@ var DeepdotsPopups = class {
1457
1547
  matchesSegmentsLang(def) {
1458
1548
  const langs = def.segments?.lang;
1459
1549
  if (!langs || langs.length === 0) return true;
1460
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
1461
- if (!browserLang) {
1462
- 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 });
1463
1553
  return true;
1464
1554
  }
1465
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
1555
+ const matches = langs.some(
1556
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
1557
+ );
1466
1558
  if (!matches) {
1467
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
1559
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
1468
1560
  }
1469
1561
  return matches;
1470
1562
  }