Skip to content

Commit 9526b5e

Browse files
Merge pull request #674 from akvo/poc/671-try-out-analytic-options
Poc/671 try out analytic options
2 parents 11a8207 + 14dd7e8 commit 9526b5e

File tree

20 files changed

+608
-98
lines changed

20 files changed

+608
-98
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
.vscode/
22
.DS_Store
3-
.env
3+
.env
4+
5+
frontend/.env

analytic.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Evaluation of GDPR-Compliant Analytics Platforms with Custom Event Tracking
2+
3+
**Recommendation, Feature Assessment, and Cost Projection (Multi-Client Use Case)**
4+
5+
---
6+
7+
## 1. Executive Summary
8+
9+
This document evaluates GDPR-compliant analytics tools that support custom event tracking for multi-client, multi-domain environments to replace current Matomo/Piwik. The focus is on cloud-hosted solutions to reduce infrastructure overhead.
10+
11+
**Top Recommendation:** **PostHog Cloud**
12+
**Rationale:** Best combination of cost-efficiency (under €5,000/year for moderate traffic multi-client setup) and custom dashboard/event capabilities similar to Matomo/Piwik.
13+
14+
---
15+
16+
## 2. Tools Included in This Evaluation
17+
18+
| # | Platform | Reason for Inclusion | GDPR Reference |
19+
|---|----------|--------------------|----------------|
20+
| 1 | **Plausible Analytics** | Strong privacy positioning, predictable pricing, supports goals/events | https://plausible.io/data-policy |
21+
| 2 | **Fathom Analytics** | Fully GDPR-compliant, cookieless, basic event tracking | https://usefathom.com/legal/compliance/gdpr-compliant-website-analytics |
22+
| 3 | **Umami Cloud** | Lightweight, privacy-focused, supports event tracking | https://umami.is/features |
23+
| 4 | **PostHog Cloud** | Advanced event analytics, generous free tier | https://posthog.com/docs/privacy/gdpr-compliance |
24+
| 5 | **Countly Cloud (Managed)** | Enterprise-grade event analytics with GDPR configuration | https://countly.com/privacy-by-design |
25+
26+
---
27+
28+
## 3. Feature Comparison: Customization & Dashboard Capabilities
29+
30+
| Platform | Custom Dashboard | Custom Event Visualization | Third-Party BI Needed? | Notes |
31+
|----------|-----------------|----------------------------|----------------------|-------|
32+
| **Plausible** | Limited (built-in) | Yes | Not required | Custom events successfully tracked, but advanced reports like Matomo/Piwik are not possible |
33+
| **Fathom** | Limited | Basic custom events | Not required | Sign-up requires credit card; pricing depends on total page-views; not tested yet |
34+
| **Umami Cloud** | Limited | Yes | Not required | One key limitation of Umami is that, although we can send arbitrary properties using `window.umami.track("eventName", { prop1: value1, prop2: value2 })`, the dashboard only displays the event name and a simple count. It does not allow direct filtering or grouping based on custom payload properties such as step or case_id |
35+
| **PostHog Cloud** | Extensive | Advanced charts, formulas, funnels, cohorts | Not required | Closest match to Matomo/Piwik; custom events and dashboards tested locally |
36+
| **Countly Cloud** | Extensive | Fully custom dashboards, segments, funnels | Not required | Enterprise-grade; excluded due to cost |
37+
38+
---
39+
40+
## 4. Cost Ranking (Cheapest to Most Expensive, Cloud Hosted, GDPR-Compliant)
41+
42+
| Rank | Platform | Cost Level | GDPR Strength | Why |
43+
|------|----------|------------|----------------|-----|
44+
| **1** | **Fathom Analytics** | Low (based on page-views) | Very strong | Cost-effective if total page-views remain within plan limits |
45+
| **2** | **Plausible Analytics** | Low–medium | Very strong | Predictable per-site pricing, EU-hosted, cookieless |
46+
| **3** | **Umami Cloud** | Low–medium | Strong | Lightweight, simple event tracking |
47+
| **4** | **PostHog Cloud** | Medium | Strong | Free tier sufficient for moderate events; advanced dashboards |
48+
| **5** | **Countly Cloud** | High | Strong | Enterprise-grade; excluded due to cost |
49+
50+
---
51+
52+
## 5. Estimated Monthly Cost in EUR (10 Client Scenario)
53+
54+
**Assumptions:**
55+
- 10 client websites
56+
- Traffic per client: small–medium (50k–300k monthly pageviews)
57+
- Currency conversion: USD → EUR ≈ 0.92
58+
- Fathom pricing depends on aggregated monthly page-views
59+
60+
| Platform | Pricing Model | Estimated Monthly Total (EUR) | Notes |
61+
|----------|---------------|-------------------------------|-------|
62+
| **Fathom Analytics** | ~$15–45/mo depending on total page-views | €23–€41 | Assumes aggregated page-views ~200k–500k/month; monitor page-views to avoid cost spikes |
63+
| **Plausible** | €9–€29 per site | €90–€290 | Predictable per-site pricing |
64+
| **Umami Cloud** | €9–€25 per site | €90–€250 | Simple cloud-hosted model |
65+
| **PostHog Cloud** | Free tier up to ~1M events; paid >1M | €0–€80+ | Free tier sufficient for moderate traffic; scalable beyond |
66+
| **Countly Cloud** | Enterprise plans | €200–€600+ | Exceeds €5k/year for 10 clients; excluded |
67+
68+
---
69+
70+
## 6. Top Recommendation (Cost + Feature Similarity to Matomo/Piwik)
71+
72+
| Rank | Platform | Reason for Top Recommendation |
73+
|------|----------|-------------------------------|
74+
| **1** | **PostHog Cloud** | Best combination of cost & feature richness: supports custom events, segmentation, funnels, cohort analysis, and flexible dashboards — most similar to Matomo/Piwik. Cost remains controlled for moderate traffic multi-client setup. |
75+
| **2** | **Plausible Analytics** | Low-cost & GDPR-friendly; sufficient for simple custom events. Dashboard limited but ideal for medium-traffic clients or simpler analytics. |
76+
| **3** | **Umami Cloud** | Mid-level option: more flexible dashboards & custom events than Plausible, costs manageable; suitable for small to medium clients. |
77+
78+
> **Note:** Fathom is cost-efficient but pricing depends on aggregated page-views; Countly excluded due to cost (> €5k/year).
79+
80+
---
81+
82+
## 7. Final Recommendation for Management
83+
84+
- **Primary Platform:** **PostHog Cloud**
85+
- Best for multi-client setups needing flexible dashboards & event tracking similar to Matomo/Piwik.
86+
- Cost remains low for moderate traffic (free or paid tier).
87+
88+
- **Secondary Platform:** **Plausible Analytics**
89+
- Default choice for clients with medium traffic or simpler analytics needs.
90+
- Predictable per-site pricing, cookieless, GDPR-friendly.
91+
92+
- **Optional Lightweight Alternative:** **Umami Cloud**
93+
- For clients needing basic custom events and dashboards at lower cost.
94+
95+
**Summary Table: Recommended Platforms Under €5k/year**
96+
97+
| Platform | Annual Cost (10 clients) | GDPR | Custom Dashboard / Event Similarity to Matomo/Piwik |
98+
|----------|--------------------------|------|---------------------------------------------------|
99+
| **PostHog Cloud** | €0–€960+ | Strong | High: closest match to Matomo/Piwik |
100+
| **Plausible Analytics** | €1.080–€3.480 | Very strong | Moderate: simple custom events & built-in dashboards |
101+
| **Umami Cloud** | €1.080–€3.000 | Strong | Moderate: basic dashboards, custom events supported |
102+
| **Fathom Analytics** | €276–€492 | Very strong | Basic: limited customization; price depends on aggregated page-views |
103+
| **Countly Cloud** | €2.400–€7.200+ | Strong | High, but excluded due to cost |
104+
105+
---
106+
107+
## 8. Developer Notes / Local Testing
108+
109+
- **Plausible:** Successfully tracked custom events from local site; however, advanced custom reports like Matomo/Piwik cannot be generated.
110+
- **PostHog:** Local event tracking works as expected, including custom page-duration and user action events; dashboard supports flexible visualization.
111+
- **Umami:** One key limitation of Umami is that, although we can send arbitrary properties using `window.umami.track("eventName", { prop1: value1, prop2: value2 })`, the dashboard only displays the event name and a simple count. It does not allow direct filtering or grouping based on custom payload properties such as step or case_id.
112+
- **Fathom:** Cost depends on aggregated page-views; signed up with a credit card for testing.
113+
- **Countly:** Excluded due to cost.

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,8 @@ services:
3939
working_dir: /app
4040
depends_on:
4141
- backend
42+
environment:
43+
- VITE_PUBLIC_POSTHOG_KEY=${VITE_PUBLIC_POSTHOG_KEY}
44+
- VITE_PUBLIC_POSTHOG_HOST=${VITE_PUBLIC_POSTHOG_HOST}
4245
volumes:
4346
pg-data:

frontend/.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
PUBLIC_URL=/
2+
REACT_APP_PUBLIC_POSTHOG_HOST=
3+
REACT_APP_PUBLIC_POSTHOG_KEY=

frontend/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
# misc
1515
.DS_Store
16+
.env
1617
.env.local
1718
.env.development.local
1819
.env.test.local

frontend/public/index.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,36 @@
2424
work correctly both with client-side routing and a non-root public URL.
2525
Learn how to configure a non-root public URL by running `npm run build`.
2626
-->
27+
28+
<!-- Matomo Cloudron -->
29+
<script>
30+
var testEnvs = ["test", "staging"];
31+
var localEnvs = ["ngrok", ".dev"];
32+
var origin = window?.location?.origin;
33+
var siteID = testEnvs.some((env) => origin?.includes(env))
34+
? "1"
35+
: localEnvs.some((env) => origin?.includes(env))
36+
? "2"
37+
: "3";
38+
39+
var _paq = (window._paq = window._paq || []);
40+
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
41+
_paq.push(["trackPageView"]);
42+
_paq.push(["enableLinkTracking"]);
43+
(function () {
44+
var u = "https://matomo.cloud.akvo.org/";
45+
_paq.push(["setTrackerUrl", u + "matomo.php"]);
46+
_paq.push(["setSiteId", siteID]);
47+
var d = document,
48+
g = d.createElement("script"),
49+
s = d.getElementsByTagName("script")[0];
50+
g.async = true;
51+
g.src = u + "matomo.js";
52+
s.parentNode.insertBefore(g, s);
53+
})();
54+
</script>
55+
<!-- End Matomo Code -->
56+
2757
<title>IDH - Toolkit towards better incomes</title>
2858
</head>
2959
<body>

frontend/src/App.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
CocoaIncomeInventoryDashboard,
3838
} from "./pages/cocoa-income-inventory";
3939
import { LivingIncomeBenchmarkExplorer } from "./pages/lib-explorer";
40-
import { useSignOut } from "./hooks";
40+
import { useSignOut, useMatomoPageView } from "./hooks";
4141
import { ScrollToHash } from "./components/utils";
4242

4343
const optionRoutes = [
@@ -67,6 +67,9 @@ const App = () => {
6767
const isInternalUser = UserState.useState((s) => s.internal_user);
6868
const signOut = useSignOut();
6969

70+
// Matomo Page View Tracking
71+
useMatomoPageView();
72+
7073
const isExternalUser = useMemo(() => {
7174
return userRole === "user" && !isInternalUser;
7275
}, [userRole, isInternalUser]);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useEffect, useRef } from "react";
2+
import { useLocation } from "react-router-dom";
3+
4+
const useMatomoCaseStepAnalytics = () => {
5+
const location = useLocation();
6+
7+
const startTimeRef = useRef(Date.now());
8+
const previousPathRef = useRef(null);
9+
10+
useEffect(() => {
11+
if (!window._paq) {
12+
return;
13+
}
14+
15+
/**
16+
* 1️⃣ Send step duration EVENT for the previous route
17+
*/
18+
if (previousPathRef.current) {
19+
const durationSeconds = Math.floor(
20+
(Date.now() - startTimeRef.current) / 1000
21+
);
22+
23+
const prevSegments = previousPathRef.current.split("/").filter(Boolean);
24+
const prevCaseIndex = prevSegments.indexOf("case");
25+
26+
if (prevCaseIndex !== -1 && prevSegments.length >= prevCaseIndex + 3) {
27+
const caseId = prevSegments[prevCaseIndex + 1];
28+
const step = prevSegments[prevCaseIndex + 2];
29+
30+
window._paq.push([
31+
"trackEvent",
32+
"Case Step Page Duration", // Category (future-proof)
33+
step, // Action
34+
`case/${caseId}`, // Label
35+
durationSeconds, // Value (seconds)
36+
]);
37+
38+
console.info("Matomo step duration event", {
39+
step,
40+
case_id: caseId,
41+
duration_seconds: durationSeconds,
42+
});
43+
}
44+
}
45+
46+
/**
47+
* 2️⃣ Track PAGE VIEW for the new route
48+
*/
49+
const segments = location.pathname.split("/").filter(Boolean);
50+
const caseIndex = segments.indexOf("case");
51+
52+
let title = document.title;
53+
54+
if (caseIndex !== -1 && segments.length >= caseIndex + 3) {
55+
const step = segments[caseIndex + 2];
56+
title = `Case Step – ${step}`;
57+
}
58+
59+
window._paq.push(["setCustomUrl", location.pathname]);
60+
window._paq.push(["setDocumentTitle", title]);
61+
window._paq.push(["trackPageView"]);
62+
63+
console.info("Matomo page tracked", {
64+
path: location.pathname,
65+
title,
66+
});
67+
68+
/**
69+
* 3️⃣ Reset refs for next navigation
70+
*/
71+
startTimeRef.current = Date.now();
72+
previousPathRef.current = location.pathname;
73+
}, [location.pathname]);
74+
};
75+
76+
export default useMatomoCaseStepAnalytics;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useEffect, useRef } from "react";
2+
import { useLocation } from "react-router-dom";
3+
4+
const useMatomoPageView = () => {
5+
const location = useLocation();
6+
const previousPathRef = useRef(null);
7+
8+
useEffect(() => {
9+
if (!window._paq) {
10+
return;
11+
}
12+
13+
// Prevent duplicate tracking (React 18 StrictMode safe)
14+
if (previousPathRef.current === location.pathname) {
15+
return;
16+
}
17+
previousPathRef.current = location.pathname;
18+
19+
window._paq.push(["setCustomUrl", location.pathname]);
20+
window._paq.push(["setDocumentTitle", document.title]);
21+
window._paq.push(["trackPageView"]);
22+
}, [location.pathname]);
23+
};
24+
25+
export default useMatomoPageView;

frontend/src/hooks/PiwikTrackPageTime.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ const usePiwikTrackPageTime = () => {
1010
const sendTimeSpentEvent = () => {
1111
const timeSpent = Math.floor((Date.now() - startTimeRef.current) / 1000);
1212

13+
// updated due to adding group income-driver-calculator into the path
1314
const segments = location.pathname.split("/").filter(Boolean);
15+
const [, currentPage, caseId, step] = segments;
1416

15-
if (segments[0] === "case" && segments.length >= 3) {
16-
const caseId = segments[1]; // e.g. "16"
17-
const step = segments[2]; // e.g. "set-income-target"
18-
17+
if (currentPage === "case" && segments.length >= 3) {
1918
CustomEvent.trackEvent(
2019
"Page Duration", // Event Category
2120
step, // Event Action (e.g. step name)

0 commit comments

Comments
 (0)