Skip to content

View Metrics Analytics API

Flow ID: AD-60 | Module(s): ecommercen/view_metrics/ | Complexity: Low Last Updated: 2026-06-28

Business Context

A small, self-contained subsystem that counts impressions and hits (clicks) for two object types only — slide (homepage/campaign slider slides) and stream (video showcase). It is not a general product/CMS view tracker. The storefront emits events via a JS IntersectionObserver to a public, sessionless, unauthenticated recorder endpoint; an admin-login-gated endpoint returns aggregated totals plus a computed CTR.

It is entirely legacy CI3 HMVC — no modern src/Domains/src/Rest equivalent. A notable wrinkle: the admin "totals API" endpoint has no known consumer — in-product display of metrics happens via direct viewMetricTotals() model calls in the slider/campaign/video admin controllers, bypassing the HTTP endpoint entirely (see Known Issues).

API Reference

REST Endpoints (Modern Layer)

None. Both endpoints are plain CI3 routes returning JSON via ApiEndpointTrait; they are not in application/config/rest_policies.php and do not use the modern REST/JWT middleware.

Legacy Routes

application/config/routes.php:580-581:

EndpointMethodAuthPurpose
view_metrics/api/recordview_metrics/api_view_metrics_recorder/indexPOST (JSON)none (public, session-excluded)Records impressions/hits
view_metrics_admin/api/viewview_metrics/api_view_metrics_view_metric_totals_admin/indexGET (query params)admin login only (no role)Returns aggregated totals + CTR

Recorder (POST /view_metrics/api/record): body { "impressions": [[type,id],...], "hits": [[type,id],...] } read via jsonDecodeInputStream() (ecommercen/eshop/traits/ApiEndpointTrait.php:8-11). Validates both arrays present, each type in viewMetricsAvailableObjectTypes, each id numeric (recorder.php:114-125,134-176). Returns 200 empty on success, 400 on validation failure, 500 on model exception (trace only in dev/testing). Session-excluded at application/config/session_excludes.php:11.

Totals (GET /view_metrics_admin/api/view): optional params — objects ([type,id] pairs; absent → all), date_from/date_to (parsed :66-78, invalid silently → null), sort (default hits), order (default desc), limit (default 10), offset (default 0). Returns JSON keyed by object_id, each {object_type, object_id, hits, impressions, ctr}.

Code Flow

Storefront element with data-ovm attr (theme_helper.php:528-534 observableViewMetric)
  └─ observable-view-metrics.js  (IntersectionObserver)
       ├─ tick() every 1000ms -> batch in-viewport impressions   :24-38
       └─ first click/touchstart -> hits   :40-57
            └─ POST /view_metrics/api/record {impressions[],hits[]}
                 └─ Adv_api_view_metrics_recorder::index()  recorder.php:34-67  (public, no auth/CSRF/throttle)
                      └─ Adv_view_metrics_model::recordMetrics()  model.php:27-87
                           ├─ gate: registry VIEW_METRICS/ENABLED   :29-31
                           ├─ filter recordable types + dedup pairs   :34-42
                           ├─ find latest non-expired bucket per object (self-join)   :194-221
                           └─ insert new buckets (:291-309) or CASE-WHEN batch increment (:321-385)

Admin slider/campaign/video pages
  └─ direct call viewMetricTotals(...)   (NOT via HTTP) e.g. Adv_slide_admin.php:55-62, AdvVideoShowcaseAdmin.php:99-104
       └─ Adv_view_metrics_model::viewMetricTotals()  model.php:102-142
            ├─ applyMetricsInRangeQuery: SELECT object_type,object_id,SUM(hits),SUM(impressions) GROUP BY object_type,object_id   :235-264
            ├─ backfill zero rows for requested objects   :122-131
            └─ ctr = round(hits/impressions, 2)   :136-138

Data Model

Reads/writes exactly one table, view_metrics (database/initial/initial.sql:2421-2433); defined in initial.sql only (no Phinx migration):

ColumnTypeNotes
object_typechar(32)polymorphic type (slide/stream)
object_idint(11)generic id, no FK to slide/video
sincedatetimebucket start; part of PK
expires_afterchar(6)ISO duration (P1D/P1W/P1M/P1Y)
hitsint(11)click counter
impressionsint(11)impression counter

PK (object_type, object_id, since) — multiple rows per object over time (one per non-expired period bucket). Four covering indexes (object_hits, object_impressions, object_hits_since, object_impressions_since). No surrogate PK, no FKs (orphan rows persist after the referenced slide/video is deleted).

Domain Layer

Modern Domain / REST (src/)

None. No src/Domains/*/ViewMetric*, no src/Rest/*/ViewMetric*, no Entity/Repository/Service/Resource. Legacy-only.

Legacy Layer (ecommercen/)

FileResponsibility
ecommercen/view_metrics/controllers/Adv_api_view_metrics_view_metric_totals_admin.php:1-79Admin totals endpoint (index() :35-57, parseDateFromInput() :66-78); extends Admin_c.
ecommercen/view_metrics/controllers/Adv_api_view_metrics_recorder.php:1-177Public recorder (index() :34-67, validation :86-176); extends MX_Controller.
ecommercen/view_metrics/models/Adv_view_metrics_model.php:1-414recordMetrics() :27-87, viewMetricTotals() :102-142, getObjectExpiration() :145-150, wipeMetricsBefore() :152-160 (dead), applyMetricsInRangeQuery() :235-264, addNewMetricRecords() :291-309, updateExistingMetricRecords() :321-385, hasRecordExpired() :396-401, isObjectTypeRecordable() :409-413.
application/modules/view_metrics/controllers/Api_view_metrics_*.phpEmpty HMVC binding subclasses (route targets).
ecommercen/helpers/theme_helper.php:528-534observableViewMetric($type,$id) — emits the data-ovm write-side instrumentation.

Write-side instrumentation & front-end: assets/main/js/es6/components/observable-view-metrics.js:1-106; activated by importing the module in assets/main/js/es6/config.js:2,14-15. RBAC base: Admin_cAdv_admin_controller (ecommercen/core/Adv_admin_controller.php:35-58, login gate only).

Actual metric display (direct model calls, not the API): ecommercen/sliders/controllers/Adv_slide_admin.php:55-62,332-347; ecommercen/campaigns/controllers/Adv_campaigns_admin.php:43-60; ecommercen/sliders/controllers/AdvApiSlideshowGroupsEditorAdmin.php:215; ecommercen/video/controllers/AdvVideoShowcaseAdmin.php:59,99-104 (the only server-side recordMetrics() caller, for stream).

Configuration

application/config/app.php:

  • viewMetricsAvailableObjectTypes = ['slide','stream'] (:509-512) — the authoritative scope (no products, no CMS).
  • viewMetricsAvailablePeriods = ['P1D'=>'day','P1W'=>'week','P1M'=>'month','P1Y'=>'year'] (:514-520).

Registry group VIEW_METRICS (written by ecommercen/settings/controllers/Adv_settings.php:1893-1908, read by the model): ENABLED, PERIOD, and per-type <TYPE>_ENABLED / <TYPE>_PERIOD. The recorder is gated by VIEW_METRICS.ENABLED (model.php:29); expiration resolves <TYPE>_PERIODPERIODFALLBACK_PERIOD='P1D' (model.php:18,145-150). Front-end activation is opt-in via the JS module import. No .env flag.

Client Extension Points

application/modules/view_metrics/controllers/Api_view_metrics_*.php are the empty override seams (both extends Adv_api_view_metrics_*).

Business Rules

  1. Scope = slide + stream only — enforced by viewMetricsAvailableObjectTypes (app.php:509-512) at both record-validation (recorder.php) and isObjectTypeRecordable() (model.php:409-413).
  2. Period bucketing — counters accumulate into a per-object bucket until since + expires_after passes; then a new bucket is opened (hasRecordExpired() :396-401, addNewMetricRecords() :291-309).
  3. CTR computed in PHPround(hits / impressions, 2), 0 when impressions = 0 (model.php:136-138).
  4. Time filter on since — a bucket is included if its since falls in [date_from, date_to] (model.php:246-251).
  5. Backfill on explicit object listviewMetricTotals returns zero-rows for requested objects with no data, but only when an explicit $objects list is passed (:122-131).
  6. Recorder gated by VIEW_METRICS.ENABLEDrecordMetrics() no-ops when the flag is falsy (:29-31).

Known Issues & Security Gaps

  1. Admin totals API has no known consumerview_metrics_admin/api/view is referenced only in routes.php:581; no Vue/JS/admin view calls it. All real display goes through direct viewMetricTotals() model calls (see Domain Layer). The endpoint exists but is effectively dead.
  2. Recorder is fully public — no rate limiting, CSRF, or bot filteringrecorder extends MX_Controller (:7), session-excluded (session_excludes.php:11). Any client can POST arbitrary impression/hit batches for any valid slide/stream id → counter inflation / metrics poisoning. Contrast the REST LoginThrottle.
  3. wipeMetricsBefore() is dead code → unbounded table growthmodel.php:152-160 has no caller and no cron; view_metrics accumulates one row per object per period bucket forever with no retention.
  4. recordMetrics undefined-index pathmodel.php:34,71-72,82-83 read $data['hits']/$data['impressions'] unconditionally. The HTTP path is guarded by recorder validation, but the model is called directly (AdvVideoShowcaseAdmin.php:99); a partial payload omitting one key triggers a warning/TypeError.
  5. object_id-only result keying collides across typesviewMetricTotals keys results by object_id only (model.php:120, helper shopmodule_helper.php:705-718). A slide and a stream sharing the same numeric id overwrite each other in the returned map. Safe today only because callers query one type at a time.
  6. No sort whitelist on the totals endpointsort/order come straight from query params (totals_admin.php:40-41) into $db->order_by() (model.php:255-257); CI escapes identifiers but there is no allow-list (recommend hits|impressions|object_id).
  7. No pagination caplimit is unbounded (totals_admin.php:42-45); default 10 but no max clamp.
  8. Recorder fails silently in production — model exceptions are caught and returned as an empty 500 with no log_message (recorder.php:55-65); operators get no signal when recording breaks.
  9. RBAC granularity mismatch — the totals endpoint is readable by any logged-in admin (Adv_admin_controller.php:35), while the VIEW_METRICS config is only_advisable-flagged in settings (AD-13); read access is broader than config access.
  10. expires_after char(6) truncation risk — a custom ISO duration longer than 6 chars (e.g. P1Y1M1D) silently truncates. Not exploitable with current config values.
  11. No FK / no orphan cleanup — rows survive deletion of the referenced slide/video (no FK, no deletion event), accumulating stale analytics.
  12. date_from/date_to parse errors silently ignoredparseDateFromInput() swallows exceptions and returns null (totals_admin.php:66-78), so a malformed date becomes "no filter" rather than a 400.

Tests

None. No ViewMetric*Test or recorder/model test exists (verified glob). The bucketing/expiry logic (hasRecordExpired, insert-vs-update branching) and the hand-rolled CASE WHEN batch UPDATE (updateExistingMetricRecords:321-385) are untested — and exactly the area that regressed and was patched in 2025 (the duplicate-update fix, git e2497dd6f7).