Перейти к основному содержимому

Отправка событий в источник

Источники трафика платят за результат, а чтобы результат засчитали, источник должен о нём услышать. Каждый ждёт эту новость в своём виде: один — &status=site_registration_isok, другой — &status_app=approve, третьему нужен депозит вместе с суммой и валютой.

Qubix держит все события, которые видит, в одном месте, а скрипт умеет это место читать и обращаться на любой разрешённый адрес. Поэтому отчёт уходит ровно в том виде, какого просит ваш партнёр: вы выбираете события, собираете запрос и решаете, что несёт каждый параметр.

В этой статье собран один рабочий скрипт и разобрана каждая его часть, чтобы вы подогнали его под своего партнёра.

Шаг 1. Разрешите адрес

Исходящие запросы закрыты, пока их не откроет администратор, и на свежей коробке список пуст — скрипт, обратившийся на неразрешённый адрес, получит отказ в консоли.

  1. Откройте Система → вкладку JavaScript.
  2. Добавьте узел своего партнёра — голое имя узла, без https:// и без пути: partner.example.com.
  3. Сохраните.
Внимание

Имя узла сверяется точно: partner.example.com не покрывает www.partner.example.com. Добавьте каждый узел, на который будете обращаться.

Шаг 2. Посмотрите, что несут ваши события

Все события живут в одной таблице qubix_events: заходы с рекламы, показы страниц, установки PWA, доставки и нажатия пуш-уведомлений, а также конверсии, которые присылают вам партнёрские сети. Что несёт событие, зависит от того, какое оно — визит приносит адрес захода со всеми метками, которые поставил источник, а регистрация или депозит приносят статус, сумму и валюту.

Прежде чем что-то писать, посмотрите на свои данные. Откройте любой скрипт и выполните:

JavaScript
function main() {
const rows = sql`
SELECT event, event_time, piuid, status, revenue, currency, country, url
FROM qubix_events
WHERE event_time > now() - INTERVAL 7 DAY
ORDER BY event_time DESC
LIMIT 20`
for (const r of rows) console.log(r.event, '|', r.status, r.revenue, r.currency, '|', r.url)
}

Чтобы увидеть, какие виды событий даёт ваш собственный трафик и сколько каждого, спросите сам перечень:

JavaScript
function main() {
const kinds = sql`
SELECT event, count() AS n, max(event_time) AS last_seen
FROM qubix_events
WHERE event_time > now() - INTERVAL 30 DAY
GROUP BY event
ORDER BY n DESC`
for (const k of kinds) console.log(k.event, k.n, k.last_seen)
}

Обычные имена: campaign_visit (заход с объявления), render (страница показана), white_page (клоака отдала безопасную страницу), installed и install_accepted (установка PWA — они идут парой, поэтому считайте только одно из них), launch_pwa (установленное приложение открыли), reg и dep (регистрация и депозит, приходят от партнёрской сети), а также семейство push_* — доставки, показы и нажатия пуш-уведомлений.

В выводе стоит заметить две вещи — вокруг них и построен скрипт ниже.

Регистрация и депозит не несут метку вашего партнёра. Они знают piuid — опознаватель посетителя, который присваивает Qubix. Метка, присланная партнёром, приехала раньше, на визите, с которого начался этот путь, и у визита хранится весь сырой адрес. Поэтому метка читается оттуда, и точно так же читается любой другой параметр той ссылки — включая те, которых никто заранее не предусматривал.

Читаемые имена лежат в своих таблицах. Событие несёт offer_id, а не имя оффера. Имя добирается отдельно — для этого в скрипте есть функция getOffer(), и тем же способом достаётся всё остальное, что вы храните в Qubix.

Шаг 3. Скрипт

Поставьте расписание * * * * * — раз в минуту.

postback-to-partner.jsJavaScript
// ════════════════════════════════════════════════════════════════════════════
// Reporting conversions back to your traffic source.
//
// Every minute the script looks for events that happened since the previous run,
// builds the request your partner expects, and sends it. Where it stopped is
// remembered between runs, so nothing is reported twice and nothing is missed.
//
// A script is exactly one `function main()` with everything inside it — that is
// the shape the editor accepts.
// ════════════════════════════════════════════════════════════════════════════

function main() {
// ─── Everything you change for your own partner lives here ────────────────
const options = {
// 1. EVENT — which events we pick up. An empty list or a zero = no restriction.
event: {
filter: {
events: ['reg', 'dep'], // event kinds to report
countries: [], // e.g. ['GH', 'NL'] — these geos only
offers: [], // e.g. ['27074f0c-…'] — these offers only
minRevenue: 0, // e.g. 1 — skip leads that pay nothing
},
},

// 2. SEND TO — the address, and the query built for each event.
send_to: {
url: 'https://partner.example.com/postback',

// Called once per event with three sources, and you decide what goes where:
// event — the event row: event, event_time, piuid, status, revenue, currency, country, offer_id
// link — every parameter of the link this visitor arrived by
// offer — the offer card: name, payout_value, payout_currency, geo, state
// Keys become parameter names; empty values are dropped.
query: function (event, link, offer) {
return {
// The partner's own marker if it sent one; otherwise piuid — the id we
// handed the partner ourselves in the offer link.
clickid: link.sub1 || event.piuid,
status: event.event === 'dep' ? 'deposit' : 'site_registration_isok',
sum: event.revenue,
cur: event.currency,
geo: event.country,
offer: offer.name, // readable name, not an id
}
},
},

// 3. RUN — how the run itself behaves.
run: {
windowMin: 60, // how far back each run looks; the schedule may be far more often
batch: 20, // events per run — stays under the outbound-call ceiling
timeoutMs: 10000, // how long we wait for the partner
},
}

// ─── Helpers. They live inside main() because the editor allows exactly one
// top-level function and nothing else. ─────────────────────────────────

// The database wants 'YYYY-MM-DD HH:MM:SS'. ctx.now() is the server clock.
function minutesAgo(minutes) {
const t = new Date(ctx.now().getTime() - minutes * 60000)
return t.toISOString().slice(0, 19).replace('T', ' ')
}

// The events themselves, oldest first — ids and raw values only. Everything
// readable is fetched separately below. Each filter line reads "setting is empty
// OR the column matches", so an unused filter costs nothing.
//
// Each run looks at a WINDOW — the last hour — rather than walking forward from a
// saved position. Telemetry can be written a little after the moment it describes,
// and a moving position would step over such an event for good; a window sees it on
// the next run. What keeps events from being reported twice is the marks below,
// not the window.
function eventsInWindow() {
const countries = options.event.filter.countries.join(',')
const offers = options.event.filter.offers.join(',')

return sql`
SELECT event_time, event, piuid, status, revenue, currency, country, offer_id
FROM qubix_events
WHERE event IN splitByChar(',', ${options.event.filter.events.join(',')})
AND event_time > now() - INTERVAL ${options.run.windowMin} MINUTE
AND (${countries} = '' OR country IN splitByChar(',', ${countries}))
AND (${offers} = '' OR offer_id IN splitByChar(',', ${offers}))
AND revenue >= ${options.event.filter.minRevenue}
ORDER BY event_time, piuid, event
LIMIT ${options.run.batch}`
}

// Turns 'https://host/path?a=1&b=2' into {a: '1', b: '2'}.
// By hand because scripts run on an ECMAScript engine: URL and URLSearchParams
// are a browser API and do not exist here. The name is cut at the FIRST '=' —
// values legitimately contain '=' (a nested address, a base64 tail).
function parseQuery(url) {
const params = {}
const query = String(url).split('?')[1] || ''

for (const pair of query.split('&')) {
if (!pair) continue
const eq = pair.indexOf('=')
const name = eq === -1 ? pair : pair.slice(0, eq)
params[decodeURIComponent(name)] = eq === -1 ? '' : decodeURIComponent(pair.slice(eq + 1))
}
return params
}

// Every parameter of the links these visitors arrived by, in ONE query.
//
// Why in one: a run may make only a limited number of database queries (20 by
// default), and asking per visitor blows through that on a full portion. One
// query for the whole portion keeps the count fixed no matter how many events
// came in.
//
// A registration or a deposit knows only piuid; the marker your partner sent
// arrived earlier, on the visit that started the journey, and that visit stores
// its whole raw address.
function linksFor(events) {
const visitors = events.map(function (e) { return e.piuid }).join(',')
const rows = sql`
SELECT piuid, argMin(url, event_time) AS url
FROM qubix_events
WHERE event = 'campaign_visit'
AND piuid IN splitByChar(',', ${visitors})
GROUP BY piuid`

const byVisitor = {}
for (const r of rows) byVisitor[r.piuid] = parseQuery(r.url)
return byVisitor
}

// The offer cards, also in ONE query, for the same reason.
//
// Offers are stored as a ReplacingMergeTree table, which keeps several versions
// of a row until they merge, so it is read with FINAL — otherwise an edited offer
// can be read in its old shape. This is the pattern for reaching anything else
// you keep in Qubix: one query for the whole portion.
function offersFor(events) {
const ids = events
.map(function (e) { return e.offer_id })
.filter(function (id) { return id })
.join(',')
if (!ids) return {}

const rows = sql`SELECT * FROM offers FINAL WHERE offer_id IN splitByChar(',', ${ids})`
const byId = {}
for (const r of rows) byId[r.offer_id] = r
return byId
}

// Assembles the address. Empty values are skipped so the partner never receives
// '&geo=' with nothing behind it; the rest is percent-encoded, which matters for
// offer names with spaces.
function buildUrl(query) {
const parts = []
for (const name of Object.keys(query)) {
const value = query[name]
if (value === undefined || value === null || value === '') continue
parts.push(encodeURIComponent(name) + '=' + encodeURIComponent(String(value)))
}
return options.send_to.url + '?' + parts.join('&')
}

// Marks older than the window are dropped: those events fall out of every future
// selection anyway, so keeping their marks would grow the state without end.
function withinWindow(marks) {
const edge = minutesAgo(options.run.windowMin)
const kept = {}
for (const m of Object.keys(marks)) {
if (m.slice(0, 19) >= edge) kept[m] = true
}
return kept
}

// ─── The run itself ───────────────────────────────────────────────────────

const events = eventsInWindow()

if (events.length === 0) {
console.log('no events in the last', options.run.windowMin, 'minutes')
return
}

// Three queries per run, whatever the portion size: the events, their links,
// their offers.
const links = linksFor(events)
const offers = offersFor(events)

const reported = ctx.state.get('reported') || {}

for (const event of events) {
// A mark identifies one event exactly: one visitor produces both a registration
// and a deposit, and two visitors land in the same second. The timestamp goes
// first so that sorting the marks is chronological.
const mark = event.event_time + '|' + event.piuid + '|' + event.event

// Why marks exist: the window overlaps between runs on purpose, so the same
// event is seen many times. The mark is what makes it go out exactly once.
if (reported[mark]) continue

const link = links[event.piuid] || {}
const offer = offers[event.offer_id] || {}
const query = options.send_to.query(event, link, offer)

// ctx.fetch THROWS when the address is not on the allowlist, when the host does
// not answer, and on a timeout — it does not return a failed answer. Without
// this catch the whole run dies and the state below is never saved, so the work
// already done in this run is lost.
let answer
try {
answer = ctx.fetch(buildUrl(query), { method: 'GET', timeout_ms: options.run.timeoutMs })
} catch (e) {
console.log('request failed:', String(e.message || e))
break
}

if (!answer.ok) {
// The partner answered, but with an error. Stop the run: these events stay
// unmarked, so the next run — still inside the window — takes them again.
console.log('refused', event.event, event.piuid, '→', answer.status, answer.body)
break
}

reported[mark] = true
console.log('reported', event.event, offer.name || event.offer_id, '→', answer.status)
}

// Saved at the very end and outside any failing path: whatever this run managed to
// report stays reported. Marks older than the window are dropped — they can never
// be seen again, so keeping them would only grow the state forever.
ctx.state.set('reported', withinWindow(reported))
}

Как он устроен

Всё, что вы меняете, лежит в options, и он разделён по вопросу, на который отвечает каждая часть:

БлокОтвечает на вопрос
event.filterкакие события мы вообще берём — виды, гео, офферы, порог выплаты. Пустой список или ноль выключает этот отбор.
send_toкуда уходит отчёт и — в query — что он несёт.
runкак ведёт себя прогон: насколько назад он смотрит (windowMin), размер порции (batch) и сколько ждёт партнёра (timeoutMs).

Запрос собирается функцией, а не шаблоном. Она получает три источника и возвращает обычный объект, поэтому никаких скрытых подстановок запоминать не нужно:

  • event — строка события: event, event_time, piuid, status, revenue, currency, country, offer_id;
  • link — все параметры ссылки, по которой пришёл посетитель: link.sub1, link.utm_source, link.ad_id, всё, что прислал источник;
  • offer — карточка оффера: name, payout_value, payout_currency, payout_type, geo, state, cap.

Раз это обычный JavaScript, разный статус на разные события, условие по сумме или параметр, который партнёр выдумал на прошлой неделе, — каждое по одной строке.

Собственная память скрипта. ctx.state переживает перезапуск коробки. В ней лежит reported — по одной метке на каждое уже отправленное событие. Память видна в правой панели редактора, в разделе Состояние; удаление записи reported заставит скрипт отправить заново всё, что ещё попадает в окно.

Почему отказ ничего не теряет. Если партнёр ответил ошибкой, прогон останавливается и оставляет эти события без меток, поэтому следующий прогон — всё ещё внутри окна — возьмёт их снова. Ничего не теряется, пока партнёр лежит.

Почему окно, а не позиция, идущая вперёд. Телеметрию иногда записывают чуть позже того момента, который она описывает, и позиция, шагающая вперёд, перешагнула бы такое событие навсегда; окно увидит его на следующем прогоне. Окно намеренно перекрывается между прогонами, поэтому ровно один раз событие уходит благодаря метке, а не окну. Каждая метка начинается со времени события, поэтому при обрезке списка остаются самые свежие.

Зачем LIMIT. У прогона есть потолок исходящих вызовов (по умолчанию 20, правится на той же вкладке JavaScript). Постоянная порция за минуту держит скрипт внутри этого потолка, а накопившееся разбирается за несколько прогонов, вместо того чтобы упасть на первом.

Совет

Значения, переданные через ${…}, уходят безопасными параметрами — SQL вы не собираете руками, и внедрение невозможно.

Пишите последовательно, без await

Скрипты исполняются без цикла событий. Promise и async/await видны в редакторе, но обработчик .then не выполняется, а код после await не будет достигнут — без всякой ошибки и при зелёном прогоне. Держите скрипт прямолинейно последовательным, как выше: sql, ctx.fetch и ctx.state синхронны и возвращают результат сразу.

Тот же скрипт под другие события

Меняется только options.

Установки PWA. Установка не несёт ни статуса, ни выручки, поэтому запрос короче:

JavaScript
event: {
filter: { events: ['installed'], countries: [], offers: [], minRevenue: 0 },
},
send_to: {
url: 'https://partner.example.com/postback',
query: function (event, link, offer) {
return {
clickid: link.sub1 || event.piuid,
status: 'install',
geo: event.country,
offer: offer.name,
}
},
},

Только депозиты выше порога. Работу делает отбор, запрос остаётся прежним:

JavaScript
event: {
filter: { events: ['dep'], countries: [], offers: [], minRevenue: 10 },
},

Нажатия по пуш-уведомлениям — возвращённый трафик. Пригодится, когда партнёр считает возвраты:

JavaScript
event: {
filter: { events: ['push_click'], countries: [], offers: [], minRevenue: 0 },
},
send_to: {
url: 'https://partner.example.com/postback',
query: function (event, link, offer) {
return { clickid: link.sub1 || event.piuid, status: 'retention' }
},
},

Отчёт в момент события

Скрипт работает по расписанию, поэтому отчёт уходит в течение минуты после события. Когда партнёру нужно обращение ровно в момент действия, для кода есть второе место — обработчик сайта: он исполняется внутри самого входящего запроса, с теми же исходящими обращениями и тем же доступом к базе. См. Серверный код сайта.

Расписание берите для всего, что терпит минуту, — установки, регистрации, депозиты, — а обработчик там, где ответ нужен немедленно.

Что дальше