From e019d90b07aabe57f2fbf6ec09c3fb8d1c620fbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 11:46:44 +0000 Subject: [PATCH 1/4] Add Matomo Tag Manager as third analytics tracking mode Adds Matomo Tag Manager support alongside existing Google Analytics and Piwik Pro integrations. Includes settings.json configuration (url + tag), build-time script injection via hook-analytics.py, Klaro GDPR consent banner integration, and runtime consent granting via MTM data layer API. https://claude.ai/code/session_0165AXHkmRZ6bx23n7Tbyz8h --- gdpr_consent/dist/bundle.js | 2 +- gdpr_consent/src/main.ts | 10 ++++++++++ hooks/hook-analytics.py | 22 ++++++++++++++++++++++ settings.json | 5 +++++ src/common/captcha_.py | 5 +++-- src/common/common.py | 17 +++++++++++++++++ 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/gdpr_consent/dist/bundle.js b/gdpr_consent/dist/bundle.js index 2d2d8142b..861445734 100644 --- a/gdpr_consent/dist/bundle.js +++ b/gdpr_consent/dist/bundle.js @@ -235,7 +235,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! streamlit-component-lib */ \"./node_modules/streamlit-component-lib/dist/index.js\");\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nvar __generator = (undefined && undefined.__generator) || function (thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;\n return g = { next: verb(0), \"throw\": verb(1), \"return\": verb(2) }, typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n};\n\n// Defines the configuration for Klaro\nvar klaroConfig = {\n mustConsent: true,\n acceptAll: true,\n services: []\n};\n// This will make klaroConfig globally accessible\nwindow.klaroConfig = klaroConfig;\n// Function to safely access the Klaro manager\nfunction getKlaroManager() {\n var _a;\n return ((_a = window.klaro) === null || _a === void 0 ? void 0 : _a.getManager) ? window.klaro.getManager() : null;\n}\n// Waits until Klaro Manager is available\nfunction waitForKlaroManager() {\n return __awaiter(this, arguments, void 0, function (maxWaitTime, interval) {\n var startTime, klaroManager;\n if (maxWaitTime === void 0) { maxWaitTime = 5000; }\n if (interval === void 0) { interval = 100; }\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0:\n startTime = Date.now();\n _a.label = 1;\n case 1:\n if (!(Date.now() - startTime < maxWaitTime)) return [3 /*break*/, 3];\n klaroManager = getKlaroManager();\n if (klaroManager) {\n return [2 /*return*/, klaroManager];\n }\n return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, interval); })];\n case 2:\n _a.sent();\n return [3 /*break*/, 1];\n case 3: throw new Error(\"Klaro manager did not become available within the allowed time.\");\n }\n });\n });\n}\n// Helper function to handle unknown errors\nfunction handleError(error) {\n if (error instanceof Error) {\n console.error(\"Error:\", error.message);\n }\n else {\n console.error(\"Unknown error:\", error);\n }\n}\n// Tracking was accepted\nfunction callback() {\n return __awaiter(this, void 0, void 0, function () {\n var manager, return_vals, _i, _a, service, error_1;\n return __generator(this, function (_b) {\n switch (_b.label) {\n case 0:\n _b.trys.push([0, 2, , 3]);\n return [4 /*yield*/, waitForKlaroManager()];\n case 1:\n manager = _b.sent();\n if (manager.confirmed) {\n return_vals = {};\n for (_i = 0, _a = klaroConfig.services; _i < _a.length; _i++) {\n service = _a[_i];\n return_vals[service.name] = manager.getConsent(service.name);\n }\n streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentValue(return_vals);\n }\n return [3 /*break*/, 3];\n case 2:\n error_1 = _b.sent();\n handleError(error_1);\n return [3 /*break*/, 3];\n case 3: return [2 /*return*/];\n }\n });\n });\n}\n// Stores if the component has been rendered before\nvar rendered = false;\nfunction onRender(event) {\n // Klaro does not work if embedded multiple times\n if (rendered) {\n return;\n }\n rendered = true;\n var data = event.detail;\n if (data.args['google_analytics']) {\n klaroConfig.services.push({\n name: 'google-analytics',\n cookies: [\n /^_ga(_.*)?/ // we delete the Google Analytics cookies if the user declines its use\n ],\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n if (data.args['piwik_pro']) {\n klaroConfig.services.push({\n name: 'piwik-pro',\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n // Create a new script element\n var script = document.createElement('script');\n // Set the necessary attributes\n script.defer = true;\n script.type = 'application/javascript';\n script.src = 'https://cdn.kiprotect.com/klaro/v0.7/klaro.js';\n // Set the klaro config\n script.setAttribute('data-config', 'klaroConfig');\n // Append the script to the head or body\n document.head.appendChild(script);\n}\n// Attach our `onRender` handler to Streamlit's render event.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.events.addEventListener(streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.RENDER_EVENT, onRender);\n// Tell Streamlit we're ready to start receiving data. We won't get our\n// first RENDER_EVENT until we call this function.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentReady();\n// Finally, tell Streamlit to update the initial height.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setFrameHeight(1000);\n\n\n//# sourceURL=webpack://gdpr_consent/./src/main.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! streamlit-component-lib */ \"./node_modules/streamlit-component-lib/dist/index.js\");\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nvar __generator = (undefined && undefined.__generator) || function (thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;\n return g = { next: verb(0), \"throw\": verb(1), \"return\": verb(2) }, typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n};\n\n// Defines the configuration for Klaro\nvar klaroConfig = {\n mustConsent: true,\n acceptAll: true,\n services: []\n};\n// This will make klaroConfig globally accessible\nwindow.klaroConfig = klaroConfig;\n// Function to safely access the Klaro manager\nfunction getKlaroManager() {\n var _a;\n return ((_a = window.klaro) === null || _a === void 0 ? void 0 : _a.getManager) ? window.klaro.getManager() : null;\n}\n// Waits until Klaro Manager is available\nfunction waitForKlaroManager() {\n return __awaiter(this, arguments, void 0, function (maxWaitTime, interval) {\n var startTime, klaroManager;\n if (maxWaitTime === void 0) { maxWaitTime = 5000; }\n if (interval === void 0) { interval = 100; }\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0:\n startTime = Date.now();\n _a.label = 1;\n case 1:\n if (!(Date.now() - startTime < maxWaitTime)) return [3 /*break*/, 3];\n klaroManager = getKlaroManager();\n if (klaroManager) {\n return [2 /*return*/, klaroManager];\n }\n return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, interval); })];\n case 2:\n _a.sent();\n return [3 /*break*/, 1];\n case 3: throw new Error(\"Klaro manager did not become available within the allowed time.\");\n }\n });\n });\n}\n// Helper function to handle unknown errors\nfunction handleError(error) {\n if (error instanceof Error) {\n console.error(\"Error:\", error.message);\n }\n else {\n console.error(\"Unknown error:\", error);\n }\n}\n// Tracking was accepted\nfunction callback() {\n return __awaiter(this, void 0, void 0, function () {\n var manager, return_vals, _i, _a, service, error_1;\n return __generator(this, function (_b) {\n switch (_b.label) {\n case 0:\n _b.trys.push([0, 2, , 3]);\n return [4 /*yield*/, waitForKlaroManager()];\n case 1:\n manager = _b.sent();\n if (manager.confirmed) {\n return_vals = {};\n for (_i = 0, _a = klaroConfig.services; _i < _a.length; _i++) {\n service = _a[_i];\n return_vals[service.name] = manager.getConsent(service.name);\n }\n streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentValue(return_vals);\n }\n return [3 /*break*/, 3];\n case 2:\n error_1 = _b.sent();\n handleError(error_1);\n return [3 /*break*/, 3];\n case 3: return [2 /*return*/];\n }\n });\n });\n}\n// Stores if the component has been rendered before\nvar rendered = false;\nfunction onRender(event) {\n // Klaro does not work if embedded multiple times\n if (rendered) {\n return;\n }\n rendered = true;\n var data = event.detail;\n if (data.args['google_analytics']) {\n klaroConfig.services.push({\n name: 'google-analytics',\n cookies: [\n /^_ga(_.*)?/ // we delete the Google Analytics cookies if the user declines its use\n ],\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n if (data.args['piwik_pro']) {\n klaroConfig.services.push({\n name: 'piwik-pro',\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n if (data.args['matomo']) {\n klaroConfig.services.push({\n name: 'matomo',\n purposes: ['analytics'],\n onAccept: callback,\n onDecline: callback,\n });\n }\n // Create a new script element\n var script = document.createElement('script');\n // Set the necessary attributes\n script.defer = true;\n script.type = 'application/javascript';\n script.src = 'https://cdn.kiprotect.com/klaro/v0.7/klaro.js';\n // Set the klaro config\n script.setAttribute('data-config', 'klaroConfig');\n // Append the script to the head or body\n document.head.appendChild(script);\n}\n// Attach our `onRender` handler to Streamlit's render event.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.events.addEventListener(streamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.RENDER_EVENT, onRender);\n// Tell Streamlit we're ready to start receiving data. We won't get our\n// first RENDER_EVENT until we call this function.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setComponentReady();\n// Finally, tell Streamlit to update the initial height.\nstreamlit_component_lib__WEBPACK_IMPORTED_MODULE_0__.Streamlit.setFrameHeight(1000);\n\n\n//# sourceURL=webpack://gdpr_consent/./src/main.ts?"); /***/ }), diff --git a/gdpr_consent/src/main.ts b/gdpr_consent/src/main.ts index f7219ff14..059fef89e 100644 --- a/gdpr_consent/src/main.ts +++ b/gdpr_consent/src/main.ts @@ -114,6 +114,16 @@ function onRender(event: Event): void { } ) } + if (data.args['matomo']) { + klaroConfig.services.push( + { + name: 'matomo', + purposes: ['analytics'], + onAccept: callback, + onDecline: callback, + } + ) + } // Create a new script element var script = document.createElement('script') diff --git a/hooks/hook-analytics.py b/hooks/hook-analytics.py index 6b8b2dab2..f073af004 100644 --- a/hooks/hook-analytics.py +++ b/hooks/hook-analytics.py @@ -56,6 +56,22 @@ def piwik_pro_body(piwik_tag): """ +def matomo_head(matomo_url, matomo_tag): + return f""" + + + + """ + + if __name__ == '__main__': # Load configuration @@ -79,6 +95,12 @@ def piwik_pro_body(piwik_tag): piwik_tag = settings['analytics']['piwik-pro']['tag'] index = patch_body(index, piwik_pro_body(piwik_tag)) + # Configure matomo tag manager + if settings['analytics']['matomo']['enabled']: + matomo_url = settings['analytics']['matomo']['url'] + matomo_tag = settings['analytics']['matomo']['tag'] + index = patch_head(index, matomo_head(matomo_url, matomo_tag)) + # Save index.html with open(index_path, 'w') as f: f.write(index) \ No newline at end of file diff --git a/settings.json b/settings.json index 34189b8c1..acd02e701 100644 --- a/settings.json +++ b/settings.json @@ -11,6 +11,11 @@ "piwik-pro": { "enabled": true, "tag": "57690c44-d635-43b0-ab43-f8bd3064ca06" + }, + "matomo": { + "enabled": false, + "url": "", + "tag": "" } }, "online_deployment": false, diff --git a/src/common/captcha_.py b/src/common/captcha_.py index 5c7afd770..498b13368 100644 --- a/src/common/captcha_.py +++ b/src/common/captcha_.py @@ -208,12 +208,13 @@ def captcha_control(): # Check if consent for tracking was given ga = st.session_state.settings['analytics']['google-analytics']['enabled'] pp = st.session_state.settings['analytics']['piwik-pro']['enabled'] - if (ga or pp) and (st.session_state.tracking_consent is None): + mt = st.session_state.settings['analytics']['matomo']['enabled'] + if (ga or pp or mt) and (st.session_state.tracking_consent is None): consent_component = st_components.declare_component("gdpr_consent", path=Path("gdpr_consent")) with st.spinner(): # Ask for consent st.session_state.tracking_consent = consent_component( - google_analytics=ga, piwik_pro=pp + google_analytics=ga, piwik_pro=pp, matomo=mt ) if st.session_state.tracking_consent is None: # No response by user yet diff --git a/src/common/common.py b/src/common/common.py index 4d24e7656..b29d14f0e 100644 --- a/src/common/common.py +++ b/src/common/common.py @@ -405,6 +405,23 @@ def page_setup(page: str = "") -> dict[str, Any]: width=1, height=1, ) + if (st.session_state.settings["analytics"]["matomo"]["enabled"]) and ( + st.session_state.tracking_consent["matomo"] == True + ): + html( + """ + + + + + + """, + width=1, + height=1, + ) # Determine the workspace for the current session if ("workspace" not in st.session_state) or ( From 76282fd3ae0590f7569e1f7eff02c00b03cc1781 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 12:06:31 +0000 Subject: [PATCH 2/4] Fix Matomo Tag Manager snippet to match official docs - Accept full container JS URL instead of separate url + tag fields, supporting both self-hosted and Matomo Cloud URL patterns - Match the official snippet: var _mtm alias, _mtm.push shorthand - Remove redundant type="text/javascript" attribute - Remove unused "tag" field from settings.json https://claude.ai/code/session_0165AXHkmRZ6bx23n7Tbyz8h --- hooks/hook-analytics.py | 14 ++++++-------- settings.json | 3 +-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/hooks/hook-analytics.py b/hooks/hook-analytics.py index f073af004..dc7e3265b 100644 --- a/hooks/hook-analytics.py +++ b/hooks/hook-analytics.py @@ -56,16 +56,15 @@ def piwik_pro_body(piwik_tag): """ -def matomo_head(matomo_url, matomo_tag): +def matomo_head(matomo_url): return f""" - @@ -98,8 +97,7 @@ def matomo_head(matomo_url, matomo_tag): # Configure matomo tag manager if settings['analytics']['matomo']['enabled']: matomo_url = settings['analytics']['matomo']['url'] - matomo_tag = settings['analytics']['matomo']['tag'] - index = patch_head(index, matomo_head(matomo_url, matomo_tag)) + index = patch_head(index, matomo_head(matomo_url)) # Save index.html with open(index_path, 'w') as f: diff --git a/settings.json b/settings.json index acd02e701..b9ab5d884 100644 --- a/settings.json +++ b/settings.json @@ -14,8 +14,7 @@ }, "matomo": { "enabled": false, - "url": "", - "tag": "" + "url": "" } }, "online_deployment": false, From 1689f59673eb569c3689a6fccce4a0011a82357b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 12:51:33 +0000 Subject: [PATCH 3/4] Split Matomo config into base url + tag fields Separate the Matomo setting into `url` (base URL, e.g. https://cdn.matomo.cloud/openms.matomo.cloud) and `tag` (container ID, e.g. yDGK8bfY), consistent with how other providers use a tag field. The script constructs the full path: {url}/container_{tag}.js https://claude.ai/code/session_0165AXHkmRZ6bx23n7Tbyz8h --- hooks/hook-analytics.py | 7 ++++--- settings.json | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/hooks/hook-analytics.py b/hooks/hook-analytics.py index dc7e3265b..c47f0c23e 100644 --- a/hooks/hook-analytics.py +++ b/hooks/hook-analytics.py @@ -56,7 +56,7 @@ def piwik_pro_body(piwik_tag): """ -def matomo_head(matomo_url): +def matomo_head(matomo_url, matomo_tag): return f""" @@ -97,7 +97,8 @@ def matomo_head(matomo_url): # Configure matomo tag manager if settings['analytics']['matomo']['enabled']: matomo_url = settings['analytics']['matomo']['url'] - index = patch_head(index, matomo_head(matomo_url)) + matomo_tag = settings['analytics']['matomo']['tag'] + index = patch_head(index, matomo_head(matomo_url, matomo_tag)) # Save index.html with open(index_path, 'w') as f: diff --git a/settings.json b/settings.json index b9ab5d884..acd02e701 100644 --- a/settings.json +++ b/settings.json @@ -14,7 +14,8 @@ }, "matomo": { "enabled": false, - "url": "" + "url": "", + "tag": "" } }, "online_deployment": false, From ab627a22474cd93e0af4a526bd3f9bc942b55a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20David=20M=C3=BCller?= <57191390+t0mdavid-m@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:04:25 +0200 Subject: [PATCH 4/4] install matomo tag --- settings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/settings.json b/settings.json index acd02e701..e0733966f 100644 --- a/settings.json +++ b/settings.json @@ -9,13 +9,13 @@ "tag": "" }, "piwik-pro": { - "enabled": true, - "tag": "57690c44-d635-43b0-ab43-f8bd3064ca06" - }, - "matomo": { "enabled": false, - "url": "", "tag": "" + }, + "matomo": { + "enabled": true, + "url": "https://cdn.matomo.cloud/openms.matomo.cloud", + "tag": "yDGK8bfY" } }, "online_deployment": false,