Appearance
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:
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
view_metrics/api/record → view_metrics/api_view_metrics_recorder/index | POST (JSON) | none (public, session-excluded) | Records impressions/hits |
view_metrics_admin/api/view → view_metrics/api_view_metrics_view_metric_totals_admin/index | GET (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-138Data Model
Reads/writes exactly one table, view_metrics (database/initial/initial.sql:2421-2433); defined in initial.sql only (no Phinx migration):
| Column | Type | Notes |
|---|---|---|
object_type | char(32) | polymorphic type (slide/stream) |
object_id | int(11) | generic id, no FK to slide/video |
since | datetime | bucket start; part of PK |
expires_after | char(6) | ISO duration (P1D/P1W/P1M/P1Y) |
hits | int(11) | click counter |
impressions | int(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/)
| File | Responsibility |
|---|---|
ecommercen/view_metrics/controllers/Adv_api_view_metrics_view_metric_totals_admin.php:1-79 | Admin totals endpoint (index() :35-57, parseDateFromInput() :66-78); extends Admin_c. |
ecommercen/view_metrics/controllers/Adv_api_view_metrics_recorder.php:1-177 | Public recorder (index() :34-67, validation :86-176); extends MX_Controller. |
ecommercen/view_metrics/models/Adv_view_metrics_model.php:1-414 | recordMetrics() :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_*.php | Empty HMVC binding subclasses (route targets). |
ecommercen/helpers/theme_helper.php:528-534 | observableViewMetric($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_c → Adv_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>_PERIOD → PERIOD → FALLBACK_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
- Scope =
slide+streamonly — enforced byviewMetricsAvailableObjectTypes(app.php:509-512) at both record-validation (recorder.php) andisObjectTypeRecordable()(model.php:409-413). - Period bucketing — counters accumulate into a per-object bucket until
since + expires_afterpasses; then a new bucket is opened (hasRecordExpired():396-401,addNewMetricRecords():291-309). - CTR computed in PHP —
round(hits / impressions, 2),0when impressions = 0 (model.php:136-138). - Time filter on
since— a bucket is included if itssincefalls in[date_from, date_to](model.php:246-251). - Backfill on explicit object list —
viewMetricTotalsreturns zero-rows for requested objects with no data, but only when an explicit$objectslist is passed (:122-131). - Recorder gated by
VIEW_METRICS.ENABLED—recordMetrics()no-ops when the flag is falsy (:29-31).
Known Issues & Security Gaps
- Admin totals API has no known consumer —
view_metrics_admin/api/viewis referenced only inroutes.php:581; no Vue/JS/admin view calls it. All real display goes through directviewMetricTotals()model calls (see Domain Layer). The endpoint exists but is effectively dead. - Recorder is fully public — no rate limiting, CSRF, or bot filtering —
recorder extends MX_Controller(:7), session-excluded (session_excludes.php:11). Any client can POST arbitrary impression/hit batches for any validslide/streamid → counter inflation / metrics poisoning. Contrast the RESTLoginThrottle. wipeMetricsBefore()is dead code → unbounded table growth —model.php:152-160has no caller and no cron;view_metricsaccumulates one row per object per period bucket forever with no retention.recordMetricsundefined-index path —model.php:34,71-72,82-83read$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.object_id-only result keying collides across types —viewMetricTotalskeys results byobject_idonly (model.php:120, helpershopmodule_helper.php:705-718). Aslideand astreamsharing the same numeric id overwrite each other in the returned map. Safe today only because callers query one type at a time.- No sort whitelist on the totals endpoint —
sort/ordercome 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 (recommendhits|impressions|object_id). - No pagination cap —
limitis unbounded (totals_admin.php:42-45); default 10 but no max clamp. - 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. - RBAC granularity mismatch — the totals endpoint is readable by any logged-in admin (
Adv_admin_controller.php:35), while theVIEW_METRICSconfig isonly_advisable-flagged in settings (AD-13); read access is broader than config access. 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.- No FK / no orphan cleanup — rows survive deletion of the referenced slide/video (no FK, no deletion event), accumulating stale analytics.
date_from/date_toparse errors silently ignored —parseDateFromInput()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).
Related Flows
- AD-13 Settings — the
VIEW_METRICSregistry keys and theironly_advisableflag. - AD-30 Video Management — the
streamobject type; server-siderecordMetrics()caller. - AD-21 Slider Management — the
slideobject type; displays totals via direct model calls. - SY-33 Bot Detection & Session Exclusion — why the recorder is session-excluded.
- CF-26 Home Page / CF-01 Product Browsing — storefront context where
slideimpressions/hits originate.