0% прочитано

Laravel Analytics Without Google Analytics: Privacy-Safe Daily and Hourly Aggregates

How ICanUp runs its own server-side Analytics without a third-party browser tracker: a public-view boundary, views and deduplicated views, daily and hourly history, bounded dimensions, Countries, Devices, Browsers, Traffic Sources, and a privacy boundary without raw request history.

26 вересня 2026 р. 11 хв читанняLaravel

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.

This is not an attempt to rebuild all of Google Analytics. I collect only the dimensions and metrics the product actually needs and that I can explain, test, and control.

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.

PHP - Public view eligibility boundary
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_ONLY

One 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.

PHP - Build one Content Analytics measurement
$measurement = new AnalyticsMeasurement(    metric:        ContentMetricEnum::VIEWS            ->metric(),     dimensions:        AnalyticsDimensions::forSubject(            $subject,            $locale,            $this                ->requestDimensions                ->forRequest($request),        ),     occurredAt:        CarbonImmutable::now(),
Deduplicated views are not an exact person count. They are a separate metric produced by applying a defined deduplication rule to repeated views.

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.

TEXT - Request enrichment before aggregate persistence
Public page view    |    vEligibility boundary    |    vRequest enrichment    |    +-- locale    +-- subject type    +-- entity identity    +-- country    +-- device type    +-- browser family    +-- OS family    +-- viewer type

Privacy-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.

SQL - Daily aggregate shape
analytics_daily_aggregates modulemetricanalytics_datelocalesubject_typeentity_identity_id_hashadditional_dimensionsdimensions_hashvaluecreated_atupdated_at

I 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?

SQL - Hourly aggregate shape
analytics_hourly_aggregates modulemetric reporting_datereporting_hour visitor_local_datevisitor_local_hour localesubject_typeentity_identity_id_hash 
I do not convert older daily-only history into invented hourly values. If hourly data was not collected at that time, it remains unavailable.

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.

PHP - Core dimensions resolve to dedicated columns
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.

PHP - Choose aggregate history from query granularity
$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.

Operational diagnostics and long-term Analytics history are not the same thing. Extra signals needed for a specific technical investigation should not automatically become ordinary Analytics dimensions.

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

  1. Define the real Analytics event first.
  2. Do not mix technical requests with public views.
  3. Build a generic measurement instead of content-specific counters.
  4. Use the same dimensions for Views and Deduplicated views.
  5. Persist long-term history as aggregates.
  6. Daily and hourly granularities answer different questions.
  7. Do not invent hourly or dimension data for older history.
  8. Persist bounded normalized dimensions instead of raw request values.
  9. Expose coverage when a dimension is unavailable for part of the population.
  10. 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.