Skip to content
by skunxicat

Signals #5: Slicing CloudFront Logs

TL;DR: Composable shell functions for slicing CloudFront access logs locally. Filter bots from humans, identify LLM traffic, visualize in the terminal with goaccess, export JSON to Grafana. About 60% of traffic is bots.

Who actually reads your blog?

CloudFront Log Analysis

CloudFront access logs are rich — every request with IP, user-agent, status, content-type, referrer, timing. But they’re also noisy. Bots, crawlers, health checks, prefetch requests — the signal-to-noise ratio is terrible.

This signal builds composable shell filters that let you slice CloudFront logs by any dimension, visualize in the terminal with goaccess, and export JSON datasets to Grafana.

The workflow: Sync logs → Filter → Explore (terminal) → Export (JSON) → Upload (S3) → Dashboard (Grafana)


The Setup

source scripts/goaccess.sh
traffic_sync

Logs sync from S3 to a local directory. Everything runs locally — fast iteration, no cost per query, full history.

Composable Filters

The script decomposes log analysis into small, pipeable functions:

_logs ()      { ( cd "$LOGDIR" && gzcat ${CFID}.*.gz ); }
_since ()     { awk -v since="${SINCE:-0}" -v to="${TO:-9}" '$1 >= since && $1 <= to'; }
_html ()      { awk '$30 ~ /text\/html/ { print }'; }
_no_bots ()   { awk -v bot="$BOT_UA" '$11 !~ bot { print }'; }
_ua ()        { awk -v ua="$1" '$11 ~ ua { print }'; }
_referrer ()  { awk -v ref="$1" '$10 ~ ref { print }'; }
_uri ()       { awk -v uri="$1" 'index($8, uri) == 1 { print }'; }

Each function operates on CloudFront’s tab-delimited log format:

  • Field $1 — date
  • Field $8 — URI path
  • Field $10 — referrer
  • Field $11 — user-agent
  • Field $30 — content-type

A shared _goaccess function handles the common flags:

_goaccess () {
  goaccess - \
    --log-format=CLOUDFRONT \
    --date-format=%Y-%m-%d \
    --time-format=%H:%M:%S \
    --no-query-string \
    --ignore-panel NOT_FOUND \
    --ignore-panel REQUESTS_STATIC \
    --exclude-ip 203.0.113.10 \
    --exclude-ip 198.51.100.42 \
    "$@"
}

Exclude your own IPs. Ignore 404s and static assets. Pass "$@" for output format override.

Traffic Functions

Each public function composes filters and pipes to goaccess:

Google Search Traffic

traffic_search () {
  _logs \
    | _referrer 'https?://(www\.)?google\.' \
    | _no_bots \
    | _html \
    | _since \
    | _goaccess --ignore-crawlers --unknowns-as-crawlers --ignore-status=404 "$@"
}

Only HTML pages. Only from Google. No bots. Date-filtered.

Human Traffic (All Sources)

traffic_non_bot () {
  _logs | _no_bots | _html | _since \
    | _goaccess --ignore-crawlers --unknowns-as-crawlers "$@"
}

LLM Consumers

traffic_llm () {
  _logs | _ua '(ChatGPT-User|Claude-User)' | _since \
    | _goaccess --ignore-status=404 "$@"
}

ChatGPT-User and Claude-User are real-time retrieval agents — they fetch pages when a user asks a question that references your content. Not crawlers. Consumers.

Specific URI Prefix

traffic_uri () {
  local uri="${1:-/}"
  [[ $# -gt 0 ]] && shift
  _logs | _no_bots | _html | _uri "$uri" | _since | _goaccess "$@"
}

traffic_uri /log/ — only blog posts.

Arbitrary User-Agent

traffic_ua () {
  local ua="$1"; shift
  _logs | _ua "$ua" | _since | _goaccess "$@"
}

traffic_ua "GPTBot" — OpenAI’s crawler specifically.

Available Functions

FunctionWhat it shows
traffic_syncSync logs from S3
traffic_allAll HTML traffic
traffic_searchGoogle organic
traffic_non_botAll human visitors
traffic_uri /path/Specific path prefix
traffic_ua "pattern"Specific user-agent
traffic_llmChatGPT-User + Claude-User
traffic_claudeClaude-User only
traffic_gpt_userChatGPT-User only
traffic_gpt_botGPTBot (crawler)
traffic_oai_botsOAI-SearchBot + OAI-AdsBot
traffic_metaMeta external agent

Terminal → JSON → Grafana

Every function accepts "$@" pass-through to goaccess. This is the key:

Terminal exploration:

traffic_search

Interactive goaccess dashboard in the terminal. Navigate with arrow keys. Real-time analysis.

JSON export:

traffic_search -o json | tee /tmp/search-traffic.json

Structured JSON output: visitors, requests, referrers, countries, time distribution.

Upload to S3:

upload_dataset /tmp/search-traffic.json

Now served via CloudFront as a static JSON dataset.

Grafana: Point Infinity datasource at the URL. Parse with JQ. Dashboard.

Date Filtering

SINCE="2026-06-01" TO="2026-06-25" traffic_search

Environment variables control the date range. Defaults: SINCE=2026-06-01, TO=today.

Bot Classification

The BOT_UA pattern catches the common ones:

BOT_UA='(bot|crawler|spider|crawl|slurp|scanner|...|ChatGPT-User|Claude-User|...)'

But note: ChatGPT-User and Claude-User are in the bot list for traffic_non_bot (which excludes them) but have their own dedicated functions (traffic_llm, traffic_claude, etc.) because they represent a distinct, valuable traffic category.

Understanding LLM consumption of your content is a signal in itself:

  • Which pages do LLMs fetch most?
  • When do they fetch? (Correlates with user questions about your topics)
  • What’s the ratio of LLM vs human traffic?

What I’ve Learned

From running this on cloudless.sh:

  • ~60% of traffic is bots. Even for a technical blog. Without filtering, your analytics are meaningless.
  • LLM traffic is growing. Claude-User and ChatGPT-User now represent measurable traffic — your content is being consumed in chat interfaces.
  • Google organic is the signal. Everything else is noise for a content site. Filter to Google referrers to see what actually gets discovered.
  • URI-specific analysis reveals winners. A few posts drive 80% of organic traffic. Focus there.

The Pattern

S3 Logs → Local Sync → Shell Filters → goaccess (terminal or JSON) → S3 → Grafana

Local files, shell pipes, goaccess for both interactive exploration and structured export.

The same composable pattern as every other signal: produce a dataset, upload as static JSON, visualize elsewhere.

Tools Used

ToolPurpose
goaccessLog analysis + terminal visualization + JSON export
awkField-level filtering on CloudFront log format
gzcatDecompress gzipped log files
upload_dataset (lib.sh)S3 upload
Grafana InfinityDashboard visualization

Part 5 of the Signals series — small systems that observe larger systems.

Previous: Signals #4: Mapping the Terraform Module Ecosystem | Next: Signals #6: Build a Visibility Layer