← всі звіти · read-2026-04-30T073547Z-fluvir-banner-tracking-fix.md

from: VPS Claude session: 58bba346-3f61-41c6-a90b-06db4edcbde1 topic: fluvir-banner-tracking-fix priority: high date: 2026-04-29T15:46:48Z originSessionId: 58bba346-3f61-41c6-a90b-06db4edcbde1

TL;DR

Проблему трекінгу Fluvir-банера діагностовано. Gap 99.9% (32 events у BQ vs 31,185 impressions у DV360 за 29.04) — через navigator.sendBeacon(Blob, application/json) + fetch(POST json) як основний tracking-метод. Обидва падають у SafeFrame/sandbox-iframe з CSP connect-src 'self' на більшості майданчиків.

Старий банер працював, бо використовував new Image().src = ... (GET-pixel) — він проходить через CSP img-src (зазвичай ширша) і не потребує CORS preflight.

Фікс: переписати send() на pixel-GET (зразок нижче) + додати GET-handler у Cloud Function.

Що знайдено (детально)

Старий банер (працює) — DV360 creativeId xbid.716177674

Файл: https://s0.2mdn.net/sadbundle/5503295506014535680/fluvir_banner_300x250_ga4.html Cloud Run: https://banner-tracker-736510372648.europe-west1.run.app

var TRACKER_URL = 'https://banner-tracker-736510372648.europe-west1.run.app';
function ga4(ev, p) {
  try {
    var params = 'event=' + encodeURIComponent(ev) + '&banner_name=fluvir_interactive&banner_size=300x250&cid=' + GA4_CID;
    if (p) { for (var k in p) { params += '&' + encodeURIComponent(k) + '=' + encodeURIComponent(p[k]); } }
    new Image().src = TRACKER_URL + '?' + params + '&_t=' + Date.now();  // ← GET pixel
  } catch(e) {}
}
ga4('banner_impression', {phase: 'game_start'});  // ← синхронно, не в DOMContentLoaded

Новий банер (не трекає) — DV360 creativeId xbid.721925137

Файл: https://s0.2mdn.net/sadbundle/14846425297276004514/index.html Cloud Function: https://europe-central2-banner-analytics-fluvir.cloudfunctions.net/track

var ENDPOINT = 'https://europe-central2-banner-analytics-fluvir.cloudfunctions.net/track';
function send(name, extra) {
  var data = { sid:..., name:name, ... };
  var body = JSON.stringify(data);
  try {
    if (navigator.sendBeacon) {
      var blob = new Blob([body], { type: 'application/json' });   // ← preflight needed
      if (navigator.sendBeacon(ENDPOINT, blob)) return;
    }
    fetch(ENDPOINT, { method:'POST', headers:{'Content-Type':'application/json'},
                      body:body, keepalive:true, mode:'cors' }).catch(function(){});
  } catch(_) {}
}
// Виклик через DOMContentLoaded → BannerTrack.init({ ... }) → once('impression')

Перевірка endpoint

Ендпойнт технічно справний. Проблема не на сервері, а у клієнті: SafeFrame/sandbox-iframe DV360 + CSP публішерів блокують sendBeacon/fetch на 99.9% реальних показів.

Що ще є проблема

  1. Macros %%CAMPAIGN_ID%%/%%CREATIVE_ID%%/%%SITE%% не підставляються у DV360 Studio HTML5 креативах. У всіх 32 наявних BQ-рядках campaign_id/creative_id/site = пустий string. DV360 підставляє macros лише в URL-полях (clickTag тощо), не у тілі HTML5 bundle. Без них атрибуція по майданчиках сліпа. Робочий шлях: знімати ці поля через postMessage з SafeFrame ($sf.ext.meta()) або просто залишити пустими і атрибутувати по cid/crid з DV360 Reports.

  2. pagehide listener для exit event — у sandbox-iframe іноді не вистрелює (parent page не закривається). Не критично.

  3. BannerTrack.init() викликається у DOMContentLoaded — у SafeFrame працює, але старий банер робив ga4('banner_impression', ...) синхронно у кінці тіла. Це відрізок ~50ms, але може врятувати ще кілька exit-before-load випадків.

Фікс index.html (на Desktop)

Замінити функцію send() (рядки ~625-645) у D:\Claude Code\banners\Інтерактивні банери\fluvir\index.html (та аналогічного 300x250 файлу) на:

function send(name, extra) {
  var data = { sid:ctx.sid, name:name, product:ctx.product, size:ctx.size, ver:ctx.ver,
               cid:ctx.cid, crid:ctx.crid, site:ctx.site, device:ctx.device, _t: Date.now() };
  if (extra) {
    if (extra.step    != null) data.step    = extra.step;
    if (extra.dur     != null) data.dur     = extra.dur;
    if (extra.payload != null) data.payload = (typeof extra.payload === 'string') ? extra.payload : JSON.stringify(extra.payload);
  }
  // GET pixel — обходить CSP connect-src + sandbox-iframe restrictions
  var qs = [];
  for (var k in data) {
    if (data[k] == null) continue;
    qs.push(encodeURIComponent(k) + '=' + encodeURIComponent(data[k]));
  }
  try {
    new Image().src = ENDPOINT + '/pixel?' + qs.join('&');
  } catch(_) {}
}

Або якщо хочемо двоступінчастий fallback (pixel пріоритет, sendBeacon резерв для великих payload-ів):

function send(name, extra) {
  var data = { /* ... як вище ... */ };
  var qs = [];
  for (var k in data) { if (data[k] != null) qs.push(encodeURIComponent(k) + '=' + encodeURIComponent(data[k])); }
  var url = ENDPOINT + '/pixel?' + qs.join('&');
  try {
    if (url.length < 2000) {
      new Image().src = url;          // primary: GET pixel
      return;
    }
    // payload завеликий для query string → fallback на sendBeacon з text/plain (CORS-safelisted)
    if (navigator.sendBeacon) {
      var blob = new Blob([JSON.stringify(data)], { type: 'text/plain' });
      if (navigator.sendBeacon(ENDPOINT, blob)) return;
    }
    fetch(ENDPOINT, { method:'POST', headers:{'Content-Type':'text/plain'},
                      body:JSON.stringify(data), keepalive:true, mode:'cors' }).catch(function(){});
  } catch(_) {}
}

Також: BannerTrack.init({...}) залишається у DOMContentLoaded — це OK, sendBeacon це не врятує, проблему ловить pixel-GET.

Фікс Cloud Function (на VPS або Desktop — де код CF)

Додати GET-handler у функцію track:

// Cloud Function: index.js (Node 20)
const { BigQuery } = require('@google-cloud/bigquery');
const bq = new BigQuery();
const TABLE = bq.dataset('banner_analytics').table('events');

const ALLOWED = new Set(['impression','view_start','game_start','game_finish','cta_click','exit']);

exports.track = async (req, res) => {
  res.set('Access-Control-Allow-Origin', '*');
  res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  res.set('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') return res.status(204).end();

  let data = {};
  try {
    if (req.method === 'GET' || req.path === '/pixel') {
      data = req.query || {};
    } else if (req.method === 'POST') {
      // body може приходити як JSON object (sendBeacon з content-type:json) АБО як string (text/plain)
      data = (typeof req.body === 'string') ? JSON.parse(req.body) : (req.body || {});
    } else {
      return res.status(405).end();
    }
  } catch (e) { return res.status(204).end(); /* малозначущий error — не блокуємо клієнт */ }

  const event = String(data.name || '').slice(0, 32);
  if (!ALLOWED.has(event)) {
    // якщо GET pixel — повертаємо 1×1 GIF, не 204, інакше браузер реагує як на error
    if (req.method === 'GET') return sendPixel(res);
    return res.status(204).end();
  }

  const row = {
    event_date: new Date().toISOString().slice(0, 10),
    event_ts:   new Date().toISOString(),
    session_id: String(data.sid || '').slice(0, 64),
    event_name: event,
    product:    String(data.product || '').slice(0, 32),
    banner_size:String(data.size || '').slice(0, 16),
    banner_ver: String(data.ver || '').slice(0, 16),
    campaign_id:String(data.cid || '').slice(0, 32),
    creative_id:String(data.crid || '').slice(0, 32),
    site:       String(data.site || '').slice(0, 128),
    device:     String(data.device || '').slice(0, 16),
    step:       data.step != null ? Number(data.step) : null,
    duration_ms:data.dur != null ? Number(data.dur) : null,
    payload:    data.payload != null ? (typeof data.payload === 'string' ? data.payload : JSON.stringify(data.payload)) : null,
  };
  try { await TABLE.insert([row], { ignoreUnknownValues: true }); } catch (e) { console.error('bq', e); }

  if (req.method === 'GET') return sendPixel(res);
  return res.status(204).end();
};

function sendPixel(res) {
  // 1×1 transparent GIF
  const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
  res.set('Content-Type', 'image/gif');
  res.set('Cache-Control', 'no-store');
  return res.status(200).end(gif);
}

Деплой: gcloud functions deploy track --gen2 --runtime=nodejs20 --region=europe-central2 --trigger-http --allow-unauthenticated --project=banner-analytics-fluvir --entry-point=track

Бонус (low priority)

Дай VPS Claude'у roles/logging.viewer на проекті banner-analytics-fluvir, щоб самостійно дивитися CF-помилки:

gcloud projects add-iam-policy-binding banner-analytics-fluvir \
  --member="serviceAccount:ad-pipeline-worker@ad-analytics-hub.iam.gserviceaccount.com" \
  --role="roles/logging.viewer"

Як я це знайшов (для повторення)

VPS має VNC-Chromium з persistent Google profile у /srv/browser-profile/main/ (60 google.com cookies). Запустив Playwright з тим profile у DISPLAY=:99 (Xvfb), пройшов на DV360 preview-URL, поспостерігав за response-event для adspreview.googleusercontent.com / s0.2mdn.net — отримав прямі CDN-URL обох креативів. Далі звичайний curl + grep по тексту banner HTML.

Скрипт можна повторити: /tmp/dv360_*.{html,png} зараз містять артефакти. Сам репро-скрипт — у поточній сесії bash history.

— VPS Claude