From bd17a5aef081153fca881f711b986c1adde7617f Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 14 Oct 2026 06:55:06 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(matomo):=20TIGER-306=20=E2=80=94=20Mat?= =?UTF-8?q?omo=20tracking=20adapter=20(first-party=20analytics=20+=20affil?= =?UTF-8?q?iate=20attribution)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new `matomo` module, sibling to `analytics` (GA4): a config-backed, consent-gated self-hosted Matomo tracker that also reads the `/a/` affiliate cookie into a Matomo visit-scoped custom dimension so visits + conversions are segmentable by affiliate. - Registers `matomo` in `Tiger_Tracking` (category analytics; coexists with ga4). - `Matomo_Plugin_Tag` emits the `_paq` snippet into the `tigerTracking` head placeholder, gated by `Tiger_Consent::allows('analytics')`, staff-excludable, fail-open. Auto-assigns its front-controller stackIndex (analytics owns 92 — a duplicate index is fatal at boot). - Affiliate: reads the `tiger_aff` contract cookie, validates the name, and pushes `setCustomDimension(, )` before trackPageView. This module READS the cookie; TigerAffiliates (TIGER-303) WRITES it and owns the `/a/` route. - Admin Settings screen (`/matomo/admin`, admin-only) + `/api` save to the config tier (url, site_id, affiliate dimension index, enabled, exclude-signed-in). 7 locales. - Tests: 14 integration tests (plugin snippet/gating/affiliate-dimension/latch + controller). Proven live on tiger-dev against the dev Matomo (analytics-dev.webtigers.com): guest pages emit the snippet; `tiger_aff=jane` sets dimension 1 before the pageview; invalid cookie ignored; admin route gates to login; admin view renders. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- modules/matomo/AGENTS.md | 76 +++++++ modules/matomo/Bootstrap.php | 66 ++++++ modules/matomo/assets/js/matomo.settings.js | 56 +++++ modules/matomo/configs/acl.ini | 29 +++ .../matomo/controllers/AdminController.php | 50 +++++ modules/matomo/forms/Settings.php | 43 ++++ modules/matomo/languages/de/matomo.php | 41 ++++ modules/matomo/languages/en/matomo.php | 41 ++++ modules/matomo/languages/es/matomo.php | 41 ++++ modules/matomo/languages/fr/matomo.php | 41 ++++ modules/matomo/languages/hi/matomo.php | 41 ++++ modules/matomo/languages/pt/matomo.php | 41 ++++ modules/matomo/languages/tlh/matomo.php | 42 ++++ modules/matomo/module.json | 5 + modules/matomo/plugins/Tag.php | 181 ++++++++++++++++ modules/matomo/services/Matomo.php | 67 ++++++ .../matomo/views/scripts/admin/index.phtml | 79 +++++++ .../Matomo/AdminControllerTest.php | 32 +++ tests/Integration/Matomo/TagPluginTest.php | 201 ++++++++++++++++++ 19 files changed, 1173 insertions(+) create mode 100644 modules/matomo/AGENTS.md create mode 100644 modules/matomo/Bootstrap.php create mode 100644 modules/matomo/assets/js/matomo.settings.js create mode 100644 modules/matomo/configs/acl.ini create mode 100644 modules/matomo/controllers/AdminController.php create mode 100644 modules/matomo/forms/Settings.php create mode 100644 modules/matomo/languages/de/matomo.php create mode 100644 modules/matomo/languages/en/matomo.php create mode 100644 modules/matomo/languages/es/matomo.php create mode 100644 modules/matomo/languages/fr/matomo.php create mode 100644 modules/matomo/languages/hi/matomo.php create mode 100644 modules/matomo/languages/pt/matomo.php create mode 100644 modules/matomo/languages/tlh/matomo.php create mode 100644 modules/matomo/module.json create mode 100644 modules/matomo/plugins/Tag.php create mode 100644 modules/matomo/services/Matomo.php create mode 100644 modules/matomo/views/scripts/admin/index.phtml create mode 100644 tests/Integration/Matomo/AdminControllerTest.php create mode 100644 tests/Integration/Matomo/TagPluginTest.php diff --git a/modules/matomo/AGENTS.md b/modules/matomo/AGENTS.md new file mode 100644 index 0000000..b0971bc --- /dev/null +++ b/modules/matomo/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md — working on the `matomo` module + +> **Step 0 — check core before you build.** Before writing any mechanism, grep +> [`../../CAPABILITIES.md`](../../CAPABILITIES.md) — a generated, CI-checked index of every `Tiger_*` class and module. +> Core probably already has it. **Assume a capability exists until you have grepped the index and confirmed it doesn't.** + +Instructions for an AI assistant (or a new contributor) working on Tiger's **matomo** module. For +platform conventions read the root **AGENTS.md** (`read.guide` with no module). This file is the +module-specific layer; match the surrounding style. + +> First-party **self-hosted Matomo** integration — the "paste your Matomo URL + site id" tracker done +> the Tiger way: config-backed (no deploy), consent-aware (`Tiger_Consent`), provider-extensible +> (`Tiger_Tracking`). It emits the Matomo `_paq` snippet on public pages AND reads the `/a/` +> affiliate cookie into a Matomo **visit-scoped custom dimension** so affiliate traffic is segmentable. +> This module is the **sibling of `analytics`** (GA4) — same shape, same registry, and **both can be +> active at once**. + +## The one thing to know: this module only TALKS to Matomo, it is not Matomo + +Matomo is a **separate GPLv3 application** (its own install at e.g. `analytics.webtigers.com`, its own +PHP pool + DB). This BSD-3 module is **never** vendored into it and never embeds its code — it only +speaks Matomo's client-side tracking API (`_paq`) and (future) its HTTP Reporting API. Keep that wall. + +## Where things live (no models of its own) + +- `Bootstrap.php` — registers the tag plugin (stackIndex 92), declares `matomo` to `Tiger_Tracking`, + and lists the screen under the admin **Settings** tree (`Tiger_Admin_Settings`). +- `plugins/Tag.php` — **`Matomo_Plugin_Tag`**, the `_paq` emitter (the whole runtime). +- `controllers/AdminController.php` — `/matomo/admin` (settings), admin-only. +- `services/Matomo.php` — `Matomo_Service_Matomo::save` (writes the config tier). `forms/Settings.php`. +- `views/scripts/admin/index.phtml` + `assets/js/matomo.settings.js` (the save wiring). +- **No `models/`** — settings live in `Tiger_Model_Config` (the live-override tier). + +## Config keys (all `tiger.matomo.*`, GLOBAL scope today) + +- `enabled` (bool) · `url` (the Matomo base URL, trailing-slashed) · `site_id` (idsite, int) · + `exclude_signed_in` (bool, default on) · `affiliate.dimension_id` (the visit-scoped custom-dimension + **index**, default 1). + +## The affiliate cookie contract (TIGER-306 ↔ TIGER-303) + +- **`Matomo_Plugin_Tag::AFFILIATE_COOKIE` = `tiger_aff`** holds the affiliate **name**; the plugin reads + it, validates it against `AFFILIATE_NAME_RE` (`[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}`), and pushes + `setCustomDimension` **before** `trackPageView`. The value is JSON-encoded before it reaches the page. +- **This module READS the cookie; it never WRITES it and never owns the `/a/` route.** Writing the + cookie (and validating the name against real affiliate records, recording the click, stamping the + account) is **TigerAffiliates**' job (TIGER-303). When that module lands it must write **this same + cookie name**. Until then the dimension is simply never set (set the cookie by hand to test the read). + +## Conventions + gotchas (this module) + +- **The tag rides a front-controller plugin** (`Matomo_Plugin_Tag`, stackIndex 92, `preDispatch`, + emit-once) appending the `_paq` snippet to the `tigerTracking` head placeholder — which **only the + public layout renders**, so admin traffic is never tagged. It emits only when enabled + a valid + `http(s)` URL + a positive site id + `Tiger_Consent::allows('analytics')` (TRUE until the cookie + feature sets a mode, so Matomo works standalone) + not excluding signed-in staff. **Fail-open** — a + broken lookup never breaks the page render. +- **`isConfigured()` is the registry's `active` signal** — enabled AND url AND site id. The URL + id are + validated in the plugin before ever being emitted (don't trust the stored config shape). +- **The two switches (`enabled`, `exclude_signed_in`) are NOT form elements** — the form validates only + the URL / site id / dimension index; the switches come straight off `$params`. +- **Config scope is GLOBAL today**, isolated in `_scope()` (multi-site flips to per-org later — same as + the analytics module). +- **Everything is `class_exists`-guarded** against a Core predating `Tiger_Tracking`/`Tiger_Admin_Settings` + — the module still loads (degraded) on older cores. + +## ACL + +admin: `Matomo_AdminController`, `Matomo_Service_Matomo` (both in `configs/acl.ini`, deny-by-default). + +## Do / Don't + +- **Do** keep tagging consent-gated + fail-open; **do** validate the URL/site id before emitting. +- **Don't** write the `/a/` cookie or add an `/a/:name` route here — that's TigerAffiliates. +- **Don't** emit the tag into admin/auth layouts (they don't render `tigerTracking` — keep it that way). +- **Don't** embed or vendor any Matomo code — HTTP/`_paq` only. diff --git a/modules/matomo/Bootstrap.php b/modules/matomo/Bootstrap.php new file mode 100644 index 0000000..929f231 --- /dev/null +++ b/modules/matomo/Bootstrap.php @@ -0,0 +1,66 @@ + cookie + * — the contract TigerAffiliates writes — into a Matomo visit-scoped custom dimension). This module + * owns only the client-side tracker + its settings screen; the cookie-consent banner is a separate + * feature, and the Matomo server install is a separate app (GPLv3) reached only over HTTP. + * + * It is a SIBLING of the analytics (GA4) module — both register as `analytics`-category trackers and + * both can be active at once; a site can run GA4 and Matomo together, each consent-gated. + * + * Extending Zend_Application_Module_Bootstrap gives the module its resource autoloader, so + * Matomo_Service_* / Matomo_Form_* / Matomo_Plugin_* load by convention; controllers load via the + * registered module dir; configs/acl.ini + languages/ are picked up by the core globs. + */ +class Matomo_Bootstrap extends Zend_Application_Module_Bootstrap +{ + /** + * Contribute the Matomo tag to the head (config-driven, consent-gated). No explicit stackIndex — + * the analytics (GA4) sibling already holds 92, and the Zend plugin broker throws (fatally, during + * the module scan) on a duplicate index; order is irrelevant here since the plugin only appends to a + * head placeholder, so we let the broker auto-assign a free slot. + */ + protected function _initMatomoTag() + { + Zend_Controller_Front::getInstance()->registerPlugin(new Matomo_Plugin_Tag()); + } + + /** + * Declare Matomo to the tracking registry so the cookie-consent feature's "auto" mode can tell that + * tracking is present (and pop the banner). Active = enabled AND a valid Matomo URL + site id are set. + * Guarded so the module still loads on a Core that predates Tiger_Tracking. + */ + protected function _initMatomoTracker() + { + if (!class_exists('Tiger_Tracking')) { + return; + } + Tiger_Tracking::register('matomo', [ + 'label' => 'Matomo', + 'category' => 'analytics', + 'active' => Matomo_Plugin_Tag::isConfigured(), + ]); + } + + /** List Matomo under the admin Settings tree (ACL-gated to Matomo_AdminController). */ + protected function _initAdminSettings() + { + if (!class_exists('Tiger_Admin_Settings')) { + return; + } + Tiger_Admin_Settings::register([ + 'key' => 'matomo', + 'label' => 'matomo.nav.label', + 'icon' => 'fa-chart-simple', + 'href' => '/matomo/admin', + 'resource' => 'Matomo_AdminController', + 'order' => 41, + ]); + } +} diff --git a/modules/matomo/assets/js/matomo.settings.js b/modules/matomo/assets/js/matomo.settings.js new file mode 100644 index 0000000..fba9522 --- /dev/null +++ b/modules/matomo/assets/js/matomo.settings.js @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: BSD-3-Clause + * Copyright (c) 2026 WebTigers. + * + * Matomo settings — save wiring. Posts the form to /api (matomo/matomo/save) through the house UI + * primitives: TigerButton.run for the busy state, TigerDOM.notify for feedback, res.form for inline + * field errors. The two switches (enabled, exclude_signed_in) ride along as checkbox values. No + * page-POST, no + + + +
+ +
+ + +
+
+ +
+
t('matomo.card.connection') ?>
+
+
+ enabled ? ' checked' : '' ?>> + +
+
+ + +
t('matomo.url_help') ?>
+
+
+ + +
t('matomo.site_id_help') ?>
+
+
+
+ +
+
t('matomo.card.affiliate') ?>
+
+

t('matomo.affiliate_help') ?>

+
+ + +
t('matomo.affiliate_dimension_help') ?>
+
+
+
+ +
+
t('matomo.card.privacy') ?>
+
+
+ excludeSignedIn ? ' checked' : '' ?>> + +
+
t('matomo.exclude_staff_help') ?>
+

t('matomo.privacy_note') ?>

+
+
+ +
+
+
+pageScript($this->asset('/_modules/matomo/js/matomo.settings.js')); diff --git a/tests/Integration/Matomo/AdminControllerTest.php b/tests/Integration/Matomo/AdminControllerTest.php new file mode 100644 index 0000000..fb3e4b7 --- /dev/null +++ b/tests/Integration/Matomo/AdminControllerTest.php @@ -0,0 +1,32 @@ +loginAs('admin'); + $this->dispatchAction(Matomo_AdminController::class, 'index', [], 'GET'); + + $view = $this->controller()->view; + $this->assertStringContainsString('Matomo', (string) $view->title); + $this->assertInstanceOf(\Matomo_Form_Settings::class, $view->form); + $this->assertFalse($view->enabled, 'unconfigured → disabled'); + $this->assertTrue($view->excludeSignedIn, 'exclude signed-in staff defaults on'); + } +} diff --git a/tests/Integration/Matomo/TagPluginTest.php b/tests/Integration/Matomo/TagPluginTest.php new file mode 100644 index 0000000..41cb44d --- /dev/null +++ b/tests/Integration/Matomo/TagPluginTest.php @@ -0,0 +1,201 @@ +` contract cookie (present / absent / invalid / custom index); + * and the emit-once latch. + * + * The plugin has a process-wide emit-once latch (`$_done`); each test resets it via reflection. + */ +#[CoversClass(Matomo_Plugin_Tag::class)] +final class TagPluginTest extends IntegrationTestCase +{ + private Zend_View $view; + + protected function setUp(): void + { + parent::setUp(); + $this->view = new Zend_View(); + Zend_Registry::set('Zend_View', $this->view); + // Placeholder containers are a process-wide singleton — clear the one this plugin writes. + $this->view->placeholder('tigerTracking')->exchangeArray([]); + unset($_COOKIE[Matomo_Plugin_Tag::AFFILIATE_COOKIE]); + $this->resetLatch(); + } + + protected function tearDown(): void + { + unset($_COOKIE[Matomo_Plugin_Tag::AFFILIATE_COOKIE]); + $this->resetLatch(); + parent::tearDown(); + } + + /** Reset the plugin's emit-once static so each test starts clean. */ + private function resetLatch(): void + { + $p = new ReflectionProperty(Matomo_Plugin_Tag::class, '_done'); + $p->setValue(null, false); + } + + private function config(array $matomo): void + { + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['matomo' => $matomo]], true)); + } + + private function tracking(): string + { + return (string) $this->view->placeholder('tigerTracking'); + } + + // ----- url / siteId / isConfigured ---------------------------------------------------------- + + #[Test] + public function is_not_configured_when_disabled(): void + { + $this->config(['enabled' => '0', 'url' => 'https://a.example.com/', 'site_id' => '1']); + $this->assertFalse(Matomo_Plugin_Tag::isConfigured(), 'disabled → not configured even with url + id'); + } + + #[Test] + public function url_is_empty_for_an_invalid_url(): void + { + $this->config(['enabled' => '1', 'url' => 'not a url', 'site_id' => '1']); + $this->assertSame('', Matomo_Plugin_Tag::url(), 'a non-http(s) url is rejected'); + $this->assertFalse(Matomo_Plugin_Tag::isConfigured()); + } + + #[Test] + public function is_not_configured_without_a_positive_site_id(): void + { + $this->config(['enabled' => '1', 'url' => 'https://a.example.com/', 'site_id' => '0']); + $this->assertSame(0, Matomo_Plugin_Tag::siteId()); + $this->assertFalse(Matomo_Plugin_Tag::isConfigured()); + } + + #[Test] + public function url_is_normalized_with_a_trailing_slash(): void + { + $this->config(['enabled' => '1', 'url' => 'https://a.example.com', 'site_id' => '3']); + $this->assertSame('https://a.example.com/', Matomo_Plugin_Tag::url(), 'no trailing slash → one is added'); + $this->assertSame(3, Matomo_Plugin_Tag::siteId()); + $this->assertTrue(Matomo_Plugin_Tag::isConfigured()); + } + + // ----- preDispatch emission ----------------------------------------------------------------- + + #[Test] + public function predispatch_appends_the_snippet_when_configured_for_a_guest(): void + { + $this->config(['enabled' => '1', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + + $out = $this->tracking(); + $this->assertStringContainsString("u=\"https:\\/\\/mat.example.com\\/\"", $out, 'the tracker base url is emitted'); + $this->assertStringContainsString("u+'matomo.js'", $out, 'the matomo.js loader is emitted'); + $this->assertStringContainsString("_paq.push(['setSiteId', '7'])", $out, 'the site id is emitted'); + $this->assertStringContainsString("_paq.push(['trackPageView'])", $out); + } + + #[Test] + public function predispatch_emits_nothing_when_unconfigured(): void + { + $this->config(['enabled' => '0', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + $this->assertSame('', $this->tracking(), 'disabled → nothing in the placeholder'); + } + + #[Test] + public function predispatch_skips_signed_in_staff_when_exclusion_is_on(): void + { + $this->config(['enabled' => '1', 'exclude_signed_in' => '1', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + $this->loginAs('admin'); + + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + $this->assertSame('', $this->tracking(), 'staff traffic is not tagged'); + } + + #[Test] + public function predispatch_tags_signed_in_staff_when_exclusion_is_off(): void + { + $this->config(['enabled' => '1', 'exclude_signed_in' => '0', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + $this->loginAs('admin'); + + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + $this->assertStringContainsString("setSiteId', '7'", $this->tracking(), 'exclusion off → staff tagged'); + } + + // ----- affiliate custom dimension (the /a/ contract cookie) ---------------------------- + + #[Test] + public function affiliate_dimension_is_set_from_the_cookie_before_pageview(): void + { + $this->config(['enabled' => '1', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + $_COOKIE[Matomo_Plugin_Tag::AFFILIATE_COOKIE] = 'jane'; + + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + + $out = $this->tracking(); + $this->assertStringContainsString("_paq.push(['setCustomDimension', 1, \"jane\"])", $out, 'default dimension index 1 holds the affiliate name'); + $this->assertLessThan( + strpos($out, "trackPageView"), + strpos($out, 'setCustomDimension'), + 'the dimension is set BEFORE the pageview' + ); + } + + #[Test] + public function affiliate_dimension_honors_a_configured_index(): void + { + $this->config(['enabled' => '1', 'url' => 'https://mat.example.com/', 'site_id' => '7', 'affiliate' => ['dimension_id' => '4']]); + $_COOKIE[Matomo_Plugin_Tag::AFFILIATE_COOKIE] = 'bob'; + + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + $this->assertStringContainsString("_paq.push(['setCustomDimension', 4, \"bob\"])", $this->tracking()); + } + + #[Test] + public function no_affiliate_dimension_without_the_cookie(): void + { + $this->config(['enabled' => '1', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + $this->assertStringNotContainsString('setCustomDimension', $this->tracking(), 'no cookie → no dimension'); + } + + #[Test] + public function an_invalid_affiliate_name_is_ignored(): void + { + $this->config(['enabled' => '1', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + $_COOKIE[Matomo_Plugin_Tag::AFFILIATE_COOKIE] = 'not valid"; evil()'; + + (new Matomo_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple()); + $this->assertStringNotContainsString('setCustomDimension', $this->tracking(), 'an unsafe cookie value is refused'); + } + + #[Test] + public function the_emit_once_latch_prevents_a_second_append(): void + { + $this->config(['enabled' => '1', 'url' => 'https://mat.example.com/', 'site_id' => '7']); + $plugin = new Matomo_Plugin_Tag(); + $plugin->preDispatch(new Zend_Controller_Request_Simple()); + $plugin->preDispatch(new Zend_Controller_Request_Simple()); + $this->assertSame(1, substr_count($this->tracking(), "u+'matomo.js'"), 'the snippet is appended exactly once'); + } +} From e74c02a810b1e826ab296b3d557d34966e8f60ed Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 14 Oct 2026 07:02:04 -0400 Subject: [PATCH 2/2] chore(matomo): regenerate CAPABILITIES.md for the new module Adds the matomo module to the generated capability index (CI `check` gate). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index b8d89fb..54bdaf6 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**222 classes** across **33 capabilities** · **23 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**222 classes** across **33 capabilities** · **24 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -342,6 +342,7 @@ - **Code** (`code`, developer) · services: Code · `modules/code` - **Comments** (`comment`, app) · services: Comment, Render, Settings, Subjects · `modules/comment` - **Identity** (`identity`, plugin) · services: Identity · `modules/identity` +- **Matomo** (`matomo`, app) · services: Matomo · `modules/matomo` - **Mcp** (`mcp`, module) · services: Settings · `modules/mcp` - **Media** (`media`, plugin) · services: Media, Settings · `modules/media` - **Messages** (`message`, app) · services: Message · `modules/message`