@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.js CHANGED
@@ -146,8 +146,8 @@ var init_tracking_manager = __esm({
146
146
 
147
147
  // src/ui/font.ts
148
148
  function fontFormatFromUrl(url) {
149
- const clean = url.split("?")[0].split("#")[0];
150
- const ext = clean.slice(clean.lastIndexOf(".") + 1).toLowerCase();
149
+ const clean2 = url.split("?")[0].split("#")[0];
150
+ const ext = clean2.slice(clean2.lastIndexOf(".") + 1).toLowerCase();
151
151
  return FORMAT_BY_EXT[ext];
152
152
  }
153
153
  function sanitizeFamily(family) {
@@ -3957,6 +3957,8 @@ var AnalyticsManager = class {
3957
3957
  constructor(options = {}) {
3958
3958
  this.events = [];
3959
3959
  this.attributes = {};
3960
+ /** Métricas del host (valores medibles persistentes). Sobrescriben por key; se reenvían en cada flush. */
3961
+ this.metrics = {};
3960
3962
  /** Mini-services activos: nombre → timestamp de entrada. El orden de inserción marca el "más reciente". */
3961
3963
  this.activeMiniServices = /* @__PURE__ */ new Map();
3962
3964
  this.sink = options.sink ?? dryRunSink;
@@ -3966,6 +3968,7 @@ var AnalyticsManager = class {
3966
3968
  this.device = options.device;
3967
3969
  this.platform = options.platform ?? "web";
3968
3970
  this.maxBatchSize = options.maxBatchSize ?? 20;
3971
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
3969
3972
  this.onFlushNeeded = options.onFlushNeeded;
3970
3973
  }
3971
3974
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -3979,6 +3982,14 @@ var AnalyticsManager = class {
3979
3982
  this.attributes[k] = String(v);
3980
3983
  }
3981
3984
  }
3985
+ /**
3986
+ * Registra/actualiza una métrica (valor medible). Persistente: se reenvía en cada flush.
3987
+ * Repetir la misma key SOBRESCRIBE; el valor se coerciona a string; ignora key vacía.
3988
+ */
3989
+ setMetric(key, value) {
3990
+ if (!key) return;
3991
+ this.metrics[key] = String(value);
3992
+ }
3982
3993
  /**
3983
3994
  * Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`.
3984
3995
  * Admite varios activos a la vez (concurrencia); el "actual" para etiquetar es el más reciente.
@@ -4018,6 +4029,7 @@ var AnalyticsManager = class {
4018
4029
  timestamp: this.now(),
4019
4030
  params: Object.keys(merged).length ? merged : void 0
4020
4031
  });
4032
+ this.enforceBufferCap();
4021
4033
  if (this.events.length >= this.maxBatchSize) {
4022
4034
  this.onFlushNeeded?.();
4023
4035
  }
@@ -4042,19 +4054,49 @@ var AnalyticsManager = class {
4042
4054
  platform: this.platform,
4043
4055
  language: this.language,
4044
4056
  device: this.device,
4045
- attributes: { ...this.attributes }
4057
+ attributes: { ...this.attributes },
4058
+ metrics: { ...this.metrics }
4046
4059
  },
4047
4060
  events: [...this.events]
4048
4061
  };
4049
4062
  }
4050
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
4051
- flush(identity) {
4063
+ /**
4064
+ * Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos.
4065
+ *
4066
+ * Si el sink falla de forma transitoria (promesa rechazada o excepción síncrona) el lote
4067
+ * se **re-encola** al principio del buffer y se reintenta en el siguiente flush: un error
4068
+ * de red o un 5xx no debe hacer desaparecer los eventos.
4069
+ */
4070
+ flush(identity, meta) {
4052
4071
  if (this.events.length === 0) return null;
4072
+ const batch = this.events;
4053
4073
  const payload = this.buildPayload(identity);
4054
- this.sink(payload);
4055
4074
  this.events = [];
4075
+ let result;
4076
+ try {
4077
+ result = meta ? this.sink(payload, meta) : this.sink(payload);
4078
+ } catch {
4079
+ this.requeue(batch);
4080
+ return payload;
4081
+ }
4082
+ if (result && typeof result.then === "function") {
4083
+ result.catch(() => this.requeue(batch));
4084
+ }
4056
4085
  return payload;
4057
4086
  }
4087
+ /**
4088
+ * Devuelve un lote fallido al buffer, delante de lo que haya llegado mientras se enviaba
4089
+ * (orden cronológico). Con el buffer lleno se descartan los eventos más antiguos.
4090
+ */
4091
+ requeue(batch) {
4092
+ this.events = [...batch, ...this.events];
4093
+ this.enforceBufferCap();
4094
+ }
4095
+ /** Techo del buffer: si el envío lleva rato fallando, se sacrifica lo más antiguo. */
4096
+ enforceBufferCap() {
4097
+ const overflow = this.events.length - this.maxBufferedEvents;
4098
+ if (overflow > 0) this.events.splice(0, overflow);
4099
+ }
4058
4100
  };
4059
4101
 
4060
4102
  // src/analytics/feedback-payload.ts
@@ -4098,10 +4140,15 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
4098
4140
  value: [JSON.stringify({ timestamp: e.timestamp, ...e.params ?? {} })]
4099
4141
  });
4100
4142
  }
4143
+ const metrics = [];
4144
+ for (const [k, v] of Object.entries(context.metrics ?? {})) {
4145
+ pushKV(metrics, k, v);
4146
+ }
4101
4147
  return {
4102
4148
  feedback: {
4103
4149
  text: "",
4104
4150
  answers: [],
4151
+ metrics,
4105
4152
  metadata,
4106
4153
  profile,
4107
4154
  finished: false
@@ -4112,33 +4159,80 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
4112
4159
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
4113
4160
  };
4114
4161
  }
4162
+ var KEEPALIVE_MAX_BYTES = 6e4;
4163
+ function isRetryableStatus(status) {
4164
+ return status >= 500 || status === 408 || status === 429;
4165
+ }
4166
+ async function safeBodyText(res) {
4167
+ try {
4168
+ return (await res.text()).slice(0, 500);
4169
+ } catch {
4170
+ return "";
4171
+ }
4172
+ }
4115
4173
  function createFeedbackSink(options) {
4116
4174
  let feedbackSessionId;
4117
- return (envelope) => {
4118
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
4175
+ let firstPostInFlight = null;
4176
+ const url = `${options.baseUrl}/sdk/feedback`;
4177
+ const post = async (body, final) => {
4178
+ const json = JSON.stringify(body);
4179
+ const small = json.length <= KEEPALIVE_MAX_BYTES;
4180
+ if (final && options.sendBeaconImpl && small) {
4181
+ const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
4182
+ if (options.sendBeaconImpl(url, payload)) {
4183
+ options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
4184
+ return;
4185
+ }
4186
+ options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
4187
+ }
4119
4188
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
4120
4189
  if (!f) {
4121
4190
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
4122
4191
  return;
4123
4192
  }
4124
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4125
- f(`${options.baseUrl}/sdk/feedback`, {
4193
+ const res = await f(url, {
4126
4194
  method: "POST",
4127
4195
  headers: { "Content-Type": "application/json" },
4128
- body: JSON.stringify(body)
4129
- }).then(async (res) => {
4130
- if (res.ok) {
4131
- try {
4132
- const data = await res.json();
4133
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
4134
- feedbackSessionId = data.sessionId;
4135
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
4136
- options.onSessionId?.(feedbackSessionId);
4137
- }
4138
- } catch {
4139
- }
4196
+ body: json,
4197
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
4198
+ ...small ? { keepalive: true } : {}
4199
+ });
4200
+ if (!res.ok) {
4201
+ const detail = await safeBodyText(res);
4202
+ if (isRetryableStatus(res.status)) {
4203
+ throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
4140
4204
  }
4141
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
4205
+ options.log?.(
4206
+ `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
4207
+ detail
4208
+ );
4209
+ return;
4210
+ }
4211
+ try {
4212
+ const data = await res.json();
4213
+ if (data?.sessionId && data.sessionId !== feedbackSessionId) {
4214
+ feedbackSessionId = data.sessionId;
4215
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
4216
+ options.onSessionId?.(feedbackSessionId);
4217
+ }
4218
+ } catch {
4219
+ }
4220
+ };
4221
+ return async (envelope, meta) => {
4222
+ const final = meta?.final === true;
4223
+ if (!feedbackSessionId && firstPostInFlight && !final) {
4224
+ await firstPostInFlight;
4225
+ }
4226
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
4227
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4228
+ const sent = post(body, final);
4229
+ if (!feedbackSessionId) {
4230
+ firstPostInFlight = sent.then(
4231
+ () => void 0,
4232
+ () => void 0
4233
+ );
4234
+ }
4235
+ await sent;
4142
4236
  };
4143
4237
  }
4144
4238
 
@@ -4282,21 +4376,96 @@ function collectDeviceInfo(appVersion) {
4282
4376
  }
4283
4377
 
4284
4378
  // src/analytics/geo-info.ts
4285
- async function collectGeoInfo(fetchImpl) {
4286
- const f = fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
4287
- if (!f) return null;
4379
+ var GEO_STORAGE_KEY = "deepdots.geo";
4380
+ var GEO_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
4381
+ var GEO_TIMEOUT_MS = 3e3;
4382
+ function readCachedGeo(storage, now) {
4383
+ try {
4384
+ const raw = storage.getItem(GEO_STORAGE_KEY);
4385
+ if (!raw) return null;
4386
+ const cached = JSON.parse(raw);
4387
+ if (typeof cached.ts !== "number" || now - cached.ts > GEO_TTL_MS) return null;
4388
+ const geo = {};
4389
+ if (cached.country) geo.country = cached.country;
4390
+ if (cached.city) geo.city = cached.city;
4391
+ return geo.country || geo.city ? geo : null;
4392
+ } catch {
4393
+ return null;
4394
+ }
4395
+ }
4396
+ function writeCachedGeo(storage, geo, now) {
4397
+ try {
4398
+ const payload = { ...geo, ts: now };
4399
+ storage.setItem(GEO_STORAGE_KEY, JSON.stringify(payload));
4400
+ } catch {
4401
+ }
4402
+ }
4403
+ var DEFAULT_GEO_PROVIDERS = [
4404
+ {
4405
+ // https://ipapi.co/json/
4406
+ url: "https://ipapi.co/json/",
4407
+ parse: (d) => geoFrom(d.country_code, d.city)
4408
+ },
4409
+ {
4410
+ // https://ipwho.is/ (devuelve success:false con 200 en errores)
4411
+ url: "https://ipwho.is/",
4412
+ parse: (d) => d.success === false ? null : geoFrom(d.country_code, d.city)
4413
+ },
4414
+ {
4415
+ // https://ipinfo.io/json (country = código ISO)
4416
+ url: "https://ipinfo.io/json",
4417
+ parse: (d) => geoFrom(d.country, d.city)
4418
+ }
4419
+ ];
4420
+ function geoFrom(country, city) {
4421
+ const c = typeof country === "string" && country ? country : void 0;
4422
+ const t = typeof city === "string" && city ? city : void 0;
4423
+ if (!c && !t) return null;
4424
+ return { country: c, city: t };
4425
+ }
4426
+ async function fetchOne(provider, f, timeoutMs) {
4427
+ const controller = new AbortController();
4428
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
4288
4429
  try {
4289
- const res = await f("https://ipapi.co/json/");
4430
+ const res = await f(provider.url, { signal: controller.signal });
4290
4431
  if (!res.ok) return null;
4291
4432
  const data = await res.json();
4292
- const country = data.country_code || void 0;
4293
- const city = data.city || void 0;
4294
- if (!country && !city) return null;
4295
- return { country, city };
4433
+ return provider.parse(data);
4296
4434
  } catch {
4297
4435
  return null;
4436
+ } finally {
4437
+ clearTimeout(timer);
4298
4438
  }
4299
4439
  }
4440
+ async function fetchGeo(options = {}) {
4441
+ const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
4442
+ if (!f) return null;
4443
+ const providers = options.providers ?? DEFAULT_GEO_PROVIDERS;
4444
+ const timeoutMs = options.timeoutMs ?? GEO_TIMEOUT_MS;
4445
+ for (const provider of providers) {
4446
+ const geo = await fetchOne(provider, f, timeoutMs);
4447
+ if (geo) return geo;
4448
+ }
4449
+ return null;
4450
+ }
4451
+
4452
+ // src/analytics/language.ts
4453
+ function clean(value) {
4454
+ const trimmed = value?.trim();
4455
+ return trimmed ? trimmed : void 0;
4456
+ }
4457
+ function resolveLanguage(sources) {
4458
+ const explicit = clean(sources.explicit);
4459
+ if (explicit) return explicit;
4460
+ const navLang = clean(sources.navigator?.language);
4461
+ if (navLang) return navLang;
4462
+ try {
4463
+ const locale = clean(sources.intl?.DateTimeFormat().resolvedOptions().locale);
4464
+ if (locale) return locale;
4465
+ } catch {
4466
+ }
4467
+ return void 0;
4468
+ }
4300
4469
 
4301
4470
  // src/analytics/engagement-tracker.ts
4302
4471
  var EngagementTracker = class {
@@ -4557,6 +4726,11 @@ var DeepdotsPopups = class {
4557
4726
  this.contact = null;
4558
4727
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
4559
4728
  this.navStarted = false;
4729
+ /**
4730
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
4731
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
4732
+ */
4733
+ this.language = void 0;
4560
4734
  }
4561
4735
  /** Initialize the SDK with configuration */
4562
4736
  init(config) {
@@ -4598,15 +4772,22 @@ var DeepdotsPopups = class {
4598
4772
  baseUrl: this.baseUrl,
4599
4773
  keys: config.analytics,
4600
4774
  log: (...a) => this.log(...a),
4775
+ // sendBeacon: único transporte que sobrevive al cierre de la página.
4776
+ sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
4601
4777
  onSessionId: (id) => {
4602
4778
  this.analyticsFeedbackSessionId = id;
4603
4779
  }
4604
4780
  }) : createDryRunSink((...a) => this.logger.log(...a));
4605
4781
  const device = config.device ?? collectDeviceInfo(config.appVersion);
4782
+ this.language = resolveLanguage({
4783
+ explicit: config.language,
4784
+ navigator: typeof navigator !== "undefined" ? navigator : void 0,
4785
+ intl: typeof Intl !== "undefined" ? Intl : void 0
4786
+ });
4606
4787
  this.analytics = new AnalyticsManager({
4607
4788
  sink: analyticsSink,
4608
4789
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
4609
- language: typeof navigator !== "undefined" ? navigator.language : void 0,
4790
+ language: this.language,
4610
4791
  platform: config.platform ?? "web",
4611
4792
  device,
4612
4793
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -4623,8 +4804,13 @@ var DeepdotsPopups = class {
4623
4804
  if (this.tracking?.isTrackingEnabled()) {
4624
4805
  this.crashReporter.install();
4625
4806
  }
4626
- collectGeoInfo().then((geo) => {
4627
- if (geo) this.analytics?.updateDevice(geo);
4807
+ const cachedGeo = readCachedGeo(storage, Date.now());
4808
+ if (cachedGeo) this.analytics.updateDevice(cachedGeo);
4809
+ fetchGeo().then((geo) => {
4810
+ if (geo) {
4811
+ this.analytics?.updateDevice(geo);
4812
+ writeCachedGeo(storage, geo, Date.now());
4813
+ }
4628
4814
  }).catch(() => {
4629
4815
  });
4630
4816
  this.navObserver = new NavigationObserver();
@@ -4681,6 +4867,15 @@ var DeepdotsPopups = class {
4681
4867
  if (!this.tracking?.isTrackingEnabled()) return;
4682
4868
  this.analytics?.setUserAttributes(attributes);
4683
4869
  }
4870
+ /**
4871
+ * Registra/actualiza una métrica (valor medible) → `feedback.metrics` del body.
4872
+ * Persistente y sobrescribe por key. No-op si tracking off.
4873
+ */
4874
+ setMetric(key, value) {
4875
+ if (!this.tracking?.isTrackingEnabled()) return;
4876
+ this.analytics?.setMetric(key, value);
4877
+ this.log("analytics \xB7 setMetric:", key, value);
4878
+ }
4684
4879
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
4685
4880
  reportError(error, options) {
4686
4881
  if (!this.tracking?.isTrackingEnabled()) return;
@@ -4768,10 +4963,14 @@ var DeepdotsPopups = class {
4768
4963
  events: []
4769
4964
  };
4770
4965
  }
4771
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
4772
- flushAnalytics() {
4966
+ /**
4967
+ * Envía el lote acumulado de analytics y vacía el buffer.
4968
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
4969
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
4970
+ */
4971
+ flushAnalytics(options) {
4773
4972
  if (!this.tracking?.isTrackingEnabled()) return;
4774
- this.analytics?.flush(this.analyticsIdentity());
4973
+ this.analytics?.flush(this.analyticsIdentity(), options);
4775
4974
  }
4776
4975
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
4777
4976
  flushEngagement() {
@@ -4794,7 +4993,7 @@ var DeepdotsPopups = class {
4794
4993
  this.navObserver?.stop();
4795
4994
  if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4796
4995
  this.flushEngagement();
4797
- this.flushAnalytics();
4996
+ this.flushAnalytics({ final: true });
4798
4997
  });
4799
4998
  document.addEventListener("visibilitychange", () => {
4800
4999
  if (document.visibilityState === "hidden") {
@@ -4915,14 +5114,16 @@ var DeepdotsPopups = class {
4915
5114
  matchesSegmentsLang(def) {
4916
5115
  const langs = def.segments?.lang;
4917
5116
  if (!langs || langs.length === 0) return true;
4918
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
4919
- if (!browserLang) {
4920
- this.debug("No navigator.language available for lang comparison", { popupId: def.id, langs });
5117
+ const currentLang = (this.language ?? "").toLowerCase();
5118
+ if (!currentLang) {
5119
+ this.debug("No language available for lang comparison", { popupId: def.id, langs });
4921
5120
  return true;
4922
5121
  }
4923
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
5122
+ const matches = langs.some(
5123
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
5124
+ );
4924
5125
  if (!matches) {
4925
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
5126
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
4926
5127
  }
4927
5128
  return matches;
4928
5129
  }