Signals #8: Measuring Publication
TL;DR: Emit publication events from CI when articles go live, use them as Grafana annotations, and measure the propagation chain: publish -> Google crawl -> index -> LLM retrieval -> human click. The publish timestamp is the causal anchor for every other signal.
The moment you publish is the most important timestamp in your analytics.
Every other signal in this series — crawler activity, LLM retrieval, search impressions — is only meaningful relative to when the content appeared. Without that anchor, you’re looking at activity without cause.
This signal adds the anchor.
The Annotation
The vertical dashed lines you see across every time-series panel in the dashboard are Grafana annotations built from publication events. Each line marks the exact moment an article went live or was updated.
This is the simplest possible form of the insight: overlay the cause onto the effect. You don’t need a SQL join to see whether crawler activity spiked after a publish — you can read it directly off the chart. The annotation lands, and you watch what moves.
The crawler panel shows Google-InspectionTool appearing within hours of a publish annotation. The retrieval panel shows whether ChatGPT-User follows. The search impressions panel shows the lag to GSC visibility. All on the same timeline, all anchored to the same T₀.
The Publication Event
When a commit lands on main, the CI pipeline deploys the site then emits a publication event for each changed article:
# .github/workflows/deploy.yml
- name: Publish events
env:
EVENTS_TOPIC_ARN: ${{ vars.EVENTS_TOPIC_ARN }}
run: |
source scripts/publish.sh
publish_diff "$GITHUB_SHA"
publish_diff detects which .md files changed in the commit, checks for published: true in frontmatter, and emits one event per article. New files get article_published. Modified files get article_updated. Drafts are silently skipped.
{
"schema": "cloudless.publication_event.v1",
"site": "cloudless",
"type": "publication",
"publication": {
"event_type": "article_updated",
"url": "https://cloudless.sh/log/serverless-analytics-pipeline/",
"path": "/log/serverless-analytics-pipeline/",
"title": "Serverless Analytics Pipelines with Terraform",
"source": "github-actions",
"git_sha": "abc123...",
"changed_file": "app/src/content/log/serverless-analytics-pipeline/index.md"
},
"event": {
"id": "uuid",
"timestamp": "2026-07-08T07:23:22.000Z"
},
"received_at": "2026-07-08T07:23:22.000Z"
}
No Lambda, no API Gateway. aws sns publish from the CI runner directly to the same SNS topic as web events. The OIDC role already has the credentials — SNS publish permission is the only addition needed.
The event lands in S3 via Firehose with dynamic partitioning:
raw-data/site=cloudless/type=publication/year=2026/month=07/day=08/
The Same Analytics Context
Publication events, CloudFront logs, and GSC data all live in the same Athena database. The path field is the join key.
Note: GSC data is imported separately via the Search Console API on a daily schedule. That pipeline isn’t covered in this series — treat it as an external data source that lands in the same Athena database with a
~2 daylag.
This means every downstream signal — crawler hits, retrieval events, search impressions — can be correlated with the publication timestamp that preceded it. The annotation in Grafana is the visual version of that join. The SQL is for when you want the precise numbers.
Grafana annotation query — reads publication events from S3 via Athena and renders each one as a vertical line across all time-series panels:
SELECT
to_unixtime(cast(received_at AS timestamp)) * 1000 AS time,
publication.event_type AS text,
publication.title AS tags
FROM publication_events
WHERE year >= '2026'
ORDER BY received_at
Each row becomes an annotation: the timestamp positions the line, event_type labels it (article_published vs article_updated), title appears on hover.
The Propagation Chain
With the anchor in place, the measurable chain is:
T₀ — received_at in the publication event. The moment the article went live.
T₁ — First Google-InspectionTool hit in cloudfront_requests for that path. Google’s inspection tool typically precedes Googlebot proper.
T₂ — First Googlebot crawl of that path.
T₃ — First GSC impression (from gsc_pages_by_dates joined on URL).
T₄ — First ChatGPT-User retrieval hit — a user asking ChatGPT something that pulls the content.
T₅ — First human click from search.
SELECT
p.publication.path,
p.publication.event_type,
cast(p.received_at AS timestamp) AS t0_published,
min(case when c.crawler = 'Google-InspectionTool' then c.ts end) AS t1_inspection,
min(case when c.crawler = 'Googlebot' then c.ts end) AS t2_googlebot,
min(case when c.access_type = 'retrieval'
and c.agent = 'openai' then c.ts end) AS t4_chatgpt_retrieval,
date_diff('hour',
cast(p.received_at AS timestamp),
min(case when c.crawler = 'Googlebot' then c.ts end)) AS hours_to_googlebot
FROM publication_events p
LEFT JOIN cloudfront_requests c
ON c.path = p.publication.path
AND c.ts > cast(p.received_at AS timestamp)
WHERE p.year >= '2026'
AND p.publication.event_type = 'article_published'
GROUP BY 1, 2, 3
ORDER BY t0_published DESC
What the Dashboard Already Shows
The pipeline is running. The Publication tab shows article_updated and article_published events flowing in real time, timestamped to the second, with path and title intact.
The annotations appear on the crawler and retrieval panels the moment a commit lands on main. You can watch Google-InspectionTool arrive in the hours after. You can see whether a ChatGPT-User retrieval follows in the same window.
The search impressions panels use a -2d timeshift because GSC data lags by two days — but the annotation is still there, and the lift is visible once the data catches up.
The Backfill
For articles published before this pipeline existed, publish_all emits events for everything with published: true:
source scripts/publish.sh
publish_all --dry-run # preview
publish_all # emit
Backfill events have source: local and no git_sha. They populate the annotation timeline historically — useful for seeing which older articles eventually attracted retrieval traffic, even without a precise T₀.
Part 8 of the Signals series — small systems that observe larger systems.
Previous: Signals #7: From Logs to Signals