When I started building Analytics for ICanUp, I did not want to simply add another third-party script to the browser and send visitor behavior to an external analytics service.
I needed more specific answers: which Posts, Pages, Series, News, and Tips are viewed, in which locale, which countries traffic comes from, which devices and browsers are used, where visits originate, and how those patterns change by day and hour.
At the same time, Analytics should not become a raw request archive.
The result is a server-side system built around two main ideas: persist aggregates instead of an endless raw event history, and persist bounded dimensions instead of arbitrary request values.
Analytics Starts by Defining a Real Public View
Even server-side Analytics can count incorrectly if every request increments a counter.
A public page can produce prefetches, Inertia partial reloads, or infinite-scroll requests. Administrators may open the same content as well. These are technical requests, but they should not automatically become audience views.
PublicContentViewTracker therefore owns an explicit eligibility boundary: GET only, no prefetch, no partial request, no infinite-scroll merge, and no admin traffic.
private function isPublicViewRequest( Request $request,): bool { if ( ! $request->isMethod('GET') || $request->prefetch() ) { return false; } if ( $request->headers->has( Header::PARTIAL_COMPONENT ) || $request->headers->has( Header::PARTIAL_ONLYOne Measurement Goes Through a Shared Analytics Pipeline
Once a request qualifies as a real public view, the tracker does not write directly into a Posts-specific analytics table.
It builds a generic AnalyticsMeasurement containing the metric, subject, locale, additional dimensions, and occurrence time.
Content currently has two core metrics: views and deduplicated_views. Both use the same dimension model so the dashboard can compare them across the same breakdowns.
$measurement = new AnalyticsMeasurement( metric: ContentMetricEnum::VIEWS ->metric(), dimensions: AnalyticsDimensions::forSubject( $subject, $locale, $this ->requestDimensions ->forRequest($request), ), occurredAt: CarbonImmutable::now(),Content Type Is a Dimension Too
I did not create a separate analytics history model for every public content type.
Subject | Entity identity |
|---|---|
Home | No separate entity |
Category | Category ID |
Post | Post ID |
Page | Page ID |
Series | Post Series ID |
News | News ID |
Tip | Tip ID |
A new content type does not require a new Analytics architecture. It becomes another supported subject in the same model.
Dimensions Are Enriched Before Persistence
The URL or raw request itself is not my analytics model.
The request passes through enrichment before persistence, leaving only normalized dimensions needed for reporting.
These include locale, country code, device type, browser family, OS family, viewer type, Human/Bot, traffic channel, traffic source, and referral domain.
Some dimensions can be unavailable. I do not invent a value the system could not determine reliably.
Public page view | vEligibility boundary | vRequest enrichment | +-- locale +-- subject type +-- entity identity +-- country +-- device type +-- browser family +-- OS family +-- viewer typePrivacy-safe Analytics does not mean collecting everything. It means defining the questions first and persisting only the bounded signals needed to answer them.
Daily Aggregates Became the Historical Base
The dashboard does not need millions of raw page-view events just to recalculate totals repeatedly.
The main historical layer is stored in analytics_daily_aggregates. A bucket is defined by the module, metric, reporting date, and canonical dimensions.
This lets the same aggregation model produce daily trends, locale breakdowns, content-type breakdowns, specific content history, and other views.
analytics_daily_aggregates modulemetricanalytics_datelocalesubject_typeentity_identity_id_hashadditional_dimensionsdimensions_hashvaluecreated_atupdated_atI am not repeating the concurrent bucket increment implementation here. It is covered separately in the dedicated article about atomic additive upserts.
Hourly History Does Not Replace Daily History
When I needed to understand traffic distribution throughout the day, I did not replace or distort the daily model.
I added analytics_hourly_aggregates beside it. The table preserves the same metric and dimension contract while adding an hourly bucket.
Daily history remains useful for longer periods. Hourly history answers another question: when during the day did activity happen?
analytics_hourly_aggregates modulemetric reporting_datereporting_hour visitor_local_datevisitor_local_hour localesubject_typeentity_identity_id_hash Reporting Time and Visitor Local Time Are Different Time Axes
The hourly table has reporting_date/reporting_hour and separate visitor_local_date/visitor_local_hour fields.
Reporting time gives the dashboard one stable system timeline. A visitor-local bucket is available only when a privacy-safe UTC-offset signal exists.
I do not treat country as a substitute for timezone. Country and visitor-local time are different dimensions with different reliability.
Core and Additional Dimensions Work Together
Not every dimension has the same structural importance.
Date, hour, locale, subject type, and entity identity have dedicated database columns. Other normalized dimensions live in additional_dimensions.
The aggregation repository hides that distinction from the application query. To the caller, country, browser, or traffic source are still normal Analytics dimensions.
This lets the analytics model expand without creating another history table for every new breakdown.
return match ( $dimension->value()) { AnalyticsDimensionKey::DATE => $query->granularity() === AnalyticsGranularity::DAILY ? 'analytics_date' : ( $query->timeBasis() === AnalyticsTimeBasis::REPORTING_TIME ? 'reporting_date' : 'visitor_local_date' ), AnalyticsDimensionKey::HOUR => $query->timeBasis()One Query Layer Reads Daily and Hourly Aggregates
The dashboard does not own persistence details for each report.
AnalyticsAggregationQuery contains the metric, granularity, time basis, filters, and group-by dimensions. The repository uses that contract to select daily or hourly history.
The same mechanism can calculate totals, trends, content types, locales, Countries, Devices, Browsers, OS families, traffic channels, traffic sources, and referral domains.
$builder = $this->connection ->table( $query->granularity() === AnalyticsGranularity::DAILY ? 'analytics_daily_aggregates' : 'analytics_hourly_aggregates' ) ->where( 'module', $query ->metric() ->module() ->value(), ) ->where( 'metric',Bounded Dimensions Matter More Than Maximum Data Collection
Generic Analytics history does not persist raw request identity as long-term history.
For example, referral analytics stores a normalized external domain rather than the full Referer URL with its path and query. Country is stored as a normalized country code when a trusted geography signal is available. Device, Browser, and OS are stored as families or categories rather than arbitrary raw headers.
I also avoid repeating the Human/Bot and crawler-family implementation here. That boundary is covered in the dedicated article about crawler families without storing raw User-Agent strings.
Data Coverage Is Part of the Truth Too
If country is known for only part of the Views, displaying a percentage as if coverage were 100% would be misleading.
A breakdown therefore returns not only rows but also coverage: population total, known views, unavailable views, and the coverage ratio.
The same applies to dimensions introduced later. Older records can honestly remain without the newer dimension.
Analytics Failure Must Not Break a Public Page
Another architectural boundary is that Analytics is supporting functionality.
If enrichment or persistence fails temporarily, a public Post or Page should not become unavailable.
PublicContentViewTracker catches the failure, logs a warning, and returns control to the caller. The tests explicitly verify that valid public content remains available when Analytics fails.
What the System Can Answer Without a Third-Party Tracker
Using the same aggregate history, the Admin Analytics dashboard can answer several different questions:
- How many Views and Deduplicated views occurred in a period?
- How did views change by day?
- At which hours did activity occur?
- Which content types receive the most views?
- Which specific Posts, Pages, Series, News, or Tips are being viewed?
- What is the locale distribution?
- For what portion of traffic is country data available?
- Which Devices, Browsers, and OS families are used?
- Which traffic channels and normalized sources bring visitors?
- Which external referral domains send traffic?
None of those reports requires a separate raw event log of its own.
The Architecture Contract I Kept
- Define the real Analytics event first.
- Do not mix technical requests with public views.
- Build a generic measurement instead of content-specific counters.
- Use the same dimensions for Views and Deduplicated views.
- Persist long-term history as aggregates.
- Daily and hourly granularities answer different questions.
- Do not invent hourly or dimension data for older history.
- Persist bounded normalized dimensions instead of raw request values.
- Expose coverage when a dimension is unavailable for part of the population.
- Analytics failure must not break public content.
Custom Analytics became useful not when I collected more data, but when every persisted signal had a clear purpose and a clear privacy boundary.
Conclusion
Analytics in ICanUp gradually grew from a simple view counter into its own reporting architecture.
A public request passes an eligibility boundary, becomes an AnalyticsMeasurement, receives bounded dimensions, and is persisted into daily and hourly aggregate history. The query layer then uses that same history for totals, trends, content, audience, and traffic breakdowns.
The most important part for me is what this architecture does not require: storing every raw request just in case it becomes useful later, or sending the entire audience behavior stream to a third-party browser tracker.
Privacy is not an extra dashboard option here. It starts in the data model and in the decision about what is worth persisting at all.



