A GA4 alternative for publishers: reader events in your own warehouse
If your reader data reaches the warehouse as a Google Analytics export, every new field means an app release on Android, iOS and web. The code that adds article metadata gets written three times, once per platform.
Put that work in the pipeline instead. Events land as rows in BigQuery or ClickHouse, the metadata is added once by a function that calls your CMS, and you query the result from your BI tool or a SQL console. Subscriptions and blocker loss have their own pages, linked from the media and publishing guide.
TL;DR
- Section, author, publish time and word count come from your CMS through a function that caches each article and lets the event through without metadata if the CMS fails.
- Median engaged time per article comes from one query, on the ClickHouse warehouse included with Jitsu Cloud, before you connect your own.
- A second function drops events from staging hosts and strips email, token and password-style query parameters from page URLs before they land.
Where Investing.com started
Investing.com was in that spot, with Google Analytics as the collection layer and a BigQuery export. Adding destinations besides BigQuery was hard, and almost every event had to be enriched in the client.
Now 5 billion events a month go through Jitsu for Investing.com, with the enriching done in functions:
"Unlike other platforms, Jitsu allows not only to modify the data in transit, but also to enrich it by calling external APIs and caching API responses between function calls in a persistent key-value store. This allowed us to move away from client-side enrichment and eliminate massive code duplication."
From the Investing.com customer story
The metadata function below is that pattern for a CMS.
Article metadata from the CMS
On a cache miss the function calls the CMS once and stores the answer in Jitsu's key-value store, so repeat events for the same article skip the call. A CMS error or timeout never blocks the event: it goes through without metadata and the warning shows in Live Events. It drops CMS preview traffic first, before any lookup.
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. Build and test it with jitsu-cli; the functions docs cover return values and retries.
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;
Engaged reading by article
Both queries on this page run on ClickHouse, against the default events table. This one 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;
Rows appear once an article has 100 readers in a week; lower the HAVING threshold for a first test.
Setting it up
The events
Six events from the publisher tracking plan (CSV) land in the table these queries read:
| Event | Emitted from | Key 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, trial, ad_consent | None |
None of these six needs PII in its properties; IP and user agent arrive in context, and the script's privacy settings can trim them.
Sending them
From the browser, send the page with its article ID, and one article_engaged per page view, sent from a visibilitychange handler the first time the page becomes hidden. A 10-second heartbeat on a 3-minute read sends 18 events where that 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 });
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).
The table they land 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'.
Jitsu creates tables and adds columns as new fields arrive, and deduplicates on message_id; on ClickHouse, exact counts need FINAL. Snowflake, Redshift, Postgres, MySQL, MotherDuck and MongoDB take the same enriched events as destinations, and Amazon S3 and Google Cloud Storage take them as raw files.
Internal traffic and personal URL parameters
Put this function first in every website connection's chain, ahead of the metadata function. It drops staging traffic and removes personal query parameters (email addresses in newsletter links, reset tokens) from URLs. INTERNAL_HOSTS takes a comma-separated list of staging hosts.
import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";
export const config = {
slug: "strip-reader-pii",
name: "Strip reader PII and internal traffic",
description: "Drops events from internal hosts and removes personal query parameters from URLs",
};
type Props = {
// Comma-separated hosts whose events never reach a destination, e.g. "staging.example.com,localhost:3000"
INTERNAL_HOSTS?: string;
};
// Query parameters that carry personal data or credentials in newsletter, login and reset links
const PERSONAL_PARAMS = new Set(["email", "e", "token", "auth_token", "code", "password", "subscriber_id"]);
function scrub(value: string): string {
const hashAt = value.indexOf("#");
const beforeHash = hashAt === -1 ? value : value.slice(0, hashAt);
const hash = hashAt === -1 ? "" : value.slice(hashAt);
const queryAt = beforeHash.indexOf("?");
if (queryAt === -1) return value;
const params = new URLSearchParams(beforeHash.slice(queryAt + 1));
for (const name of [...params.keys()]) {
if (PERSONAL_PARAMS.has(name.toLowerCase())) params.delete(name);
}
const query = params.toString();
return beforeHash.slice(0, queryAt) + (query ? `?${query}` : "") + hash;
}
function scrubFields(obj: Record<string, any> | undefined, keys: string[]) {
if (!obj) return;
for (const key of keys) {
if (typeof obj[key] === "string") obj[key] = scrub(obj[key]);
}
}
const stripReaderPii: JitsuFunction<AnalyticsServerEvent, Props> = async (event, { props }) => {
const host = event.context?.page?.host ?? event.properties?.host;
const internal = (props.INTERNAL_HOSTS ?? "").split(",").map(h => h.trim()).filter(Boolean);
if (typeof host === "string" && internal.includes(host)) {
return "drop"; // no fetch has run, so this event is not billed
}
scrubFields(event.context?.page, ["url", "search", "referrer"]);
scrubFields(event.properties, ["url", "search", "referrer"]);
return event;
};
export default stripReaderPii;
Estimating your bill
Jitsu bills on active events (pricing). Before any data is in, page views times the events each view sends (a page event and one article_engaged, for example) gives your monthly count. Once events arrive, this query's total_events column counts the events that reach the warehouse, which is the bill when every event goes there. Its readers column counts unique user IDs plus anonymous IDs never tied to one, so events_per_reader shows how many events an average reader sends, the number to hold against any per-reader (MTU) price quote.
-- 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;
Questions people ask
Can I use BigQuery instead of the included ClickHouse? Yes. Point the events at BigQuery; the engaged-reading query translates by swapping its ClickHouse functions for APPROX_QUANTILES, COUNT(DISTINCT ...), COUNTIF and ANY_VALUE and dropping FINAL.
Do app reads get the same metadata columns? Once the function runs on the app connections too, app screen events carry the same four metadata columns as web page events.
Does GA4's BigQuery export keep working? Yes. Jitsu's script is separate from the Google tag, so GA4 keeps exporting while Jitsu writes its own events table, and historical GA data stays where it is.
What if the CMS has no API for article metadata? The function needs one HTTP endpoint that returns an article by ID. If the CMS has none, a small service in front of its database that answers the same request works the same way.
Get started with Jitsu
Teams moving off a GA export onto BigQuery or Snowflake start with a demo call. To see the CMS function work before that, create a free workspace and set CMS_API_URL and CMS_API_TOKEN on one website connection.