@bidkernel/analytics 0.4.0 → 0.6.0

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
@@ -628,6 +628,11 @@ var MAX_QUEUE_SIZE = 200;
628
628
  var MAX_SEND_BACKOFF_MS = 5 * 60 * 1e3;
629
629
  var MAX_CONSECUTIVE_SEND_FAILURES = 10;
630
630
  var MAX_PAYLOAD_BYTES = 32 * 1024;
631
+ var SAFE_CONTENT_TYPE = "text/plain";
632
+ var PENDING_BATCH_KEY_PREFIX = "_bidkernel_pending_";
633
+ var MAX_PENDING_BATCHES = 20;
634
+ var PENDING_BATCH_MAX_AGE_MS = 2 * 60 * 60 * 1e3;
635
+ var PENDING_BATCH_CLEANUP_DELAY_MS = 1e4;
631
636
  var EVENT_NAME_TO_TYPE = {
632
637
  auctionStart: TraceEventType.AUCTION_START,
633
638
  auctionEnd: TraceEventType.AUCTION_END,
@@ -643,6 +648,11 @@ var EVENT_NAME_TO_TYPE = {
643
648
  timeInView: TraceEventType.TIME_IN_VIEW,
644
649
  viewable: TraceEventType.VIEWABLE
645
650
  };
651
+ var IMMEDIATE_FLUSH_TYPES = /* @__PURE__ */ new Set([
652
+ TraceEventType.IMPRESSION,
653
+ TraceEventType.BID_WIN,
654
+ TraceEventType.CLICK
655
+ ]);
646
656
  var SESSION_KEY = "_bidkernel_session";
647
657
  var SESSION_TS_KEY = "_bidkernel_session_ts";
648
658
  var THIRTY_MINUTES_MS = 30 * 60 * 1e3;
@@ -658,6 +668,22 @@ function generateUUID() {
658
668
  return v.toString(16);
659
669
  });
660
670
  }
671
+ function bytesToBase64(bytes) {
672
+ let binary = "";
673
+ const chunk = 8192;
674
+ for (let i = 0; i < bytes.length; i += chunk) {
675
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
676
+ }
677
+ return btoa(binary);
678
+ }
679
+ function base64ToBytes(b64) {
680
+ const binary = atob(b64);
681
+ const bytes = new Uint8Array(binary.length);
682
+ for (let i = 0; i < binary.length; i++) {
683
+ bytes[i] = binary.charCodeAt(i);
684
+ }
685
+ return bytes;
686
+ }
661
687
  function extendSession() {
662
688
  const now = Date.now();
663
689
  inMemorySessionTs = now;
@@ -805,7 +831,8 @@ function parseBidDimensions(bid) {
805
831
  height: Number.isFinite(bid?.height) ? bid.height : 0
806
832
  };
807
833
  }
808
- var BidkernelPrebidAnalytics = class {
834
+ var BidkernelPrebidAnalytics = class _BidkernelPrebidAnalytics {
835
+ static activeInstances = /* @__PURE__ */ new Set();
809
836
  config;
810
837
  queue = [];
811
838
  errorCount = 0;
@@ -821,6 +848,18 @@ var BidkernelPrebidAnalytics = class {
821
848
  consecutiveSendFailures = 0;
822
849
  nextSendAllowedAt = 0;
823
850
  replayedEventCount = 0;
851
+ immediateFlushScheduled = false;
852
+ // localStorage keys of exit batches this instance persisted, so a page that
853
+ // survives its own pagehide/hidden (bfcache restore, tab re-focus) can
854
+ // remove them instead of leaving them for a duplicate resend.
855
+ persistedBatchKeys = [];
856
+ persistedCleanupTimer = null;
857
+ // Viewability & refresh tracking
858
+ intersectionObserver = null;
859
+ slotViewabilityRecords = /* @__PURE__ */ new Map();
860
+ elementToSlotId = /* @__PURE__ */ new Map();
861
+ slotRefreshIndices = /* @__PURE__ */ new Map();
862
+ pendingThresholdListeners = /* @__PURE__ */ new Map();
824
863
  constructor(config) {
825
864
  this.config = {
826
865
  endpoint: config.endpoint || "",
@@ -834,19 +873,36 @@ var BidkernelPrebidAnalytics = class {
834
873
  auctionEnabled: config.auctionEnabled ?? true,
835
874
  warningsEnabled: config.warningsEnabled ?? true,
836
875
  errorsEnabled: config.errorsEnabled ?? true,
876
+ viewabilityEnabled: config.viewabilityEnabled ?? true,
837
877
  logLevel: config.logLevel || "INFO",
838
878
  pbjsGlobalName: config.pbjsGlobalName || "pbjs",
839
879
  attachPbjsListeners: config.attachPbjsListeners ?? true
840
880
  };
841
- this.boundFlushBeacon = () => this.flushBeacon();
881
+ this.boundFlushBeacon = () => {
882
+ this.flushAllSlotsTimeInView();
883
+ this.flushBeacon();
884
+ };
842
885
  this.boundVisibilityChange = () => this.handleVisibilityChange();
843
886
  }
844
887
  enable() {
845
888
  if (this.isEnabled) return;
846
889
  this.isEnabled = true;
890
+ _BidkernelPrebidAnalytics.activeInstances.add(this);
847
891
  if (!this.config.endpoint) {
848
892
  this.log("WARN", "Endpoint is empty. Analytics events will not be transmitted.");
849
893
  }
894
+ if (this.config.viewabilityEnabled) {
895
+ if (typeof IntersectionObserver !== "undefined") {
896
+ this.intersectionObserver = new IntersectionObserver(this.handleIntersection.bind(this), {
897
+ threshold: [0.5]
898
+ });
899
+ for (const record of this.slotViewabilityRecords.values()) {
900
+ if (record.element) {
901
+ this.intersectionObserver.observe(record.element);
902
+ }
903
+ }
904
+ }
905
+ }
850
906
  if (this.config.attachPbjsListeners) {
851
907
  const win = typeof window !== "undefined" ? window : {};
852
908
  const pbjs = win[this.config.pbjsGlobalName] || {};
@@ -922,15 +978,41 @@ var BidkernelPrebidAnalytics = class {
922
978
  document.addEventListener("visibilitychange", this.boundVisibilityChange);
923
979
  }
924
980
  this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
981
+ this.resendPersistedBatches();
925
982
  }
926
983
  }
927
984
  disable() {
928
985
  if (!this.isEnabled) return;
986
+ this.flushAllSlotsTimeInView();
929
987
  this.isEnabled = false;
988
+ _BidkernelPrebidAnalytics.activeInstances.delete(this);
989
+ for (const record of this.slotViewabilityRecords.values()) {
990
+ if (record.dwellTimer) {
991
+ clearTimeout(record.dwellTimer);
992
+ record.dwellTimer = null;
993
+ record.dwellStartedAt = null;
994
+ }
995
+ for (const l of record.thresholdListeners) {
996
+ if (l.timer) {
997
+ clearTimeout(l.timer);
998
+ l.timer = null;
999
+ }
1000
+ }
1001
+ }
1002
+ if (this.intersectionObserver) {
1003
+ this.intersectionObserver.disconnect();
1004
+ this.intersectionObserver = null;
1005
+ }
1006
+ this.slotViewabilityRecords.clear();
1007
+ this.elementToSlotId.clear();
930
1008
  if (this.flushTimer) {
931
1009
  clearInterval(this.flushTimer);
932
1010
  this.flushTimer = null;
933
1011
  }
1012
+ if (this.persistedCleanupTimer) {
1013
+ clearTimeout(this.persistedCleanupTimer);
1014
+ this.persistedCleanupTimer = null;
1015
+ }
934
1016
  if (typeof window !== "undefined") {
935
1017
  window.removeEventListener("pagehide", this.boundFlushBeacon);
936
1018
  if (typeof document !== "undefined") {
@@ -950,8 +1032,443 @@ var BidkernelPrebidAnalytics = class {
950
1032
  this.flush();
951
1033
  }
952
1034
  handleVisibilityChange() {
953
- if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1035
+ if (typeof document === "undefined") return;
1036
+ if (document.visibilityState === "hidden") {
1037
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1038
+ for (const record of this.slotViewabilityRecords.values()) {
1039
+ if (record.inView && record.lastEnteredViewAt !== null) {
1040
+ record.accumulatedTimeInViewMs += Math.max(0, now - record.lastEnteredViewAt);
1041
+ record.lastEnteredViewAt = null;
1042
+ this.checkThresholdListeners(record);
1043
+ }
1044
+ if (record.dwellTimer) {
1045
+ clearTimeout(record.dwellTimer);
1046
+ record.dwellTimer = null;
1047
+ record.dwellStartedAt = null;
1048
+ }
1049
+ for (const l of record.thresholdListeners) {
1050
+ if (l.timer) {
1051
+ clearTimeout(l.timer);
1052
+ l.timer = null;
1053
+ }
1054
+ }
1055
+ const durationMs = Math.round(record.accumulatedTimeInViewMs);
1056
+ if (durationMs > 0) {
1057
+ this.enqueue(TraceEventType.TIME_IN_VIEW, "timeInView", {
1058
+ auctionId: record.auctionId,
1059
+ transactionId: record.transactionId,
1060
+ adUnitCode: record.adUnitCode,
1061
+ viewableDurationMs: durationMs,
1062
+ bid: record.bidPayload,
1063
+ metadata: {
1064
+ ...record.metadata,
1065
+ refresh_index: String(record.refreshIndex)
1066
+ }
1067
+ });
1068
+ }
1069
+ }
954
1070
  this.flushBeacon();
1071
+ } else if (document.visibilityState === "visible") {
1072
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1073
+ for (const record of this.slotViewabilityRecords.values()) {
1074
+ if (record.inView) {
1075
+ record.lastEnteredViewAt = now;
1076
+ if (!record.viewableFired && record.dwellTimer === null) {
1077
+ const dwellTarget = record.mediaType === "video" ? 2e3 : 1e3;
1078
+ record.dwellStartedAt = now;
1079
+ record.dwellTimer = setTimeout(() => {
1080
+ this.handleDwellComplete(record);
1081
+ }, dwellTarget);
1082
+ }
1083
+ this.scheduleThresholdTimers(record);
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ handleIntersection(entries) {
1089
+ for (const entry of entries) {
1090
+ let slotId = this.elementToSlotId.get(entry.target);
1091
+ if (!slotId) {
1092
+ for (const instance of _BidkernelPrebidAnalytics.activeInstances) {
1093
+ if (instance !== this && instance.elementToSlotId.has(entry.target)) {
1094
+ instance.handleIntersection([entry]);
1095
+ break;
1096
+ }
1097
+ }
1098
+ continue;
1099
+ }
1100
+ const record = this.slotViewabilityRecords.get(slotId);
1101
+ if (!record) continue;
1102
+ const isViewableRatio = entry.isIntersecting && (entry.intersectionRatio === void 0 || entry.intersectionRatio >= 0.5);
1103
+ const isDocVisible = typeof document === "undefined" || document.visibilityState === "visible";
1104
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1105
+ if (isViewableRatio) {
1106
+ if (!record.inView) {
1107
+ record.inView = true;
1108
+ if (isDocVisible) {
1109
+ record.lastEnteredViewAt = now;
1110
+ if (!record.viewableFired && record.dwellTimer === null) {
1111
+ const dwellTarget = record.mediaType === "video" ? 2e3 : 1e3;
1112
+ record.dwellStartedAt = now;
1113
+ record.dwellTimer = setTimeout(() => {
1114
+ this.handleDwellComplete(record);
1115
+ }, dwellTarget);
1116
+ }
1117
+ this.scheduleThresholdTimers(record);
1118
+ }
1119
+ }
1120
+ } else {
1121
+ if (record.inView) {
1122
+ if (record.lastEnteredViewAt !== null) {
1123
+ record.accumulatedTimeInViewMs += Math.max(0, now - record.lastEnteredViewAt);
1124
+ record.lastEnteredViewAt = null;
1125
+ }
1126
+ record.inView = false;
1127
+ this.checkThresholdListeners(record);
1128
+ }
1129
+ if (record.dwellTimer !== null) {
1130
+ clearTimeout(record.dwellTimer);
1131
+ record.dwellTimer = null;
1132
+ record.dwellStartedAt = null;
1133
+ }
1134
+ for (const l of record.thresholdListeners) {
1135
+ if (l.timer) {
1136
+ clearTimeout(l.timer);
1137
+ l.timer = null;
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+ }
1143
+ handleDwellComplete(record) {
1144
+ record.dwellTimer = null;
1145
+ record.dwellStartedAt = null;
1146
+ if (!record.inView) return;
1147
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
1148
+ if (!record.viewableFired) {
1149
+ record.viewableFired = true;
1150
+ this.enqueue(TraceEventType.VIEWABLE, "viewable", {
1151
+ auctionId: record.auctionId,
1152
+ transactionId: record.transactionId,
1153
+ adUnitCode: record.adUnitCode,
1154
+ bid: record.bidPayload,
1155
+ metadata: {
1156
+ ...record.metadata,
1157
+ refresh_index: String(record.refreshIndex)
1158
+ }
1159
+ });
1160
+ }
1161
+ }
1162
+ scheduleThresholdTimers(record) {
1163
+ if (!record.inView || typeof document !== "undefined" && document.visibilityState === "hidden") {
1164
+ return;
1165
+ }
1166
+ const currentTotal = this.getCurrentTimeInView(record);
1167
+ for (const listener of record.thresholdListeners) {
1168
+ if (listener.fired) continue;
1169
+ if (currentTotal >= listener.thresholdMs) {
1170
+ listener.fired = true;
1171
+ if (listener.timer) {
1172
+ clearTimeout(listener.timer);
1173
+ listener.timer = null;
1174
+ }
1175
+ try {
1176
+ listener.callback(record.slotId, currentTotal);
1177
+ } catch (e) {
1178
+ this.log("ERROR", "Error in threshold listener callback", e);
1179
+ }
1180
+ } else {
1181
+ if (listener.timer) {
1182
+ clearTimeout(listener.timer);
1183
+ }
1184
+ const remaining = listener.thresholdMs - currentTotal;
1185
+ listener.timer = setTimeout(() => {
1186
+ listener.timer = null;
1187
+ if (!listener.fired && record.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1188
+ const nowTotal = this.getCurrentTimeInView(record);
1189
+ if (nowTotal >= listener.thresholdMs) {
1190
+ listener.fired = true;
1191
+ try {
1192
+ listener.callback(record.slotId, nowTotal);
1193
+ } catch (e) {
1194
+ this.log("ERROR", "Error in threshold listener callback", e);
1195
+ }
1196
+ }
1197
+ }
1198
+ }, remaining);
1199
+ }
1200
+ }
1201
+ }
1202
+ checkThresholdListeners(record) {
1203
+ const currentTotal = this.getCurrentTimeInView(record);
1204
+ for (const listener of record.thresholdListeners) {
1205
+ if (!listener.fired && currentTotal >= listener.thresholdMs) {
1206
+ listener.fired = true;
1207
+ if (listener.timer) {
1208
+ clearTimeout(listener.timer);
1209
+ listener.timer = null;
1210
+ }
1211
+ try {
1212
+ listener.callback(record.slotId, currentTotal);
1213
+ } catch (e) {
1214
+ this.log("ERROR", "Error in threshold listener callback", e);
1215
+ }
1216
+ }
1217
+ }
1218
+ }
1219
+ getCurrentTimeInView(record) {
1220
+ let total = record.accumulatedTimeInViewMs;
1221
+ if (record.inView && record.lastEnteredViewAt !== null) {
1222
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1223
+ total += Math.max(0, now - record.lastEnteredViewAt);
1224
+ }
1225
+ return total;
1226
+ }
1227
+ flushSlotTimeInView(slotId) {
1228
+ const record = this.slotViewabilityRecords.get(slotId);
1229
+ if (!record) return;
1230
+ if (record.inView && record.lastEnteredViewAt !== null) {
1231
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1232
+ record.accumulatedTimeInViewMs += Math.max(0, now - record.lastEnteredViewAt);
1233
+ record.lastEnteredViewAt = null;
1234
+ }
1235
+ const durationMs = Math.round(record.accumulatedTimeInViewMs);
1236
+ if (durationMs > 0) {
1237
+ this.enqueue(TraceEventType.TIME_IN_VIEW, "timeInView", {
1238
+ auctionId: record.auctionId,
1239
+ transactionId: record.transactionId,
1240
+ adUnitCode: record.adUnitCode,
1241
+ viewableDurationMs: durationMs,
1242
+ bid: record.bidPayload,
1243
+ metadata: {
1244
+ ...record.metadata,
1245
+ refresh_index: String(record.refreshIndex)
1246
+ }
1247
+ });
1248
+ }
1249
+ record.accumulatedTimeInViewMs = 0;
1250
+ }
1251
+ flushAllSlotsTimeInView() {
1252
+ for (const slotId of Array.from(this.slotViewabilityRecords.keys())) {
1253
+ this.flushSlotTimeInView(slotId);
1254
+ }
1255
+ }
1256
+ observeSlot(element, slotId, options) {
1257
+ if (!this.config.viewabilityEnabled) return;
1258
+ let el = null;
1259
+ if (typeof element === "string") {
1260
+ if (typeof document !== "undefined") {
1261
+ try {
1262
+ el = document.querySelector(element) || document.getElementById(element);
1263
+ } catch {
1264
+ el = document.getElementById(element);
1265
+ }
1266
+ if (!el) {
1267
+ el = document.getElementById(element);
1268
+ }
1269
+ }
1270
+ } else {
1271
+ el = element;
1272
+ }
1273
+ const resolvedSlotId = slotId || options?.adUnitCode || (el ? el.id : "") || generateUUID();
1274
+ const existing = this.slotViewabilityRecords.get(resolvedSlotId);
1275
+ if (existing) {
1276
+ const isNewRefreshCycle = options?.refreshIndex === void 0 || options.refreshIndex > existing.refreshIndex;
1277
+ if (isNewRefreshCycle) {
1278
+ this.flushSlotTimeInView(resolvedSlotId);
1279
+ const nextRefreshIndex = options?.refreshIndex !== void 0 ? options.refreshIndex : (this.slotRefreshIndices.get(resolvedSlotId) ?? existing.refreshIndex) + 1;
1280
+ this.slotRefreshIndices.set(resolvedSlotId, nextRefreshIndex);
1281
+ if (options?.emitRefreshEvent !== false) {
1282
+ this.enqueue(TraceEventType.REFRESH, "refresh", {
1283
+ auctionId: options?.auctionId || existing.auctionId,
1284
+ transactionId: options?.transactionId || existing.transactionId,
1285
+ adUnitCode: resolvedSlotId,
1286
+ bid: options?.bid ?? existing.bidPayload,
1287
+ metadata: {
1288
+ ...options?.metadata,
1289
+ refresh_index: String(nextRefreshIndex)
1290
+ }
1291
+ });
1292
+ }
1293
+ if (existing.dwellTimer) {
1294
+ clearTimeout(existing.dwellTimer);
1295
+ existing.dwellTimer = null;
1296
+ existing.dwellStartedAt = null;
1297
+ }
1298
+ for (const l of existing.thresholdListeners) {
1299
+ l.fired = false;
1300
+ if (l.timer) {
1301
+ clearTimeout(l.timer);
1302
+ l.timer = null;
1303
+ }
1304
+ }
1305
+ existing.viewableFired = false;
1306
+ existing.accumulatedTimeInViewMs = 0;
1307
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1308
+ existing.lastEnteredViewAt = existing.inView ? now : null;
1309
+ existing.refreshIndex = nextRefreshIndex;
1310
+ existing.auctionId = options?.auctionId || existing.auctionId;
1311
+ existing.transactionId = options?.transactionId || existing.transactionId;
1312
+ existing.mediaType = options?.mediaType || existing.mediaType || "banner";
1313
+ existing.bidPayload = options?.bid !== void 0 ? options.bid : existing.bidPayload;
1314
+ existing.metadata = options?.metadata ?? existing.metadata;
1315
+ if (existing.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1316
+ const dwellTarget = existing.mediaType === "video" ? 2e3 : 1e3;
1317
+ existing.dwellStartedAt = now;
1318
+ existing.dwellTimer = setTimeout(() => {
1319
+ this.handleDwellComplete(existing);
1320
+ }, dwellTarget);
1321
+ this.scheduleThresholdTimers(existing);
1322
+ }
1323
+ } else {
1324
+ existing.auctionId = options?.auctionId || existing.auctionId;
1325
+ existing.transactionId = options?.transactionId || existing.transactionId;
1326
+ existing.mediaType = options?.mediaType || existing.mediaType;
1327
+ if (options?.bid !== void 0) existing.bidPayload = options.bid;
1328
+ if (options?.metadata !== void 0) existing.metadata = options.metadata;
1329
+ }
1330
+ if (el) {
1331
+ if (existing.element && existing.element !== el && this.intersectionObserver) {
1332
+ this.intersectionObserver.unobserve(existing.element);
1333
+ this.elementToSlotId.delete(existing.element);
1334
+ }
1335
+ existing.element = el;
1336
+ this.elementToSlotId.set(el, resolvedSlotId);
1337
+ this.intersectionObserver?.observe(el);
1338
+ }
1339
+ } else {
1340
+ const initialRefreshIndex = options?.refreshIndex ?? this.slotRefreshIndices.get(resolvedSlotId) ?? 0;
1341
+ this.slotRefreshIndices.set(resolvedSlotId, initialRefreshIndex);
1342
+ const listeners = this.pendingThresholdListeners.get(resolvedSlotId) || /* @__PURE__ */ new Set();
1343
+ this.pendingThresholdListeners.delete(resolvedSlotId);
1344
+ const record = {
1345
+ slotId: resolvedSlotId,
1346
+ element: el,
1347
+ adUnitCode: options?.adUnitCode || resolvedSlotId,
1348
+ auctionId: options?.auctionId || "",
1349
+ transactionId: options?.transactionId || "",
1350
+ mediaType: options?.mediaType || "banner",
1351
+ bidPayload: options?.bid,
1352
+ metadata: options?.metadata,
1353
+ refreshIndex: initialRefreshIndex,
1354
+ inView: false,
1355
+ lastEnteredViewAt: null,
1356
+ accumulatedTimeInViewMs: 0,
1357
+ viewableFired: false,
1358
+ dwellTimer: null,
1359
+ dwellStartedAt: null,
1360
+ thresholdListeners: listeners
1361
+ };
1362
+ this.slotViewabilityRecords.set(resolvedSlotId, record);
1363
+ if (el) {
1364
+ this.elementToSlotId.set(el, resolvedSlotId);
1365
+ this.intersectionObserver?.observe(el);
1366
+ }
1367
+ }
1368
+ }
1369
+ unobserveSlot(slotId) {
1370
+ const record = this.slotViewabilityRecords.get(slotId);
1371
+ if (!record) return;
1372
+ this.flushSlotTimeInView(slotId);
1373
+ if (record.dwellTimer) {
1374
+ clearTimeout(record.dwellTimer);
1375
+ record.dwellTimer = null;
1376
+ record.dwellStartedAt = null;
1377
+ }
1378
+ for (const l of record.thresholdListeners) {
1379
+ if (l.timer) {
1380
+ clearTimeout(l.timer);
1381
+ l.timer = null;
1382
+ }
1383
+ }
1384
+ record.thresholdListeners = /* @__PURE__ */ new Set();
1385
+ if (record.element && this.intersectionObserver) {
1386
+ this.intersectionObserver.unobserve(record.element);
1387
+ this.elementToSlotId.delete(record.element);
1388
+ record.element = null;
1389
+ }
1390
+ record.inView = false;
1391
+ record.lastEnteredViewAt = null;
1392
+ }
1393
+ destroySlot(slotId) {
1394
+ this.unobserveSlot(slotId);
1395
+ this.slotViewabilityRecords.delete(slotId);
1396
+ this.slotRefreshIndices.delete(slotId);
1397
+ this.pendingThresholdListeners.delete(slotId);
1398
+ }
1399
+ onTimeInViewThreshold(slotId, thresholdMs, callback) {
1400
+ const listener = {
1401
+ thresholdMs,
1402
+ callback,
1403
+ timer: null,
1404
+ fired: false
1405
+ };
1406
+ const record = this.slotViewabilityRecords.get(slotId);
1407
+ if (record) {
1408
+ record.thresholdListeners.add(listener);
1409
+ const currentTotal = this.getCurrentTimeInView(record);
1410
+ if (currentTotal >= thresholdMs) {
1411
+ listener.fired = true;
1412
+ try {
1413
+ callback(slotId, currentTotal);
1414
+ } catch (e) {
1415
+ this.log("ERROR", "Error in threshold callback", e);
1416
+ }
1417
+ } else if (record.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1418
+ const remaining = thresholdMs - currentTotal;
1419
+ listener.timer = setTimeout(() => {
1420
+ listener.timer = null;
1421
+ if (!listener.fired && record.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1422
+ const nowTotal = this.getCurrentTimeInView(record);
1423
+ if (nowTotal >= thresholdMs) {
1424
+ listener.fired = true;
1425
+ try {
1426
+ callback(slotId, nowTotal);
1427
+ } catch (e) {
1428
+ this.log("ERROR", "Error in threshold callback", e);
1429
+ }
1430
+ }
1431
+ }
1432
+ }, remaining);
1433
+ }
1434
+ } else {
1435
+ if (!this.pendingThresholdListeners.has(slotId)) {
1436
+ this.pendingThresholdListeners.set(slotId, /* @__PURE__ */ new Set());
1437
+ }
1438
+ this.pendingThresholdListeners.get(slotId).add(listener);
1439
+ }
1440
+ return () => {
1441
+ if (listener.timer) {
1442
+ clearTimeout(listener.timer);
1443
+ listener.timer = null;
1444
+ }
1445
+ if (record) {
1446
+ record.thresholdListeners.delete(listener);
1447
+ }
1448
+ const pending = this.pendingThresholdListeners.get(slotId);
1449
+ if (pending) {
1450
+ pending.delete(listener);
1451
+ }
1452
+ };
1453
+ }
1454
+ getViewabilityState(slotId) {
1455
+ const record = this.slotViewabilityRecords.get(slotId);
1456
+ if (!record) return void 0;
1457
+ const timeInViewMs = Math.round(this.getCurrentTimeInView(record));
1458
+ return {
1459
+ inView: record.inView,
1460
+ viewable: record.viewableFired,
1461
+ timeInViewMs,
1462
+ refreshIndex: record.refreshIndex
1463
+ };
1464
+ }
1465
+ getRefreshIndex(slotId) {
1466
+ return this.slotRefreshIndices.get(slotId) ?? 0;
1467
+ }
1468
+ triggerSlotRefresh(slotId, options) {
1469
+ const record = this.slotViewabilityRecords.get(slotId);
1470
+ if (record && record.element) {
1471
+ this.observeSlot(record.element, slotId, options);
955
1472
  }
956
1473
  }
957
1474
  trackRawEvent(level, eventName, data) {
@@ -966,6 +1483,8 @@ var BidkernelPrebidAnalytics = class {
966
1483
  }
967
1484
  navigate(pageviewId, pageUrl) {
968
1485
  this.disable();
1486
+ this.slotRefreshIndices.clear();
1487
+ this.pendingThresholdListeners.clear();
969
1488
  this.config.pageviewId = pageviewId || generateUUID();
970
1489
  if (pageUrl) {
971
1490
  try {
@@ -1140,23 +1659,81 @@ var BidkernelPrebidAnalytics = class {
1140
1659
  handleAdRenderSucceeded(data) {
1141
1660
  if (this.isDuplicate("adRenderSucceeded", data)) return;
1142
1661
  this.log("DEBUG", "adRenderSucceeded", data);
1143
- const bid = data.bid || {};
1662
+ const bid = data.bid || data || {};
1663
+ const adUnitCode = data.adUnitCode || bid.adUnitCode || "";
1664
+ const auctionId = bid.auctionId || data.auctionId || "";
1665
+ const transactionId = bid.transactionId || data.transactionId || "";
1666
+ const mediaType = bid.mediaType || "banner";
1667
+ const bidTrace = {
1668
+ bidder: bid.bidderCode || bid.bidder || "",
1669
+ cpm: Number.isFinite(bid.originalCpm) ? bid.originalCpm : Number.isFinite(bid.cpm) ? bid.cpm : 0,
1670
+ currency: bid.originalCurrency ?? bid.currency ?? "USD",
1671
+ ...parseBidDimensions(bid),
1672
+ dealId: bid.dealId || "",
1673
+ mediaType,
1674
+ latencyMs: Number.isFinite(bid.timeToRespond) ? bid.timeToRespond : 0,
1675
+ advertiserDomain: bid.meta?.advertiserDomains?.[0] || "",
1676
+ creativeId: bid.creativeId || ""
1677
+ };
1678
+ const isRefresh = adUnitCode && (this.slotViewabilityRecords.has(adUnitCode) || this.slotRefreshIndices.has(adUnitCode) && this.slotRefreshIndices.get(adUnitCode) > 0);
1679
+ if (isRefresh) {
1680
+ this.flushSlotTimeInView(adUnitCode);
1681
+ const nextRefreshIndex = (this.slotRefreshIndices.get(adUnitCode) ?? 0) + 1;
1682
+ this.slotRefreshIndices.set(adUnitCode, nextRefreshIndex);
1683
+ this.enqueue(TraceEventType.REFRESH, "refresh", {
1684
+ auctionId,
1685
+ transactionId,
1686
+ adUnitCode,
1687
+ bid: bidTrace,
1688
+ metadata: { refresh_index: String(nextRefreshIndex) }
1689
+ });
1690
+ } else if (adUnitCode && !this.slotRefreshIndices.has(adUnitCode)) {
1691
+ this.slotRefreshIndices.set(adUnitCode, 0);
1692
+ }
1693
+ const currentRefreshIndex = adUnitCode ? this.slotRefreshIndices.get(adUnitCode) ?? 0 : 0;
1144
1694
  this.enqueue(TraceEventType.IMPRESSION, "impression", {
1145
- auctionId: bid.auctionId || data.auctionId || "",
1146
- transactionId: bid.transactionId || data.transactionId || "",
1147
- adUnitCode: data.adUnitCode || bid.adUnitCode || "",
1148
- bid: {
1149
- bidder: bid.bidderCode || bid.bidder || "",
1150
- cpm: Number.isFinite(bid.originalCpm) ? bid.originalCpm : Number.isFinite(bid.cpm) ? bid.cpm : 0,
1151
- currency: bid.originalCurrency ?? bid.currency ?? "USD",
1152
- ...parseBidDimensions(bid),
1153
- dealId: bid.dealId || "",
1154
- mediaType: bid.mediaType || "banner",
1155
- latencyMs: Number.isFinite(bid.timeToRespond) ? bid.timeToRespond : 0,
1156
- advertiserDomain: bid.meta?.advertiserDomains?.[0] || "",
1157
- creativeId: bid.creativeId || ""
1158
- }
1695
+ auctionId,
1696
+ transactionId,
1697
+ adUnitCode,
1698
+ bid: bidTrace,
1699
+ metadata: { refresh_index: String(currentRefreshIndex) }
1159
1700
  });
1701
+ if (this.config.viewabilityEnabled && typeof document !== "undefined") {
1702
+ let el = null;
1703
+ const targetId = adUnitCode || data.adId || bid.adId;
1704
+ if (targetId) {
1705
+ el = document.getElementById(targetId);
1706
+ }
1707
+ if (!el && typeof window !== "undefined" && window.googletag?.pubads) {
1708
+ try {
1709
+ const slots = window.googletag.pubads().getSlots();
1710
+ for (const slot of slots) {
1711
+ if (slot.getAdUnitPath?.() === adUnitCode || slot.getSlotElementId?.() === adUnitCode) {
1712
+ el = document.getElementById(slot.getSlotElementId());
1713
+ if (el) break;
1714
+ }
1715
+ }
1716
+ } catch {
1717
+ }
1718
+ }
1719
+ if (!el && targetId) {
1720
+ try {
1721
+ el = document.querySelector(`[data-ad-slot="${targetId}"]`) || document.querySelector(`#${targetId}`);
1722
+ } catch {
1723
+ }
1724
+ }
1725
+ if (el) {
1726
+ this.observeSlot(el, adUnitCode || targetId, {
1727
+ adUnitCode,
1728
+ auctionId,
1729
+ transactionId,
1730
+ mediaType,
1731
+ bid: bidTrace,
1732
+ refreshIndex: currentRefreshIndex,
1733
+ emitRefreshEvent: false
1734
+ });
1735
+ }
1736
+ }
1160
1737
  }
1161
1738
  isDuplicate(eventName, data) {
1162
1739
  return this.deduper.isDuplicate(eventName, data);
@@ -1202,6 +1779,9 @@ var BidkernelPrebidAnalytics = class {
1202
1779
  if (data.gpid) {
1203
1780
  metadata.gpid = String(data.gpid);
1204
1781
  }
1782
+ if (data.refreshIndex !== void 0 && metadata.refresh_index === void 0) {
1783
+ metadata.refresh_index = String(data.refreshIndex);
1784
+ }
1205
1785
  if (Object.keys(metadata).length > 0) {
1206
1786
  protoEvent.metadata = metadata;
1207
1787
  }
@@ -1211,6 +1791,12 @@ var BidkernelPrebidAnalytics = class {
1211
1791
  }
1212
1792
  if (this.queue.length >= BATCH_SIZE) {
1213
1793
  this.flush();
1794
+ } else if (IMMEDIATE_FLUSH_TYPES.has(type) && !this.immediateFlushScheduled) {
1795
+ this.immediateFlushScheduled = true;
1796
+ setTimeout(() => {
1797
+ this.immediateFlushScheduled = false;
1798
+ this.flush();
1799
+ }, 0);
1214
1800
  }
1215
1801
  }
1216
1802
  shouldSample(type, level) {
@@ -1267,10 +1853,11 @@ var BidkernelPrebidAnalytics = class {
1267
1853
  events
1268
1854
  };
1269
1855
  }
1270
- sendFetch(payload, useKeepalive = false) {
1856
+ sendFetch(payload, useKeepalive = false, onDelivered) {
1271
1857
  const fetchOpts = {
1272
1858
  method: "POST",
1273
- headers: { "Content-Type": "application/x-protobuf" },
1859
+ // Safelisted content type: no CORS preflight (see SAFE_CONTENT_TYPE).
1860
+ headers: { "Content-Type": SAFE_CONTENT_TYPE },
1274
1861
  body: payload.encoded
1275
1862
  };
1276
1863
  if (useKeepalive) {
@@ -1279,7 +1866,7 @@ var BidkernelPrebidAnalytics = class {
1279
1866
  fetch(payload.url, fetchOpts).then((res) => {
1280
1867
  if (!res.ok) {
1281
1868
  this.log("WARN", `Failed to send batch: HTTP ${res.status}`);
1282
- if (res.status >= 400 && res.status < 500) {
1869
+ if (res.status >= 400 && res.status < 500 && res.status !== 429 && res.status !== 408) {
1283
1870
  this.log("WARN", `Dropping batch due to non-retryable client error HTTP ${res.status}`);
1284
1871
  return;
1285
1872
  }
@@ -1287,11 +1874,19 @@ var BidkernelPrebidAnalytics = class {
1287
1874
  } else {
1288
1875
  this.consecutiveSendFailures = 0;
1289
1876
  this.nextSendAllowedAt = 0;
1877
+ if (onDelivered) onDelivered();
1290
1878
  }
1291
1879
  }).catch((err) => {
1292
1880
  this.log("ERROR", "Failed to send batch", err);
1293
1881
  this.handleSendFailure(payload.events);
1294
1882
  });
1883
+ const proc = typeof globalThis !== "undefined" ? globalThis.process : void 0;
1884
+ if (proc && typeof proc._tickCallback === "function") {
1885
+ try {
1886
+ proc._tickCallback();
1887
+ } catch {
1888
+ }
1889
+ }
1295
1890
  }
1296
1891
  handleSendFailure(events) {
1297
1892
  this.consecutiveSendFailures++;
@@ -1324,19 +1919,99 @@ var BidkernelPrebidAnalytics = class {
1324
1919
  let sent = false;
1325
1920
  if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
1326
1921
  try {
1327
- const blob = new Blob([payload.encoded], {
1328
- type: "application/x-protobuf"
1329
- });
1922
+ const blob = new Blob([payload.encoded], { type: SAFE_CONTENT_TYPE });
1330
1923
  sent = navigator.sendBeacon(payload.url, blob);
1331
1924
  } catch {
1332
1925
  sent = false;
1333
1926
  }
1334
1927
  }
1335
1928
  if (!sent) {
1336
- this.sendFetch(payload, true);
1929
+ const persistKey = this.persistBatch(payload.encoded);
1930
+ this.sendFetch(payload, true, () => this.removePersistedBatch(persistKey));
1337
1931
  }
1338
1932
  payload = this.drainBatch();
1339
1933
  }
1934
+ this.schedulePersistedCleanup();
1935
+ }
1936
+ // --- Exit-batch persistence -----------------------------------------------
1937
+ persistBatch(encoded) {
1938
+ try {
1939
+ if (typeof localStorage === "undefined") return null;
1940
+ let pendingCount = 0;
1941
+ for (let i = 0; i < localStorage.length; i++) {
1942
+ const k = localStorage.key(i);
1943
+ if (k && k.startsWith(PENDING_BATCH_KEY_PREFIX)) pendingCount++;
1944
+ }
1945
+ if (pendingCount >= MAX_PENDING_BATCHES) return null;
1946
+ const key = `${PENDING_BATCH_KEY_PREFIX}${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
1947
+ localStorage.setItem(key, bytesToBase64(encoded));
1948
+ this.persistedBatchKeys.push(key);
1949
+ return key;
1950
+ } catch {
1951
+ return null;
1952
+ }
1953
+ }
1954
+ removePersistedBatch(key) {
1955
+ if (!key) return;
1956
+ try {
1957
+ if (typeof localStorage !== "undefined") localStorage.removeItem(key);
1958
+ } catch {
1959
+ }
1960
+ const idx = this.persistedBatchKeys.indexOf(key);
1961
+ if (idx !== -1) this.persistedBatchKeys.splice(idx, 1);
1962
+ }
1963
+ // A page that is still running PENDING_BATCH_CLEANUP_DELAY_MS after an exit
1964
+ // flush was never torn down, so its beacons have long since gone out; drop
1965
+ // the persisted copies rather than letting a later pageview resend them.
1966
+ schedulePersistedCleanup() {
1967
+ if (this.persistedBatchKeys.length === 0) return;
1968
+ if (this.persistedCleanupTimer) clearTimeout(this.persistedCleanupTimer);
1969
+ this.persistedCleanupTimer = setTimeout(() => {
1970
+ this.persistedCleanupTimer = null;
1971
+ for (const key of [...this.persistedBatchKeys]) {
1972
+ this.removePersistedBatch(key);
1973
+ }
1974
+ }, PENDING_BATCH_CLEANUP_DELAY_MS);
1975
+ }
1976
+ // Resend batches a previous pageview persisted at exit but could not
1977
+ // confirm. Keys are claimed (removed) before sending so concurrent tabs
1978
+ // don't double-send; the batch bytes carry their original pageview, session,
1979
+ // and event timestamps, so late rows attribute correctly.
1980
+ resendPersistedBatches() {
1981
+ try {
1982
+ if (typeof localStorage === "undefined" || !this.config.endpoint) return;
1983
+ const keys = [];
1984
+ for (let i = 0; i < localStorage.length; i++) {
1985
+ const k = localStorage.key(i);
1986
+ if (k && k.startsWith(PENDING_BATCH_KEY_PREFIX)) keys.push(k);
1987
+ }
1988
+ for (const key of keys) {
1989
+ let value = null;
1990
+ try {
1991
+ value = localStorage.getItem(key);
1992
+ localStorage.removeItem(key);
1993
+ } catch {
1994
+ continue;
1995
+ }
1996
+ if (!value) continue;
1997
+ const ts = parseInt(key.slice(PENDING_BATCH_KEY_PREFIX.length), 10);
1998
+ if (!Number.isFinite(ts) || Date.now() - ts > PENDING_BATCH_MAX_AGE_MS) continue;
1999
+ let encoded;
2000
+ try {
2001
+ encoded = base64ToBytes(value);
2002
+ } catch {
2003
+ continue;
2004
+ }
2005
+ if (encoded.byteLength === 0) continue;
2006
+ this.log("DEBUG", `Resending persisted exit batch (${encoded.byteLength} bytes)`);
2007
+ this.sendFetch({
2008
+ url: `${this.config.endpoint}/${this.config.propertyId}`,
2009
+ encoded,
2010
+ events: []
2011
+ });
2012
+ }
2013
+ } catch {
2014
+ }
1340
2015
  }
1341
2016
  log(level, msg, ...args) {
1342
2017
  const levels = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };