A static website is wonderfully uneventful. There is no application server waiting to be patched, no database connection pool to exhaust and no public API that must remain awake around the clock. A browser asks for files; a content delivery network returns them.
That simplicity raised an interesting question for this portfolio: could I show where the site is being read and how request traffic changes over time without turning the site into a dynamic application—or publishing information that should remain private?
The answer became a small data pipeline built from CloudFront access logs, CloudWatch, Lambda, EventBridge and a single JSON object in S3. Visitors see a world map, country totals, popular pages and request-rate charts. They never receive raw logs, IP addresses, user agents or individual browsing histories.
The website remains static. The data is prepared elsewhere and arrives as just another static file.
The architecture in one request path
The public request path remains deliberately short:
- A visitor requests the site through Amazon CloudFront.
- CloudFront serves cached files whose private origin is an S3 bucket.
- The browser later fetches
content/public-visitors.jsonlike any other static asset. - Client-side JavaScript renders the map and charts from that anonymous document.
The browser does not invoke Lambda. It does not query CloudWatch. It has no AWS credentials and cannot ask for a different time range or a more detailed record. All expensive and sensitive work occurs off the public request path.
Behind the scenes, the data path looks like this:
CloudFront → CloudWatch Logs → scheduled Lambda → aggregated JSON → S3 → CloudFront → browser
This separation is the key architectural decision. The operational system can work with detailed access data; the publishing system emits only the small subset intended for public display.
Collect once, publish less
CloudFront standard logging version 2 delivers selected access-log fields to a CloudWatch Logs group. The operational log contains enough information to investigate requests and status codes, but that does not mean all of it belongs on a public dashboard.
The publishing function deliberately reduces the data to aggregates:
- successful HTML page views grouped by country code;
- the most requested HTML paths;
- requests per minute derived from aggregated HTTP status metrics;
- an update timestamp and labels describing each time range.
The generated file contains no source IP addresses, city-level locations, cookies, user-agent strings or sequences that identify one visitor’s journey. The public result answers “what traffic did the site receive?” rather than “who visited?”
This is also why I use the phrase page views, not people. One reader can create many requests. Search crawlers, link-preview services, monitoring probes and other automation can appear alongside human traffic. The dashboard presents infrastructure signals, not identities.
Why CloudWatch sits in the middle
CloudWatch provides two useful representations of the same activity. CloudWatch Logs Insights can query structured request records for country and page aggregates. Metric filters count the four HTTP status families—2xx, 3xx, 4xx and 5xx—so CloudWatch metric data can produce request-rate series without repeatedly parsing every log event.
The public dataset offers several views:
- country totals for the previous 24 hours, 7 days and 30 days;
- request-rate points for 24 hours, 7 days, 30 days and 12 months;
- coarser time buckets for longer ranges, keeping the document compact.
CloudFront logs are not a real-time event stream. AWS notes that standard logs are generally delivered within an hour, while some entries can occasionally arrive later. That makes them well suited to an honest retrospective dashboard, not a live visitor counter.
A scheduled snapshot instead of a public API
An EventBridge rule invokes the publishing Lambda every six hours. The function runs its bounded queries, requests the metric series, assembles the document and overwrites exactly one S3 key: content/public-visitors.json.
Six hours is intentionally conservative. Visitor analytics do not need second-by-second freshness, and each refresh scans log data. A shorter interval would create more query executions and higher cost without making the page meaningfully more useful.
The JSON object is served with revalidation-oriented cache headers. The homepage can therefore discover a newer snapshot while CloudFront still protects the S3 origin. No full site deployment and no global cache invalidation are required when the statistics change.
This snapshot model has a pleasant failure mode. If a query or Lambda invocation fails, the existing static document remains available. The dashboard may become stale, but the portfolio stays online and no request waits for the analytics pipeline.
Least privilege keeps the blast radius small
The publishing Lambda uses a dedicated IAM role. Its relevant permissions are intentionally narrow:
- run and retrieve queries only against the designated access-log group;
- read the CloudWatch metrics required for the request-rate chart;
- write only
content/public-visitors.jsonin the site bucket; - write its own execution logs.
It cannot deploy arbitrary site files, alter the CloudFront distribution or expose the S3 bucket. EventBridge receives permission to invoke this specific function from the specific scheduled rule.
The site bucket itself remains private. CloudFront uses Origin Access Control to send signed requests to S3, which prevents viewers from bypassing the distribution and treating the bucket as a public file server.
The public map is intentionally less precise
A more detailed dashboard is not automatically a better public dashboard. City markers look impressive, but small counts combined with a precise timestamp and a known person can become identifying. Country-level totals are enough to tell the global story of the site without pretending that infrastructure telemetry proves where a specific reader lives.
The visual design reinforces that limitation. The map shows intensity by country and a ranking, while an explanation directly below it states that requests are signals rather than people. The interface does not offer drill-down into individual events.
That distinction matters because polished charts can create false certainty. An IP-derived country is an observation made by an edge platform. VPNs, corporate networks, mobile carriers, crawlers and cached previews all complicate the human story behind it.
Cost follows scan volume, not visual complexity
The map and charts are rendered in the browser with code already served by the static site, so their visual complexity does not create a server-side rendering bill. The meaningful operational cost comes from collecting and retaining logs, ingesting metrics and scanning log data with scheduled queries.
The controls are therefore architectural rather than cosmetic:
- a 30-day log-retention policy prevents indefinite growth;
- the function runs only four times per day;
- queries request bounded time windows and only required fields;
- longer chart ranges use coarser metric periods;
- the published JSON file is tiny compared with the source logs.
Lambda execution time and S3 storage for one compact document are negligible at this scale. Logs Insights scan volume is the component worth watching if traffic or retention grows substantially.
What the implementation taught me
Static does not mean blind. Observability can live beside a static site without moving into its synchronous request path.
Aggregation is a security boundary. The transformation from operational logs to a public document should be explicit, reviewed and one-way.
Fresh enough is better than falsely real-time. A six-hour snapshot matches the delayed nature of standard access logs and avoids needless work.
Failure should preserve the previous truth. A stale snapshot is preferable to a homepage that fails because an analytics service is unavailable.
Explain the limits next to the chart. A public visualization needs context about bots, repeat requests, geographic inference and the difference between traffic and people.
When I would choose a different design
This pattern works well for a personal site with moderate traffic and a small number of predetermined views. I would reconsider it if users needed arbitrary queries, authenticated dashboards, second-level freshness or large multidimensional datasets. Those requirements might justify an API, a dedicated analytics store or a managed visualization platform.
For this portfolio, those additions would solve a problem I do not have. One scheduled function and one static document preserve the qualities that made the site attractive in the first place: a small attack surface, predictable behaviour and components that are easy to reason about.
Final takeaway
The most important part of this visitor dashboard is not the world map. It is the boundary behind it.
Detailed request data remains in the operational environment. A least-privileged process periodically converts it into anonymous aggregates. The website receives only a compact static snapshot, and the browser turns that snapshot into a story.
That gives the site a pulse without giving it a vulnerable dynamic heart.
BERND KUHLENAll notes ↗