Analytics for 10 social networks: polling post stats by age, and why NULL isn't zero
VeloxonHub Social has an Analytics page that shows views, reactions, comments and shares for everything you publish through it, across Facebook, Instagram, YouTube, LinkedIn Pages, Threads, Bluesky, Mastodon, Tumblr, DEV
VeloxonHub Social has an Analytics page that shows views, reactions, comments and shares for everything you publish through it, across Facebook, Instagram, YouTube, LinkedIn Pages, Threads, Bluesky, Mastodon, Tumblr, DEV.to and WordPress, plus a daily follower count per account. None of these networks push numbers to you, so it's all polling. Here's how we keep that cheap and honest.
Two tables, nothing clever
-- Latest snapshot per published target. NULL = the platform doesn't report it.
CREATE TABLE post_metrics (
post_target_id UUID PRIMARY KEY REFERENCES post_targets (id) ON DELETE CASCADE,
platform social_platform NOT NULL,
views BIGINT, likes BIGINT, comments BIGINT, shares BIGINT,
error TEXT, -- last fetch error, e.g. missing permission
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One follower count per account per day.
CREATE TABLE account_daily_stats (
social_account_id UUID NOT NULL REFERENCES social_accounts (id) ON DELETE CASCADE,
day DATE NOT NULL,
followers BIGINT NOT NULL,
PRIMARY KEY (social_account_id, day)
);
A "target" is one post on one account, so a post sent to Instagram and Facebook has two rows. We keep only the latest snapshot per target, not a history: the chart groups by the day a post was published, so it never needs yesterday's numbers.
NULL means "not reported", not zero
Networks disagree about what they share. Some report views, some don't; LinkedIn only reports stats for company Pages. If we stored a missing metric as 0, a network that hides views would drag the engagement rate down and look like it's failing.
So every metric is a pointer in Go and a nullable column in Postgres:
// PostStats is an engagement snapshot. Nil means the platform doesn't report it.
type PostStats struct {
Views, Likes, Comments, Shares *int64
}
The frontend drops a metric chip entirely when it's null, and the views tile shows a dash instead of a zero.
Poll by post age
Numbers move fast in the first days and barely at all after a month. Each network client that can report stats implements one small interface, and the worker asks the database which targets are due:
metricsCadence = repository.PollCadence{
FreshFor: 3 * 24 * time.Hour, Fresh: 3 * time.Hour, // first 3 days: every 3 hours
Stale: 24 * time.Hour, // then daily
MaxAge: 30 * 24 * time.Hour, // then never
}
WHERE pt.status = 'published'
AND pt.published_at > now() - $2::interval -- MaxAge
AND (pm.fetched_at IS NULL OR pm.fetched_at < now() -
CASE WHEN pt.published_at > now() - $3::interval -- FreshFor
THEN $4::interval ELSE $5::interval END) -- Fresh / Stale
ORDER BY pm.fetched_at NULLS FIRST, pt.published_at DESC
LIMIT $6
One indexed query replaces a scheduler per post. The worker runs every 5 minutes, takes up to 100 due targets and fetches 8 at a time with a 30-second timeout each, so one slow network doesn't stall the rest. Several replicas can run it without locks: the worst case is a duplicate fetch, and saves are upserts.
Keep the last good numbers
Tokens expire and permissions get revoked. When a fetch fails we record the error but keep the last good numbers, so a post's views don't drop to nothing because a token lapsed:
ON CONFLICT (post_target_id) DO UPDATE SET
views = CASE WHEN $8 = '' THEN EXCLUDED.views ELSE post_metrics.views END,
-- likes, comments, shares the same way
error = EXCLUDED.error, fetched_at = now()
Followers: one row per day
Follower counts are checked every 12 hours and upserted on (social_account_id, current_date), so the second check of a day just corrects the first. A failed check only moves followers_checked_at, so we don't retry a broken account in a tight loop. The audience sparklines and the "+6,660 in 30d" figure come straight from these rows.
The chart doesn't skip quiet days
The daily series comes from generate_series left-joined to that day's published targets, so days with no posts are real zeros on the chart instead of missing bars that squeeze the axis.
Ranges and plans
7 and 30 days are on every plan. Longer ranges (90 days, 1 year) are part of the Grow add-on; the API checks entitlements before running the query, and the UI shows a lock on those buttons instead of an error.
If you're building something similar
- Poll by age. Most engagement happens in the first few days.
- Let the database decide what's due.
- Store "not reported" as NULL and let the UI leave it out.
- On a failed fetch, keep the last good numbers and record why.
Analytics is part of every VeloxonHub Social plan, with a 7-day free trial: https://social.veloxonhub.com. Happy to go into the per-network quirks in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.