@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.js CHANGED
@@ -658,6 +658,11 @@ var MAX_QUEUE_SIZE = 200;
658
658
  var MAX_SEND_BACKOFF_MS = 5 * 60 * 1e3;
659
659
  var MAX_CONSECUTIVE_SEND_FAILURES = 10;
660
660
  var MAX_PAYLOAD_BYTES = 32 * 1024;
661
+ var SAFE_CONTENT_TYPE = "text/plain";
662
+ var PENDING_BATCH_KEY_PREFIX = "_bidkernel_pending_";
663
+ var MAX_PENDING_BATCHES = 20;
664
+ var PENDING_BATCH_MAX_AGE_MS = 2 * 60 * 60 * 1e3;
665
+ var PENDING_BATCH_CLEANUP_DELAY_MS = 1e4;
661
666
  var EVENT_NAME_TO_TYPE = {
662
667
  auctionStart: TraceEventType.AUCTION_START,
663
668
  auctionEnd: TraceEventType.AUCTION_END,
@@ -673,6 +678,11 @@ var EVENT_NAME_TO_TYPE = {
673
678
  timeInView: TraceEventType.TIME_IN_VIEW,
674
679
  viewable: TraceEventType.VIEWABLE
675
680
  };
681
+ var IMMEDIATE_FLUSH_TYPES = /* @__PURE__ */ new Set([
682
+ TraceEventType.IMPRESSION,
683
+ TraceEventType.BID_WIN,
684
+ TraceEventType.CLICK
685
+ ]);
676
686
  var SESSION_KEY = "_bidkernel_session";
677
687
  var SESSION_TS_KEY = "_bidkernel_session_ts";
678
688
  var THIRTY_MINUTES_MS = 30 * 60 * 1e3;
@@ -688,6 +698,22 @@ function generateUUID() {
688
698
  return v.toString(16);
689
699
  });
690
700
  }
701
+ function bytesToBase64(bytes) {
702
+ let binary = "";
703
+ const chunk = 8192;
704
+ for (let i = 0; i < bytes.length; i += chunk) {
705
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
706
+ }
707
+ return btoa(binary);
708
+ }
709
+ function base64ToBytes(b64) {
710
+ const binary = atob(b64);
711
+ const bytes = new Uint8Array(binary.length);
712
+ for (let i = 0; i < binary.length; i++) {
713
+ bytes[i] = binary.charCodeAt(i);
714
+ }
715
+ return bytes;
716
+ }
691
717
  function extendSession() {
692
718
  const now = Date.now();
693
719
  inMemorySessionTs = now;
@@ -835,7 +861,8 @@ function parseBidDimensions(bid) {
835
861
  height: Number.isFinite(bid?.height) ? bid.height : 0
836
862
  };
837
863
  }
838
- var BidkernelPrebidAnalytics = class {
864
+ var BidkernelPrebidAnalytics = class _BidkernelPrebidAnalytics {
865
+ static activeInstances = /* @__PURE__ */ new Set();
839
866
  config;
840
867
  queue = [];
841
868
  errorCount = 0;
@@ -851,6 +878,18 @@ var BidkernelPrebidAnalytics = class {
851
878
  consecutiveSendFailures = 0;
852
879
  nextSendAllowedAt = 0;
853
880
  replayedEventCount = 0;
881
+ immediateFlushScheduled = false;
882
+ // localStorage keys of exit batches this instance persisted, so a page that
883
+ // survives its own pagehide/hidden (bfcache restore, tab re-focus) can
884
+ // remove them instead of leaving them for a duplicate resend.
885
+ persistedBatchKeys = [];
886
+ persistedCleanupTimer = null;
887
+ // Viewability & refresh tracking
888
+ intersectionObserver = null;
889
+ slotViewabilityRecords = /* @__PURE__ */ new Map();
890
+ elementToSlotId = /* @__PURE__ */ new Map();
891
+ slotRefreshIndices = /* @__PURE__ */ new Map();
892
+ pendingThresholdListeners = /* @__PURE__ */ new Map();
854
893
  constructor(config) {
855
894
  this.config = {
856
895
  endpoint: config.endpoint || "",
@@ -864,19 +903,36 @@ var BidkernelPrebidAnalytics = class {
864
903
  auctionEnabled: config.auctionEnabled ?? true,
865
904
  warningsEnabled: config.warningsEnabled ?? true,
866
905
  errorsEnabled: config.errorsEnabled ?? true,
906
+ viewabilityEnabled: config.viewabilityEnabled ?? true,
867
907
  logLevel: config.logLevel || "INFO",
868
908
  pbjsGlobalName: config.pbjsGlobalName || "pbjs",
869
909
  attachPbjsListeners: config.attachPbjsListeners ?? true
870
910
  };
871
- this.boundFlushBeacon = () => this.flushBeacon();
911
+ this.boundFlushBeacon = () => {
912
+ this.flushAllSlotsTimeInView();
913
+ this.flushBeacon();
914
+ };
872
915
  this.boundVisibilityChange = () => this.handleVisibilityChange();
873
916
  }
874
917
  enable() {
875
918
  if (this.isEnabled) return;
876
919
  this.isEnabled = true;
920
+ _BidkernelPrebidAnalytics.activeInstances.add(this);
877
921
  if (!this.config.endpoint) {
878
922
  this.log("WARN", "Endpoint is empty. Analytics events will not be transmitted.");
879
923
  }
924
+ if (this.config.viewabilityEnabled) {
925
+ if (typeof IntersectionObserver !== "undefined") {
926
+ this.intersectionObserver = new IntersectionObserver(this.handleIntersection.bind(this), {
927
+ threshold: [0.5]
928
+ });
929
+ for (const record of this.slotViewabilityRecords.values()) {
930
+ if (record.element) {
931
+ this.intersectionObserver.observe(record.element);
932
+ }
933
+ }
934
+ }
935
+ }
880
936
  if (this.config.attachPbjsListeners) {
881
937
  const win = typeof window !== "undefined" ? window : {};
882
938
  const pbjs = win[this.config.pbjsGlobalName] || {};
@@ -952,15 +1008,41 @@ var BidkernelPrebidAnalytics = class {
952
1008
  document.addEventListener("visibilitychange", this.boundVisibilityChange);
953
1009
  }
954
1010
  this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
1011
+ this.resendPersistedBatches();
955
1012
  }
956
1013
  }
957
1014
  disable() {
958
1015
  if (!this.isEnabled) return;
1016
+ this.flushAllSlotsTimeInView();
959
1017
  this.isEnabled = false;
1018
+ _BidkernelPrebidAnalytics.activeInstances.delete(this);
1019
+ for (const record of this.slotViewabilityRecords.values()) {
1020
+ if (record.dwellTimer) {
1021
+ clearTimeout(record.dwellTimer);
1022
+ record.dwellTimer = null;
1023
+ record.dwellStartedAt = null;
1024
+ }
1025
+ for (const l of record.thresholdListeners) {
1026
+ if (l.timer) {
1027
+ clearTimeout(l.timer);
1028
+ l.timer = null;
1029
+ }
1030
+ }
1031
+ }
1032
+ if (this.intersectionObserver) {
1033
+ this.intersectionObserver.disconnect();
1034
+ this.intersectionObserver = null;
1035
+ }
1036
+ this.slotViewabilityRecords.clear();
1037
+ this.elementToSlotId.clear();
960
1038
  if (this.flushTimer) {
961
1039
  clearInterval(this.flushTimer);
962
1040
  this.flushTimer = null;
963
1041
  }
1042
+ if (this.persistedCleanupTimer) {
1043
+ clearTimeout(this.persistedCleanupTimer);
1044
+ this.persistedCleanupTimer = null;
1045
+ }
964
1046
  if (typeof window !== "undefined") {
965
1047
  window.removeEventListener("pagehide", this.boundFlushBeacon);
966
1048
  if (typeof document !== "undefined") {
@@ -980,8 +1062,443 @@ var BidkernelPrebidAnalytics = class {
980
1062
  this.flush();
981
1063
  }
982
1064
  handleVisibilityChange() {
983
- if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1065
+ if (typeof document === "undefined") return;
1066
+ if (document.visibilityState === "hidden") {
1067
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1068
+ for (const record of this.slotViewabilityRecords.values()) {
1069
+ if (record.inView && record.lastEnteredViewAt !== null) {
1070
+ record.accumulatedTimeInViewMs += Math.max(0, now - record.lastEnteredViewAt);
1071
+ record.lastEnteredViewAt = null;
1072
+ this.checkThresholdListeners(record);
1073
+ }
1074
+ if (record.dwellTimer) {
1075
+ clearTimeout(record.dwellTimer);
1076
+ record.dwellTimer = null;
1077
+ record.dwellStartedAt = null;
1078
+ }
1079
+ for (const l of record.thresholdListeners) {
1080
+ if (l.timer) {
1081
+ clearTimeout(l.timer);
1082
+ l.timer = null;
1083
+ }
1084
+ }
1085
+ const durationMs = Math.round(record.accumulatedTimeInViewMs);
1086
+ if (durationMs > 0) {
1087
+ this.enqueue(TraceEventType.TIME_IN_VIEW, "timeInView", {
1088
+ auctionId: record.auctionId,
1089
+ transactionId: record.transactionId,
1090
+ adUnitCode: record.adUnitCode,
1091
+ viewableDurationMs: durationMs,
1092
+ bid: record.bidPayload,
1093
+ metadata: {
1094
+ ...record.metadata,
1095
+ refresh_index: String(record.refreshIndex)
1096
+ }
1097
+ });
1098
+ }
1099
+ }
984
1100
  this.flushBeacon();
1101
+ } else if (document.visibilityState === "visible") {
1102
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1103
+ for (const record of this.slotViewabilityRecords.values()) {
1104
+ if (record.inView) {
1105
+ record.lastEnteredViewAt = now;
1106
+ if (!record.viewableFired && record.dwellTimer === null) {
1107
+ const dwellTarget = record.mediaType === "video" ? 2e3 : 1e3;
1108
+ record.dwellStartedAt = now;
1109
+ record.dwellTimer = setTimeout(() => {
1110
+ this.handleDwellComplete(record);
1111
+ }, dwellTarget);
1112
+ }
1113
+ this.scheduleThresholdTimers(record);
1114
+ }
1115
+ }
1116
+ }
1117
+ }
1118
+ handleIntersection(entries) {
1119
+ for (const entry of entries) {
1120
+ let slotId = this.elementToSlotId.get(entry.target);
1121
+ if (!slotId) {
1122
+ for (const instance of _BidkernelPrebidAnalytics.activeInstances) {
1123
+ if (instance !== this && instance.elementToSlotId.has(entry.target)) {
1124
+ instance.handleIntersection([entry]);
1125
+ break;
1126
+ }
1127
+ }
1128
+ continue;
1129
+ }
1130
+ const record = this.slotViewabilityRecords.get(slotId);
1131
+ if (!record) continue;
1132
+ const isViewableRatio = entry.isIntersecting && (entry.intersectionRatio === void 0 || entry.intersectionRatio >= 0.5);
1133
+ const isDocVisible = typeof document === "undefined" || document.visibilityState === "visible";
1134
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1135
+ if (isViewableRatio) {
1136
+ if (!record.inView) {
1137
+ record.inView = true;
1138
+ if (isDocVisible) {
1139
+ record.lastEnteredViewAt = now;
1140
+ if (!record.viewableFired && record.dwellTimer === null) {
1141
+ const dwellTarget = record.mediaType === "video" ? 2e3 : 1e3;
1142
+ record.dwellStartedAt = now;
1143
+ record.dwellTimer = setTimeout(() => {
1144
+ this.handleDwellComplete(record);
1145
+ }, dwellTarget);
1146
+ }
1147
+ this.scheduleThresholdTimers(record);
1148
+ }
1149
+ }
1150
+ } else {
1151
+ if (record.inView) {
1152
+ if (record.lastEnteredViewAt !== null) {
1153
+ record.accumulatedTimeInViewMs += Math.max(0, now - record.lastEnteredViewAt);
1154
+ record.lastEnteredViewAt = null;
1155
+ }
1156
+ record.inView = false;
1157
+ this.checkThresholdListeners(record);
1158
+ }
1159
+ if (record.dwellTimer !== null) {
1160
+ clearTimeout(record.dwellTimer);
1161
+ record.dwellTimer = null;
1162
+ record.dwellStartedAt = null;
1163
+ }
1164
+ for (const l of record.thresholdListeners) {
1165
+ if (l.timer) {
1166
+ clearTimeout(l.timer);
1167
+ l.timer = null;
1168
+ }
1169
+ }
1170
+ }
1171
+ }
1172
+ }
1173
+ handleDwellComplete(record) {
1174
+ record.dwellTimer = null;
1175
+ record.dwellStartedAt = null;
1176
+ if (!record.inView) return;
1177
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
1178
+ if (!record.viewableFired) {
1179
+ record.viewableFired = true;
1180
+ this.enqueue(TraceEventType.VIEWABLE, "viewable", {
1181
+ auctionId: record.auctionId,
1182
+ transactionId: record.transactionId,
1183
+ adUnitCode: record.adUnitCode,
1184
+ bid: record.bidPayload,
1185
+ metadata: {
1186
+ ...record.metadata,
1187
+ refresh_index: String(record.refreshIndex)
1188
+ }
1189
+ });
1190
+ }
1191
+ }
1192
+ scheduleThresholdTimers(record) {
1193
+ if (!record.inView || typeof document !== "undefined" && document.visibilityState === "hidden") {
1194
+ return;
1195
+ }
1196
+ const currentTotal = this.getCurrentTimeInView(record);
1197
+ for (const listener of record.thresholdListeners) {
1198
+ if (listener.fired) continue;
1199
+ if (currentTotal >= listener.thresholdMs) {
1200
+ listener.fired = true;
1201
+ if (listener.timer) {
1202
+ clearTimeout(listener.timer);
1203
+ listener.timer = null;
1204
+ }
1205
+ try {
1206
+ listener.callback(record.slotId, currentTotal);
1207
+ } catch (e) {
1208
+ this.log("ERROR", "Error in threshold listener callback", e);
1209
+ }
1210
+ } else {
1211
+ if (listener.timer) {
1212
+ clearTimeout(listener.timer);
1213
+ }
1214
+ const remaining = listener.thresholdMs - currentTotal;
1215
+ listener.timer = setTimeout(() => {
1216
+ listener.timer = null;
1217
+ if (!listener.fired && record.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1218
+ const nowTotal = this.getCurrentTimeInView(record);
1219
+ if (nowTotal >= listener.thresholdMs) {
1220
+ listener.fired = true;
1221
+ try {
1222
+ listener.callback(record.slotId, nowTotal);
1223
+ } catch (e) {
1224
+ this.log("ERROR", "Error in threshold listener callback", e);
1225
+ }
1226
+ }
1227
+ }
1228
+ }, remaining);
1229
+ }
1230
+ }
1231
+ }
1232
+ checkThresholdListeners(record) {
1233
+ const currentTotal = this.getCurrentTimeInView(record);
1234
+ for (const listener of record.thresholdListeners) {
1235
+ if (!listener.fired && currentTotal >= listener.thresholdMs) {
1236
+ listener.fired = true;
1237
+ if (listener.timer) {
1238
+ clearTimeout(listener.timer);
1239
+ listener.timer = null;
1240
+ }
1241
+ try {
1242
+ listener.callback(record.slotId, currentTotal);
1243
+ } catch (e) {
1244
+ this.log("ERROR", "Error in threshold listener callback", e);
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ getCurrentTimeInView(record) {
1250
+ let total = record.accumulatedTimeInViewMs;
1251
+ if (record.inView && record.lastEnteredViewAt !== null) {
1252
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1253
+ total += Math.max(0, now - record.lastEnteredViewAt);
1254
+ }
1255
+ return total;
1256
+ }
1257
+ flushSlotTimeInView(slotId) {
1258
+ const record = this.slotViewabilityRecords.get(slotId);
1259
+ if (!record) return;
1260
+ if (record.inView && record.lastEnteredViewAt !== null) {
1261
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1262
+ record.accumulatedTimeInViewMs += Math.max(0, now - record.lastEnteredViewAt);
1263
+ record.lastEnteredViewAt = null;
1264
+ }
1265
+ const durationMs = Math.round(record.accumulatedTimeInViewMs);
1266
+ if (durationMs > 0) {
1267
+ this.enqueue(TraceEventType.TIME_IN_VIEW, "timeInView", {
1268
+ auctionId: record.auctionId,
1269
+ transactionId: record.transactionId,
1270
+ adUnitCode: record.adUnitCode,
1271
+ viewableDurationMs: durationMs,
1272
+ bid: record.bidPayload,
1273
+ metadata: {
1274
+ ...record.metadata,
1275
+ refresh_index: String(record.refreshIndex)
1276
+ }
1277
+ });
1278
+ }
1279
+ record.accumulatedTimeInViewMs = 0;
1280
+ }
1281
+ flushAllSlotsTimeInView() {
1282
+ for (const slotId of Array.from(this.slotViewabilityRecords.keys())) {
1283
+ this.flushSlotTimeInView(slotId);
1284
+ }
1285
+ }
1286
+ observeSlot(element, slotId, options) {
1287
+ if (!this.config.viewabilityEnabled) return;
1288
+ let el = null;
1289
+ if (typeof element === "string") {
1290
+ if (typeof document !== "undefined") {
1291
+ try {
1292
+ el = document.querySelector(element) || document.getElementById(element);
1293
+ } catch {
1294
+ el = document.getElementById(element);
1295
+ }
1296
+ if (!el) {
1297
+ el = document.getElementById(element);
1298
+ }
1299
+ }
1300
+ } else {
1301
+ el = element;
1302
+ }
1303
+ const resolvedSlotId = slotId || options?.adUnitCode || (el ? el.id : "") || generateUUID();
1304
+ const existing = this.slotViewabilityRecords.get(resolvedSlotId);
1305
+ if (existing) {
1306
+ const isNewRefreshCycle = options?.refreshIndex === void 0 || options.refreshIndex > existing.refreshIndex;
1307
+ if (isNewRefreshCycle) {
1308
+ this.flushSlotTimeInView(resolvedSlotId);
1309
+ const nextRefreshIndex = options?.refreshIndex !== void 0 ? options.refreshIndex : (this.slotRefreshIndices.get(resolvedSlotId) ?? existing.refreshIndex) + 1;
1310
+ this.slotRefreshIndices.set(resolvedSlotId, nextRefreshIndex);
1311
+ if (options?.emitRefreshEvent !== false) {
1312
+ this.enqueue(TraceEventType.REFRESH, "refresh", {
1313
+ auctionId: options?.auctionId || existing.auctionId,
1314
+ transactionId: options?.transactionId || existing.transactionId,
1315
+ adUnitCode: resolvedSlotId,
1316
+ bid: options?.bid ?? existing.bidPayload,
1317
+ metadata: {
1318
+ ...options?.metadata,
1319
+ refresh_index: String(nextRefreshIndex)
1320
+ }
1321
+ });
1322
+ }
1323
+ if (existing.dwellTimer) {
1324
+ clearTimeout(existing.dwellTimer);
1325
+ existing.dwellTimer = null;
1326
+ existing.dwellStartedAt = null;
1327
+ }
1328
+ for (const l of existing.thresholdListeners) {
1329
+ l.fired = false;
1330
+ if (l.timer) {
1331
+ clearTimeout(l.timer);
1332
+ l.timer = null;
1333
+ }
1334
+ }
1335
+ existing.viewableFired = false;
1336
+ existing.accumulatedTimeInViewMs = 0;
1337
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1338
+ existing.lastEnteredViewAt = existing.inView ? now : null;
1339
+ existing.refreshIndex = nextRefreshIndex;
1340
+ existing.auctionId = options?.auctionId || existing.auctionId;
1341
+ existing.transactionId = options?.transactionId || existing.transactionId;
1342
+ existing.mediaType = options?.mediaType || existing.mediaType || "banner";
1343
+ existing.bidPayload = options?.bid !== void 0 ? options.bid : existing.bidPayload;
1344
+ existing.metadata = options?.metadata ?? existing.metadata;
1345
+ if (existing.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1346
+ const dwellTarget = existing.mediaType === "video" ? 2e3 : 1e3;
1347
+ existing.dwellStartedAt = now;
1348
+ existing.dwellTimer = setTimeout(() => {
1349
+ this.handleDwellComplete(existing);
1350
+ }, dwellTarget);
1351
+ this.scheduleThresholdTimers(existing);
1352
+ }
1353
+ } else {
1354
+ existing.auctionId = options?.auctionId || existing.auctionId;
1355
+ existing.transactionId = options?.transactionId || existing.transactionId;
1356
+ existing.mediaType = options?.mediaType || existing.mediaType;
1357
+ if (options?.bid !== void 0) existing.bidPayload = options.bid;
1358
+ if (options?.metadata !== void 0) existing.metadata = options.metadata;
1359
+ }
1360
+ if (el) {
1361
+ if (existing.element && existing.element !== el && this.intersectionObserver) {
1362
+ this.intersectionObserver.unobserve(existing.element);
1363
+ this.elementToSlotId.delete(existing.element);
1364
+ }
1365
+ existing.element = el;
1366
+ this.elementToSlotId.set(el, resolvedSlotId);
1367
+ this.intersectionObserver?.observe(el);
1368
+ }
1369
+ } else {
1370
+ const initialRefreshIndex = options?.refreshIndex ?? this.slotRefreshIndices.get(resolvedSlotId) ?? 0;
1371
+ this.slotRefreshIndices.set(resolvedSlotId, initialRefreshIndex);
1372
+ const listeners = this.pendingThresholdListeners.get(resolvedSlotId) || /* @__PURE__ */ new Set();
1373
+ this.pendingThresholdListeners.delete(resolvedSlotId);
1374
+ const record = {
1375
+ slotId: resolvedSlotId,
1376
+ element: el,
1377
+ adUnitCode: options?.adUnitCode || resolvedSlotId,
1378
+ auctionId: options?.auctionId || "",
1379
+ transactionId: options?.transactionId || "",
1380
+ mediaType: options?.mediaType || "banner",
1381
+ bidPayload: options?.bid,
1382
+ metadata: options?.metadata,
1383
+ refreshIndex: initialRefreshIndex,
1384
+ inView: false,
1385
+ lastEnteredViewAt: null,
1386
+ accumulatedTimeInViewMs: 0,
1387
+ viewableFired: false,
1388
+ dwellTimer: null,
1389
+ dwellStartedAt: null,
1390
+ thresholdListeners: listeners
1391
+ };
1392
+ this.slotViewabilityRecords.set(resolvedSlotId, record);
1393
+ if (el) {
1394
+ this.elementToSlotId.set(el, resolvedSlotId);
1395
+ this.intersectionObserver?.observe(el);
1396
+ }
1397
+ }
1398
+ }
1399
+ unobserveSlot(slotId) {
1400
+ const record = this.slotViewabilityRecords.get(slotId);
1401
+ if (!record) return;
1402
+ this.flushSlotTimeInView(slotId);
1403
+ if (record.dwellTimer) {
1404
+ clearTimeout(record.dwellTimer);
1405
+ record.dwellTimer = null;
1406
+ record.dwellStartedAt = null;
1407
+ }
1408
+ for (const l of record.thresholdListeners) {
1409
+ if (l.timer) {
1410
+ clearTimeout(l.timer);
1411
+ l.timer = null;
1412
+ }
1413
+ }
1414
+ record.thresholdListeners = /* @__PURE__ */ new Set();
1415
+ if (record.element && this.intersectionObserver) {
1416
+ this.intersectionObserver.unobserve(record.element);
1417
+ this.elementToSlotId.delete(record.element);
1418
+ record.element = null;
1419
+ }
1420
+ record.inView = false;
1421
+ record.lastEnteredViewAt = null;
1422
+ }
1423
+ destroySlot(slotId) {
1424
+ this.unobserveSlot(slotId);
1425
+ this.slotViewabilityRecords.delete(slotId);
1426
+ this.slotRefreshIndices.delete(slotId);
1427
+ this.pendingThresholdListeners.delete(slotId);
1428
+ }
1429
+ onTimeInViewThreshold(slotId, thresholdMs, callback) {
1430
+ const listener = {
1431
+ thresholdMs,
1432
+ callback,
1433
+ timer: null,
1434
+ fired: false
1435
+ };
1436
+ const record = this.slotViewabilityRecords.get(slotId);
1437
+ if (record) {
1438
+ record.thresholdListeners.add(listener);
1439
+ const currentTotal = this.getCurrentTimeInView(record);
1440
+ if (currentTotal >= thresholdMs) {
1441
+ listener.fired = true;
1442
+ try {
1443
+ callback(slotId, currentTotal);
1444
+ } catch (e) {
1445
+ this.log("ERROR", "Error in threshold callback", e);
1446
+ }
1447
+ } else if (record.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1448
+ const remaining = thresholdMs - currentTotal;
1449
+ listener.timer = setTimeout(() => {
1450
+ listener.timer = null;
1451
+ if (!listener.fired && record.inView && (typeof document === "undefined" || document.visibilityState === "visible")) {
1452
+ const nowTotal = this.getCurrentTimeInView(record);
1453
+ if (nowTotal >= thresholdMs) {
1454
+ listener.fired = true;
1455
+ try {
1456
+ callback(slotId, nowTotal);
1457
+ } catch (e) {
1458
+ this.log("ERROR", "Error in threshold callback", e);
1459
+ }
1460
+ }
1461
+ }
1462
+ }, remaining);
1463
+ }
1464
+ } else {
1465
+ if (!this.pendingThresholdListeners.has(slotId)) {
1466
+ this.pendingThresholdListeners.set(slotId, /* @__PURE__ */ new Set());
1467
+ }
1468
+ this.pendingThresholdListeners.get(slotId).add(listener);
1469
+ }
1470
+ return () => {
1471
+ if (listener.timer) {
1472
+ clearTimeout(listener.timer);
1473
+ listener.timer = null;
1474
+ }
1475
+ if (record) {
1476
+ record.thresholdListeners.delete(listener);
1477
+ }
1478
+ const pending = this.pendingThresholdListeners.get(slotId);
1479
+ if (pending) {
1480
+ pending.delete(listener);
1481
+ }
1482
+ };
1483
+ }
1484
+ getViewabilityState(slotId) {
1485
+ const record = this.slotViewabilityRecords.get(slotId);
1486
+ if (!record) return void 0;
1487
+ const timeInViewMs = Math.round(this.getCurrentTimeInView(record));
1488
+ return {
1489
+ inView: record.inView,
1490
+ viewable: record.viewableFired,
1491
+ timeInViewMs,
1492
+ refreshIndex: record.refreshIndex
1493
+ };
1494
+ }
1495
+ getRefreshIndex(slotId) {
1496
+ return this.slotRefreshIndices.get(slotId) ?? 0;
1497
+ }
1498
+ triggerSlotRefresh(slotId, options) {
1499
+ const record = this.slotViewabilityRecords.get(slotId);
1500
+ if (record && record.element) {
1501
+ this.observeSlot(record.element, slotId, options);
985
1502
  }
986
1503
  }
987
1504
  trackRawEvent(level, eventName, data) {
@@ -996,6 +1513,8 @@ var BidkernelPrebidAnalytics = class {
996
1513
  }
997
1514
  navigate(pageviewId, pageUrl) {
998
1515
  this.disable();
1516
+ this.slotRefreshIndices.clear();
1517
+ this.pendingThresholdListeners.clear();
999
1518
  this.config.pageviewId = pageviewId || generateUUID();
1000
1519
  if (pageUrl) {
1001
1520
  try {
@@ -1170,23 +1689,81 @@ var BidkernelPrebidAnalytics = class {
1170
1689
  handleAdRenderSucceeded(data) {
1171
1690
  if (this.isDuplicate("adRenderSucceeded", data)) return;
1172
1691
  this.log("DEBUG", "adRenderSucceeded", data);
1173
- const bid = data.bid || {};
1692
+ const bid = data.bid || data || {};
1693
+ const adUnitCode = data.adUnitCode || bid.adUnitCode || "";
1694
+ const auctionId = bid.auctionId || data.auctionId || "";
1695
+ const transactionId = bid.transactionId || data.transactionId || "";
1696
+ const mediaType = bid.mediaType || "banner";
1697
+ const bidTrace = {
1698
+ bidder: bid.bidderCode || bid.bidder || "",
1699
+ cpm: Number.isFinite(bid.originalCpm) ? bid.originalCpm : Number.isFinite(bid.cpm) ? bid.cpm : 0,
1700
+ currency: bid.originalCurrency ?? bid.currency ?? "USD",
1701
+ ...parseBidDimensions(bid),
1702
+ dealId: bid.dealId || "",
1703
+ mediaType,
1704
+ latencyMs: Number.isFinite(bid.timeToRespond) ? bid.timeToRespond : 0,
1705
+ advertiserDomain: bid.meta?.advertiserDomains?.[0] || "",
1706
+ creativeId: bid.creativeId || ""
1707
+ };
1708
+ const isRefresh = adUnitCode && (this.slotViewabilityRecords.has(adUnitCode) || this.slotRefreshIndices.has(adUnitCode) && this.slotRefreshIndices.get(adUnitCode) > 0);
1709
+ if (isRefresh) {
1710
+ this.flushSlotTimeInView(adUnitCode);
1711
+ const nextRefreshIndex = (this.slotRefreshIndices.get(adUnitCode) ?? 0) + 1;
1712
+ this.slotRefreshIndices.set(adUnitCode, nextRefreshIndex);
1713
+ this.enqueue(TraceEventType.REFRESH, "refresh", {
1714
+ auctionId,
1715
+ transactionId,
1716
+ adUnitCode,
1717
+ bid: bidTrace,
1718
+ metadata: { refresh_index: String(nextRefreshIndex) }
1719
+ });
1720
+ } else if (adUnitCode && !this.slotRefreshIndices.has(adUnitCode)) {
1721
+ this.slotRefreshIndices.set(adUnitCode, 0);
1722
+ }
1723
+ const currentRefreshIndex = adUnitCode ? this.slotRefreshIndices.get(adUnitCode) ?? 0 : 0;
1174
1724
  this.enqueue(TraceEventType.IMPRESSION, "impression", {
1175
- auctionId: bid.auctionId || data.auctionId || "",
1176
- transactionId: bid.transactionId || data.transactionId || "",
1177
- adUnitCode: data.adUnitCode || bid.adUnitCode || "",
1178
- bid: {
1179
- bidder: bid.bidderCode || bid.bidder || "",
1180
- cpm: Number.isFinite(bid.originalCpm) ? bid.originalCpm : Number.isFinite(bid.cpm) ? bid.cpm : 0,
1181
- currency: bid.originalCurrency ?? bid.currency ?? "USD",
1182
- ...parseBidDimensions(bid),
1183
- dealId: bid.dealId || "",
1184
- mediaType: bid.mediaType || "banner",
1185
- latencyMs: Number.isFinite(bid.timeToRespond) ? bid.timeToRespond : 0,
1186
- advertiserDomain: bid.meta?.advertiserDomains?.[0] || "",
1187
- creativeId: bid.creativeId || ""
1188
- }
1725
+ auctionId,
1726
+ transactionId,
1727
+ adUnitCode,
1728
+ bid: bidTrace,
1729
+ metadata: { refresh_index: String(currentRefreshIndex) }
1189
1730
  });
1731
+ if (this.config.viewabilityEnabled && typeof document !== "undefined") {
1732
+ let el = null;
1733
+ const targetId = adUnitCode || data.adId || bid.adId;
1734
+ if (targetId) {
1735
+ el = document.getElementById(targetId);
1736
+ }
1737
+ if (!el && typeof window !== "undefined" && window.googletag?.pubads) {
1738
+ try {
1739
+ const slots = window.googletag.pubads().getSlots();
1740
+ for (const slot of slots) {
1741
+ if (slot.getAdUnitPath?.() === adUnitCode || slot.getSlotElementId?.() === adUnitCode) {
1742
+ el = document.getElementById(slot.getSlotElementId());
1743
+ if (el) break;
1744
+ }
1745
+ }
1746
+ } catch {
1747
+ }
1748
+ }
1749
+ if (!el && targetId) {
1750
+ try {
1751
+ el = document.querySelector(`[data-ad-slot="${targetId}"]`) || document.querySelector(`#${targetId}`);
1752
+ } catch {
1753
+ }
1754
+ }
1755
+ if (el) {
1756
+ this.observeSlot(el, adUnitCode || targetId, {
1757
+ adUnitCode,
1758
+ auctionId,
1759
+ transactionId,
1760
+ mediaType,
1761
+ bid: bidTrace,
1762
+ refreshIndex: currentRefreshIndex,
1763
+ emitRefreshEvent: false
1764
+ });
1765
+ }
1766
+ }
1190
1767
  }
1191
1768
  isDuplicate(eventName, data) {
1192
1769
  return this.deduper.isDuplicate(eventName, data);
@@ -1232,6 +1809,9 @@ var BidkernelPrebidAnalytics = class {
1232
1809
  if (data.gpid) {
1233
1810
  metadata.gpid = String(data.gpid);
1234
1811
  }
1812
+ if (data.refreshIndex !== void 0 && metadata.refresh_index === void 0) {
1813
+ metadata.refresh_index = String(data.refreshIndex);
1814
+ }
1235
1815
  if (Object.keys(metadata).length > 0) {
1236
1816
  protoEvent.metadata = metadata;
1237
1817
  }
@@ -1241,6 +1821,12 @@ var BidkernelPrebidAnalytics = class {
1241
1821
  }
1242
1822
  if (this.queue.length >= BATCH_SIZE) {
1243
1823
  this.flush();
1824
+ } else if (IMMEDIATE_FLUSH_TYPES.has(type) && !this.immediateFlushScheduled) {
1825
+ this.immediateFlushScheduled = true;
1826
+ setTimeout(() => {
1827
+ this.immediateFlushScheduled = false;
1828
+ this.flush();
1829
+ }, 0);
1244
1830
  }
1245
1831
  }
1246
1832
  shouldSample(type, level) {
@@ -1297,10 +1883,11 @@ var BidkernelPrebidAnalytics = class {
1297
1883
  events
1298
1884
  };
1299
1885
  }
1300
- sendFetch(payload, useKeepalive = false) {
1886
+ sendFetch(payload, useKeepalive = false, onDelivered) {
1301
1887
  const fetchOpts = {
1302
1888
  method: "POST",
1303
- headers: { "Content-Type": "application/x-protobuf" },
1889
+ // Safelisted content type: no CORS preflight (see SAFE_CONTENT_TYPE).
1890
+ headers: { "Content-Type": SAFE_CONTENT_TYPE },
1304
1891
  body: payload.encoded
1305
1892
  };
1306
1893
  if (useKeepalive) {
@@ -1309,7 +1896,7 @@ var BidkernelPrebidAnalytics = class {
1309
1896
  fetch(payload.url, fetchOpts).then((res) => {
1310
1897
  if (!res.ok) {
1311
1898
  this.log("WARN", `Failed to send batch: HTTP ${res.status}`);
1312
- if (res.status >= 400 && res.status < 500) {
1899
+ if (res.status >= 400 && res.status < 500 && res.status !== 429 && res.status !== 408) {
1313
1900
  this.log("WARN", `Dropping batch due to non-retryable client error HTTP ${res.status}`);
1314
1901
  return;
1315
1902
  }
@@ -1317,11 +1904,19 @@ var BidkernelPrebidAnalytics = class {
1317
1904
  } else {
1318
1905
  this.consecutiveSendFailures = 0;
1319
1906
  this.nextSendAllowedAt = 0;
1907
+ if (onDelivered) onDelivered();
1320
1908
  }
1321
1909
  }).catch((err) => {
1322
1910
  this.log("ERROR", "Failed to send batch", err);
1323
1911
  this.handleSendFailure(payload.events);
1324
1912
  });
1913
+ const proc = typeof globalThis !== "undefined" ? globalThis.process : void 0;
1914
+ if (proc && typeof proc._tickCallback === "function") {
1915
+ try {
1916
+ proc._tickCallback();
1917
+ } catch {
1918
+ }
1919
+ }
1325
1920
  }
1326
1921
  handleSendFailure(events) {
1327
1922
  this.consecutiveSendFailures++;
@@ -1354,19 +1949,99 @@ var BidkernelPrebidAnalytics = class {
1354
1949
  let sent = false;
1355
1950
  if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
1356
1951
  try {
1357
- const blob = new Blob([payload.encoded], {
1358
- type: "application/x-protobuf"
1359
- });
1952
+ const blob = new Blob([payload.encoded], { type: SAFE_CONTENT_TYPE });
1360
1953
  sent = navigator.sendBeacon(payload.url, blob);
1361
1954
  } catch {
1362
1955
  sent = false;
1363
1956
  }
1364
1957
  }
1365
1958
  if (!sent) {
1366
- this.sendFetch(payload, true);
1959
+ const persistKey = this.persistBatch(payload.encoded);
1960
+ this.sendFetch(payload, true, () => this.removePersistedBatch(persistKey));
1367
1961
  }
1368
1962
  payload = this.drainBatch();
1369
1963
  }
1964
+ this.schedulePersistedCleanup();
1965
+ }
1966
+ // --- Exit-batch persistence -----------------------------------------------
1967
+ persistBatch(encoded) {
1968
+ try {
1969
+ if (typeof localStorage === "undefined") return null;
1970
+ let pendingCount = 0;
1971
+ for (let i = 0; i < localStorage.length; i++) {
1972
+ const k = localStorage.key(i);
1973
+ if (k && k.startsWith(PENDING_BATCH_KEY_PREFIX)) pendingCount++;
1974
+ }
1975
+ if (pendingCount >= MAX_PENDING_BATCHES) return null;
1976
+ const key = `${PENDING_BATCH_KEY_PREFIX}${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
1977
+ localStorage.setItem(key, bytesToBase64(encoded));
1978
+ this.persistedBatchKeys.push(key);
1979
+ return key;
1980
+ } catch {
1981
+ return null;
1982
+ }
1983
+ }
1984
+ removePersistedBatch(key) {
1985
+ if (!key) return;
1986
+ try {
1987
+ if (typeof localStorage !== "undefined") localStorage.removeItem(key);
1988
+ } catch {
1989
+ }
1990
+ const idx = this.persistedBatchKeys.indexOf(key);
1991
+ if (idx !== -1) this.persistedBatchKeys.splice(idx, 1);
1992
+ }
1993
+ // A page that is still running PENDING_BATCH_CLEANUP_DELAY_MS after an exit
1994
+ // flush was never torn down, so its beacons have long since gone out; drop
1995
+ // the persisted copies rather than letting a later pageview resend them.
1996
+ schedulePersistedCleanup() {
1997
+ if (this.persistedBatchKeys.length === 0) return;
1998
+ if (this.persistedCleanupTimer) clearTimeout(this.persistedCleanupTimer);
1999
+ this.persistedCleanupTimer = setTimeout(() => {
2000
+ this.persistedCleanupTimer = null;
2001
+ for (const key of [...this.persistedBatchKeys]) {
2002
+ this.removePersistedBatch(key);
2003
+ }
2004
+ }, PENDING_BATCH_CLEANUP_DELAY_MS);
2005
+ }
2006
+ // Resend batches a previous pageview persisted at exit but could not
2007
+ // confirm. Keys are claimed (removed) before sending so concurrent tabs
2008
+ // don't double-send; the batch bytes carry their original pageview, session,
2009
+ // and event timestamps, so late rows attribute correctly.
2010
+ resendPersistedBatches() {
2011
+ try {
2012
+ if (typeof localStorage === "undefined" || !this.config.endpoint) return;
2013
+ const keys = [];
2014
+ for (let i = 0; i < localStorage.length; i++) {
2015
+ const k = localStorage.key(i);
2016
+ if (k && k.startsWith(PENDING_BATCH_KEY_PREFIX)) keys.push(k);
2017
+ }
2018
+ for (const key of keys) {
2019
+ let value = null;
2020
+ try {
2021
+ value = localStorage.getItem(key);
2022
+ localStorage.removeItem(key);
2023
+ } catch {
2024
+ continue;
2025
+ }
2026
+ if (!value) continue;
2027
+ const ts = parseInt(key.slice(PENDING_BATCH_KEY_PREFIX.length), 10);
2028
+ if (!Number.isFinite(ts) || Date.now() - ts > PENDING_BATCH_MAX_AGE_MS) continue;
2029
+ let encoded;
2030
+ try {
2031
+ encoded = base64ToBytes(value);
2032
+ } catch {
2033
+ continue;
2034
+ }
2035
+ if (encoded.byteLength === 0) continue;
2036
+ this.log("DEBUG", `Resending persisted exit batch (${encoded.byteLength} bytes)`);
2037
+ this.sendFetch({
2038
+ url: `${this.config.endpoint}/${this.config.propertyId}`,
2039
+ encoded,
2040
+ events: []
2041
+ });
2042
+ }
2043
+ } catch {
2044
+ }
1370
2045
  }
1371
2046
  log(level, msg, ...args) {
1372
2047
  const levels = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };