Як збираються дані з інтерактивних банерів: pixel-GET → Cloud Function → BigQuery → Grafana. Канонічний документ для будь-якого нового бренду.
[Doctor browser / DV360 SafeFrame iframe]
│ banner JS → new Image().src = ENDPOINT/pixel?event=...
│ ↓ обходить SafeFrame CSP `connect-src` (img-src ширше)
▼
[Cloud Function `track`]
europe-central2-banner-analytics-fluvir.cloudfunctions.net/track/pixel
│ parse query string → row insert
▼
[BigQuery]
project=banner-analytics-fluvir, dataset=banner_analytics, table=events, location=europe-central2
▼
[Grafana dashboard «Fluvir Banner — Live»]
http://31.131.26.203/grafana/d/fluvir-banner-live/
trackPOST https://europe-central2-banner-analytics-fluvir.cloudfunctions.net/track — legacy JSON body
GET https://europe-central2-banner-analytics-fluvir.cloudfunctions.net/track/pixel?... — головний (pixel-tracking)
Раніше банер використовував navigator.sendBeacon(Blob, application/json) + fetch(POST json). Обидва падають у SafeFrame/sandbox-iframe з CSP connect-src 'self' на більшості майданчиків. Pixel-GET через <img> src проходить через ширшу img-src CSP без CORS preflight.
| Param | Required | Опис |
|---|---|---|
sid |
✅ | session_id (<brand>-<random>-<timestamp>, генерується клієнтом) |
name |
✅ | event_name (impression/view_start/game_start/game_finish/cta_click/exit + кастомні) |
product |
✅ | brand SKU code (fluvir/menopace/enzybar-gold тощо) |
size |
recommended | banner_size (300x250/300x600/320x480 etc) |
ver |
recommended | banner_ver (v1/v2/v3-pixel-pizza тощо — для AB-test різних креативів) |
cid |
optional | DV360 %%CAMPAIGN_ID%% (працює лише в URL-полях, не у HTML5) |
crid |
optional | DV360 %%CREATIVE_ID%% |
site |
optional | publisher domain |
device |
optional | mobile/desktop/tablet |
step |
optional | для багатоступеневих кейсів (e.g. enzybar score progression) |
dur |
optional | duration_ms |
payload |
optional | JSON-string з кастомними полями для analytics (передається як 1 string) |
_t |
recommended | Date.now() — щоб обходити browser-cache |
gcloud functions deploy track --gen2 --runtime=nodejs20 \
--region=europe-central2 --trigger-http --allow-unauthenticated \
--project=banner-analytics-fluvir --entry-point=track
CF код handles обидва: GET (pixel) і POST (legacy JSON body). Логіка повертає 1×1 GIF на GET, 200 на POST.
Один CF — track у проекті banner-analytics-fluvir — обслуговує всі бренди через product параметр. Не треба окремий CF для кожного бренду.
banner-analytics-fluvir.banner_analytics.events| Колонка | Тип | Required | Опис |
|---|---|---|---|
event_date |
DATE | ✅ | автоматично з event_ts |
event_ts |
TIMESTAMP | ✅ | момент події (UTC, Date.now() з клієнта) |
session_id |
STRING | ✅ | <brand>-<random>-<ms> |
event_name |
STRING | ✅ | impression / view_start / game_start / game_finish / cta_click / exit + кастомні |
product |
STRING | fluvir / menopace / enzybar-gold |
|
banner_size |
STRING | 300x250 etc |
|
banner_ver |
STRING | versioning AB-tests | |
campaign_id |
STRING | DV360 macro | |
creative_id |
STRING | DV360 macro | |
site |
STRING | publisher | |
device |
STRING | mobile/desktop/tablet | |
step |
INTEGER | для multi-step gameplay | |
duration_ms |
INTEGER | TTI або gameplay duration | |
payload |
JSON | custom payload (e.g. nutrient_type, enzyme_match etc) |
| Event | Коли |
|---|---|
impression |
банер ініціалізовано на сторінці |
view_start |
DOMContentLoaded або gameplay container appended |
game_start |
користувач почав інтерактив (натиснув Start, перший drag etc) |
game_finish |
користувач дограв до кінця (passed all stages) |
cta_click |
клік на CTA-кнопку |
exit |
користувач закрив (pagehide listener) |
Кожен банер може додавати власні події у тому ж event_name полі. Приклади:
enzyme_grab, enzyme_fire, enzyme_match_correct, enzyme_match_wrong, nutrient_missed, level_progressquiz_answer_yes, quiz_answer_nostep_advanceКастомні події треба фільтрувати на пайплайні рапортів (canonical funnel = тільки 6 базових).
engaged = users who reached game_finish AND cta_click after
interrupt = users with game_start AND cta_click (без game_finish, або клік до finish)
passive = users with cta_click без game_start (просто misclick/brand-recognition)
SQL шаблон (Grafana panel використовує):
WITH sessions AS (
SELECT
session_id,
MAX(IF(event_name='game_start', event_ts, NULL)) AS gs_ts,
MAX(IF(event_name='game_finish', event_ts, NULL)) AS gf_ts,
MAX(IF(event_name='cta_click', event_ts, NULL)) AS cta_ts
FROM `banner-analytics-fluvir.banner_analytics.events`
WHERE $__timeFilter(event_ts) AND product='fluvir'
GROUP BY session_id
HAVING cta_ts IS NOT NULL
)
SELECT
CASE
WHEN gf_ts IS NOT NULL AND cta_ts >= gf_ts THEN 'engaged'
WHEN gs_ts IS NOT NULL AND cta_ts >= gs_ts THEN 'interrupt'
ELSE 'passive'
END AS class,
COUNT(*) AS n
FROM sessions
GROUP BY class
Перші замірі Fluvir 300×600 (2026-04-30): 6.9% engaged / 75.5% passive / ~17.6% interrupt.
75% misclicks — стандартна CPC-метрика бреше про ROI. Engaged-частка — справжній сигнал гри.
URL: http://31.131.26.203/grafana/d/fluvir-banner-live/
Логін: sergey / obsidian-memory-2026
Панелі:
Auto-refresh 5 хв, time range default 7d.
/srv/services/grafana/dashboards/fluvir-banner-live.json — provisioning JSON. Після правки → Grafana auto-reprovision за 30 секунд.
cp /srv/services/grafana/dashboards/fluvir-banner-live.json \
/srv/services/grafana/dashboards/<brand>-banner-live.json
# replace product='fluvir' → product='<brand>'
# replace title + uid
Auto-provision підхопить.
import json, time, jwt, requests
sa = json.load(open('/srv/passepartout/google/ad-pipeline-worker-sa.json'))
now = int(time.time())
claim = {
'iss': sa['client_email'],
'scope': 'https://www.googleapis.com/auth/bigquery.readonly',
'aud': 'https://oauth2.googleapis.com/token',
'iat': now, 'exp': now + 3600,
}
token = requests.post('https://oauth2.googleapis.com/token', data={
'grant_type':'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion': jwt.encode(claim, sa['private_key'], algorithm='RS256'),
}).json()['access_token']
r = requests.post(
'https://bigquery.googleapis.com/bigquery/v2/projects/banner-analytics-fluvir/queries',
headers={'Authorization': f'Bearer {token}'},
json={'query': '...', 'useLegacySql': False, 'location': 'europe-central2'},
)
SA ad-pipeline-worker@ad-analytics-hub.iam.gserviceaccount.com має cross-project IAM grant (bigquery.dataViewer + jobUser) на banner-analytics-fluvir.
Завжди використовуй location='europe-central2'. Не EU, не US — інакше 404 «Dataset not found».
pixel-GET хелпер з product=<новий-slug>, ver='v1-<concept>'. CF приймає одразу, нічого деплоїти не треба.SELECT COUNT(*) FROM `banner-analytics-fluvir.banner_analytics.events`
WHERE product='<новий-slug>' AND event_date = CURRENT_DATE()
fluvir-banner-live.json → заміни product → новий uid/title%%CAMPAIGN_ID%%/%%CREATIVE_ID%%/%%SITE%% НЕ підставляються в HTML5 креативах — тільки у URL-полях (clickTag etc). У всіх 32 v1-рядках Fluvir BQ ці поля пусті. Workaround:
$sf.ext.meta() postMessage parser (TODO впровадити)cid/crid з DV360 Reports (manual JOIN)pagehide listener для exit event у sandbox-iframe іноді не вистрелює. Не критично — стандартний user-loss.BannerTrack.init() у DOMContentLoaded у SafeFrame працює, але старий банер робив ga4('banner_impression', ...) синхронно у кінці тіла (~50ms раніше) — рятувало кілька exit-before-load випадків./etc/systemd/system/grafana.service + /srv/services/grafana/