A GA4 alternative for publishers: reader events in your own warehouse
If you are looking for a GA4 alternative because you want the raw events rather than a report, Jitsu does the collection and loading: one row per page view, engaged read or subscription, in one events table in BigQuery or ClickHouse that you query directly. Dashboards stay in your BI tool.
A function adds article metadata (section, author, publish time, word count) in transit by calling your CMS API and caching the answer, so web and app releases never carry it. This page has the function, engaged-reading SQL, and how per-event billing compares with per-reader billing on your own numbers. It is part of the media and publishing guide.
TL;DR
- Every event lands in one
eventstable by default, properties as columns of their own. - The function calls your CMS once per article and caches the answer in Jitsu's key-value store for a day.
- Events dropped before any
fetchare not billed; events dropped after one are. - Investing.com sends 5 billion events a month through Jitsu and enriches them in functions.
- Every Jitsu Cloud workspace includes a ClickHouse warehouse with a SQL console.
Who this is for
Heads of data and data engineers at publishers whose warehouse feed today is an analytics tool's export, or who run Segment and pay per monthly tracked user.
The problem
Investing.com started from Google Analytics as the collection layer with a BigQuery export. Every new parameter meant changing client code on Android, iOS and web at the same time, adding destinations besides BigQuery was hard, and almost every event had to be enriched in the client (customer story).
Today Investing.com sends 5 billion events a month through Jitsu and enriches them in functions that call external APIs and cache the responses in Jitsu's key-value store. The function on this page is that pattern for a CMS.
The events
| Event | Emitted from | Required properties | Added by the function |
|---|---|---|---|
page | Browser (Jitsu script), sent by your code after load | article_id, content_type | section, author, published_at, word_count |
screen | iOS and Android, Segment libraries | name (the screen call's name argument), article_id | The same four |
article_engaged | Browser, app | article_id, engaged_seconds, max_depth_pct | The same four |
video_play_started | Browser, app | video_id, article_id, autoplay | The same four |
recommendation_clicked | Browser, app | module, position, target_article_id | None |
subscription_started | Billing backend, HTTP API | subscription_id, plan, value, currency | None |
None of these needs PII. The media tracking plan (CSV) lists all 34 events with property types.
Sending them
From the browser, send the page with its article ID and one article_engaged per page view when the page is hidden, rather than a heartbeat every few seconds.
Every event that reaches a destination is an active event, so a 10-second heartbeat on a 3-minute read is 18 events where one would do.
Load the script with data-init-only="true" and send the page from the data-onload hook, as the server-side tracking page shows; otherwise the script sends its own page event without article_id and every view counts twice.
jitsu.page({ article_id: "a-2231", content_type: "story" });
jitsu.track("article_engaged", { article_id: "a-2231", engaged_seconds: 74, max_depth_pct: 75 });
Send article_engaged from a visibilitychange handler when the page becomes hidden. A request still in flight when the tab closes can be canceled by the browser, so compare the count of article_engaged with page views to measure the loss before trusting engaged-time medians.
Apps send through Segment's open-source libraries pointed at your Jitsu domain. For iOS, from the iOS docs:
let configuration = Configuration(
writeKey: "BROWSER_WRITE_KEY",
apiHost: "your-jitsu-domain.com",
cdnHost: "your-jitsu-domain.com")
.trackApplicationLifecycleEvents(true)
.flushInterval(10)
Android uses Segment's Analytics for Kotlin the same way (Android docs). To measure how many browser events ad blockers remove, see the server-side tracking page.
The table it lands in
By default every event lands in one events table (schema docs). Nested fields are flattened with underscores and camelCase keys become snake_case, so context.page.path becomes context_page_path.
Track and page properties are written at the root: article_id and engaged_seconds are columns of their own, while the event name keeps its value, so event = 'article_engaged'. A property named like a root field (type, event, timestamp, user_id) is overwritten by the root field, so do not use those names.
Jitsu creates tables and adds columns as new fields arrive, and deduplicates on message_id; on ClickHouse, exact counts need FINAL.
The function
Attach it to each connection that should carry metadata, after any function that drops events. It drops CMS preview traffic first, because an event dropped after a fetch call is still billed.
On a cache miss it calls the CMS once, keeps the answer in Jitsu's key-value store for a day (an unknown article for an hour), and adds four fields. A CMS error or timeout never blocks the event: it goes through without metadata and the warning shows in Live Events.
Configure three environment variables on the connection: CMS_API_URL, CMS_API_TOKEN and PREVIEW_HOSTS. Map your CMS's field names where the function reads body.section and the rest. The store is isolated per workspace, not per connection, so the cache key includes CMS_API_URL: two sites with different CMSs can then use the same article ID without reading each other's cache. Its default TTL is 31 days when none is set (runtime docs).
A function reaches the CMS over its API. Build and test with jitsu-cli; the functions docs cover return values and retries. The function editor in the Jitsu UI takes JavaScript, which is what jitsu-cli build outputs.
import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";
export const config = {
slug: "article-metadata",
name: "Article metadata from the CMS",
description: "Adds section, author, publish time and word count to events that carry an article_id",
};
type Props = {
CMS_API_URL: string; // e.g. https://cms.example.com/api
CMS_API_TOKEN: string;
PREVIEW_HOSTS?: string; // comma-separated CMS preview hosts, e.g. "preview.example.com"
};
type Article = { section?: string; author?: string; published_at?: string; word_count?: number };
const DAY = 60 * 60 * 24;
const HOUR = 60 * 60;
const articleMetadata: JitsuFunction<AnalyticsServerEvent, Props> = async (event, { log, fetch, store, props }) => {
// Drop decisions come before fetch: an event dropped after a fetch call is still billed.
const host = event.context?.page?.host;
const previewHosts = (props.PREVIEW_HOSTS ?? "").split(",").map(h => h.trim()).filter(Boolean);
if (typeof host === "string" && previewHosts.includes(host)) {
return "drop";
}
const raw = event.properties?.article_id;
if ((typeof raw !== "string" && typeof raw !== "number") || raw === "") {
return; // not an article event: passes unchanged
}
const articleId = String(raw);
const key = `article:${props.CMS_API_URL}:${articleId}`; // the store is shared by the workspace, so two CMSs must not share a key
let article: Article | undefined = await store.get(key);
if (article == null) {
try {
const res = await fetch(`${props.CMS_API_URL}/articles/${encodeURIComponent(articleId)}`, {
headers: { Authorization: `Bearer ${props.CMS_API_TOKEN}` },
});
if (res.status === 404) {
article = {};
await store.set(key, article, HOUR); // unknown ID: ask again in an hour, not on every event
} else if (!res.ok) {
log.warn(`CMS returned ${res.status} for article ${articleId}`);
return; // send the event without metadata
} else {
const body = await res.json();
article = {
section: body.section,
author: body.author,
published_at: body.published_at,
word_count: body.word_count,
};
await store.set(key, article, DAY);
}
} catch (e: any) {
log.warn(`CMS lookup failed for article ${articleId}: ${e?.message}`);
return;
}
}
event.properties = { ...event.properties, ...article };
return event;
};
export default articleMetadata;
Querying it
ClickHouse, against the default events table. Every Jitsu Cloud workspace includes a ClickHouse warehouse with a SQL console. This query ranks articles by median engaged time, with the section the function added:
-- ClickHouse: engaged reading per article, last 7 days
SELECT
section,
article_id,
any(word_count) AS words,
uniqExact(anonymous_id) AS readers,
quantileExact(0.5)(engaged_seconds) AS median_engaged_seconds,
round(countIf(max_depth_pct >= 75) / count(), 3) AS share_past_75_pct
FROM events FINAL
WHERE type = 'track'
AND event = 'article_engaged'
AND timestamp >= now() - INTERVAL 7 DAY
GROUP BY section, article_id
HAVING readers >= 100
ORDER BY median_engaged_seconds DESC
LIMIT 50;
On BigQuery, swap the ClickHouse functions for their equivalents (APPROX_QUANTILES, COUNT(DISTINCT ...), COUNTIF, ANY_VALUE) and drop FINAL.
Per-event and per-reader billing
Segment's documentation defines monthly tracked users as the unique user IDs in a billing month, plus the anonymous IDs that were never associated with a user ID (Segment docs).
Jitsu bills active events: an event sent successfully to at least one destination counts once, however many destinations it reaches, and captured events are free.
With MTU billing, cost follows how many distinct readers arrive; with active events, it follows how many events reach a destination. Neither is cheaper at every volume.
This query counts readers the way that definition does and divides events by readers:
-- ClickHouse: events per monthly reader, readers counted the way an MTU bill counts them:
-- every distinct user ID, plus every anonymous ID never seen with a user ID that month
SELECT
month,
sum(event_count) AS total_events,
uniqExactArray(user_ids) + countIf(anon_id != '' AND empty(user_ids)) AS readers,
round(total_events / readers, 1) AS events_per_reader
FROM
(
SELECT
toStartOfMonth(timestamp) AS month,
ifNull(anonymous_id, '') AS anon_id,
groupUniqArrayIf(user_id, ifNull(user_id, '') != '') AS user_ids,
count() AS event_count
FROM events FINAL
WHERE timestamp >= toStartOfMonth(now()) - INTERVAL 3 MONTH
AND (ifNull(anonymous_id, '') != '' OR ifNull(user_id, '') != '')
GROUP BY month, anon_id
)
GROUP BY month
ORDER BY month;
Jitsu vs Segment covers the rest of the comparison, and Jitsu's rates are on the pricing page.
Destinations
Live warehouse destinations are ClickHouse, BigQuery, Snowflake, Redshift, Postgres, MySQL, MotherDuck and MongoDB, with Amazon S3 and Google Cloud Storage for raw files. The same enriched events go to Mixpanel, Amplitude or PostHog when product analytics needs them. Investing.com runs Google BigQuery with Mixpanel and Google Ads as server destinations.
What others in the industry do
Public write-ups from other publishers, cited as industry examples. They do not describe Jitsu.
- The Financial Times event spec for its Spoor pipeline expands a content UUID sent with an event into article metadata during enrichment (o-tracking event spec), the same split as the function above.
- BBC's analytics pipeline on AWS validates incoming files, processes them, then loads aggregations into Redshift and the raw data into an S3 data lake, with each stage isolated so one failure does not stop the rest (InfoQ).
Where Jitsu does not fit
- Jitsu loads data and ships no dashboard. Editors read it through your BI tool.
- Per-event billing grows with engagement events. When a few readers send very many events each, per-reader billing comes out lower.
- Functions run on warehouse and cloud destinations, not on device-mode destinations or on data from connectors.
- Raw event backups are a Jitsu Cloud feature: 7 days on Free, up to 90 days on paid plans, and restores are done by the Jitsu team. Self-hosted Jitsu does not archive events.
- A feature-complete self-hosted Jitsu runs on Kubernetes. The Docker Compose setup is deprecated and cannot run functions.
Questions people ask
Where does Jitsu put reader events in BigQuery or ClickHouse? In one events table by default. Nested fields are flattened with underscores, camelCase becomes snake_case, and track properties are written at the root, so article_id becomes a column of its own.
Does the function call the CMS on every event? The function caches each article's metadata in Jitsu's key-value store for a day, and an unknown article ID for an hour, so repeat events for the same article are served from the cache.
Are events dropped by a function billed? Events dropped by a function are not billed, unless the function called fetch before dropping them. That is why the function drops CMS preview traffic before it looks anything up.
Is per-event billing cheaper than per-reader billing for publishers? Neither model is cheaper at every volume. Segment counts unique user IDs plus anonymous IDs never tied to a user ID in the month; Jitsu counts events that reach at least one destination. The events-per-reader query on this page gives the ratio that decides it for your site.
Get started with Jitsu
Get started on the free plan with 200k active events a month and the included ClickHouse warehouse, or book a demo to plan a BigQuery or Snowflake setup at publisher volume.