Repository navigation
Add EthicalMetrics privacy-preserving analytics integration #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= | ||
| github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= | ||
| github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= | ||
| github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= | ||
| github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= | ||
| github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= | ||
| github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= | ||
| github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= | ||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
| github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= | ||
| github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||
| golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= | ||
| golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| package http | ||
|
|
||
| import ( | ||
| "net" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| "github.com/livrasand/gitGost/internal/utils" | ||
| ) | ||
|
|
||
| type ethicalPageview struct { | ||
| SiteKey string `json:"site_key" binding:"required"` | ||
| Path string `json:"path"` | ||
| Referrer string `json:"referrer"` | ||
| Browser string `json:"browser"` | ||
| OS string `json:"os"` | ||
| Device string `json:"device"` | ||
| } | ||
|
|
||
| type ethicalBucket struct { | ||
| Pageviews int64 `json:"pageviews"` | ||
| } | ||
|
|
||
| type ethicalMetricsStore struct { | ||
| mu sync.RWMutex | ||
| buckets map[string]map[string]int64 | ||
| } | ||
|
|
||
| var ethicalStore = ethicalMetricsStore{buckets: make(map[string]map[string]int64)} | ||
|
|
||
| func EthicalMetricsPageviewHandler(c *gin.Context) { | ||
| if c.GetHeader("DNT") == "1" { | ||
| c.Status(http.StatusNoContent) | ||
| return | ||
| } | ||
| var input ethicalPageview | ||
| if err := c.ShouldBindJSON(&input); err != nil || strings.TrimSpace(input.SiteKey) == "" { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "site_key is required"}) | ||
| return | ||
| } | ||
|
|
||
| path := normalizeEthicalPath(input.Path) | ||
| browser := normalizeEthicalCategory(input.Browser, "Chrome", "Firefox", "Safari", "Edge") | ||
| osName := normalizeEthicalCategory(input.OS, "Windows", "macOS", "Linux", "Android", "iOS") | ||
| device := normalizeEthicalCategory(input.Device, "Desktop", "Mobile", "Tablet") | ||
| country := ethicalCountry(c.Request) | ||
| bucket := time.Now().UTC().Truncate(time.Minute).Format(time.RFC3339) | ||
|
|
||
| key := strings.Join([]string{bucket, "page", path, browser, osName, device, country}, "|") | ||
| ethicalStore.mu.Lock() | ||
| if ethicalStore.buckets[input.SiteKey] == nil { | ||
| ethicalStore.buckets[input.SiteKey] = make(map[string]int64) | ||
| } | ||
| ethicalStore.buckets[input.SiteKey][key]++ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Every accepted pageview is inserted into the process-wide Useful? React with 👍 / 👎. |
||
| ethicalStore.mu.Unlock() | ||
| if dbClient != nil { | ||
| if err := dbClient.UpsertEthicalPageview(c.Request.Context(), input.SiteKey, bucket); err != nil { | ||
| utils.Log("Error upserting pageview for %s/%s: %v", input.SiteKey, bucket, err) | ||
| } | ||
| } | ||
| c.Status(http.StatusAccepted) | ||
| } | ||
|
|
||
| func EthicalMetricsPrivacyHandler(c *gin.Context) { | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "version": "1.0", "last_updated": "2026-07-27", | ||
| "stores_ip": false, "stores_user_agent": false, "stores_cookie": false, | ||
| "stores_localstorage": false, "stores_session": false, "stores_fingerprint": false, | ||
| "stores_query_strings": false, "stores_events": false, | ||
| "transient_processing": gin.H{"ip": "used only for country lookup, then discarded"}, | ||
| }) | ||
| } | ||
|
|
||
| func EthicalMetricsManifestHandler(c *gin.Context) { | ||
| c.JSON(http.StatusOK, gin.H{"product": "EthicalMetrics", "version": "0.1.0", "protocol": "1", "api": "v1", "measurement": "pageviews", "tracking": false, "storage": "aggregated", "privacy": "/privacy"}) | ||
| } | ||
|
|
||
| func EthicalMetricsVersionHandler(c *gin.Context) { | ||
| c.JSON(http.StatusOK, gin.H{"product": "EthicalMetrics", "version": "0.1.0", "protocol": "1"}) | ||
| } | ||
|
|
||
| func EthicalMetricsMetricsHandler(c *gin.Context) { | ||
| site := strings.TrimPrefix(c.Param("site"), "") | ||
| if site == "" { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "site is required"}) | ||
| return | ||
| } | ||
| result := make(map[string]ethicalBucket) | ||
| if dbClient != nil { | ||
| if persisted, err := dbClient.GetEthicalPageviews(c.Request.Context(), site); err == nil { | ||
| for bucket, count := range persisted { | ||
| result[bucket] = ethicalBucket{Pageviews: count} | ||
| } | ||
| } else { | ||
| utils.Log("Error loading pageviews for %s: %v", site, err) | ||
| } | ||
| } | ||
| if len(result) == 0 { | ||
| // Fallback a memoria si no hay DB o falló la lectura | ||
| ethicalStore.mu.RLock() | ||
| defer ethicalStore.mu.RUnlock() | ||
| for key, count := range ethicalStore.buckets[site] { | ||
| parts := strings.SplitN(key, "|", 2) | ||
| if len(parts) == 2 { | ||
| result[parts[0]] = ethicalBucket{Pageviews: result[parts[0]].Pageviews + count} | ||
| } | ||
| } | ||
| } | ||
| c.JSON(http.StatusOK, gin.H{"site": site, "measurement": "pageviews", "buckets": result}) | ||
| } | ||
|
|
||
| func normalizeEthicalPath(value string) string { | ||
| parsed, err := url.Parse(value) | ||
| if err == nil && parsed.Path != "" { | ||
| value = parsed.Path | ||
| } | ||
| if value == "" || !strings.HasPrefix(value, "/") { | ||
| return "/" + strings.TrimPrefix(value, "/") | ||
| } | ||
| return value | ||
| } | ||
|
|
||
| func normalizeEthicalCategory(value string, allowed ...string) string { | ||
| for _, candidate := range allowed { | ||
| if value == candidate { | ||
| return value | ||
| } | ||
| } | ||
| return "Other" | ||
| } | ||
|
|
||
| func ethicalCountry(r *http.Request) string { | ||
| ip := r.RemoteAddr | ||
| if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { | ||
| ip = strings.TrimSpace(strings.Split(forwarded, ",")[0]) | ||
| } | ||
| if host, _, err := net.SplitHostPort(ip); err == nil { | ||
| ip = host | ||
| } | ||
| _ = ip | ||
| return "Unknown" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package http | ||
|
|
||
| import ( | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| func TestEthicalMetricsNormalizesCategories(t *testing.T) { | ||
| if got := normalizeEthicalCategory("Chrome 142", "Chrome", "Firefox"); got != "Other" { | ||
| t.Fatalf("browser = %q", got) | ||
| } | ||
| if got := normalizeEthicalCategory("MacBookPro18,4", "Desktop", "Mobile", "Tablet"); got != "Other" { | ||
| t.Fatalf("device = %q", got) | ||
| } | ||
| } | ||
|
|
||
| func TestEthicalMetricsDNT(t *testing.T) { | ||
| gin.SetMode(gin.TestMode) | ||
| ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) | ||
| ctx.Request = httptest.NewRequest("POST", "/v1/pageviews", nil) | ||
| ctx.Request.Header.Set("DNT", "1") | ||
| EthicalMetricsPageviewHandler(ctx) | ||
| if ctx.Writer.Status() != 204 { | ||
| t.Fatalf("status = %d", ctx.Writer.Status()) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| (function () { | ||
| const script = document.currentScript; | ||
| if (!script || !script.dataset.siteKey || navigator.doNotTrack === "1" || window.doNotTrack === "1") return; | ||
| const agent = navigator.userAgent || ""; | ||
| const device = /Tablet|iPad/i.test(agent) ? "Tablet" : /Mobi|Android/i.test(agent) ? "Mobile" : "Desktop"; | ||
| const browser = /Edg\//i.test(agent) ? "Edge" : /Firefox\//i.test(agent) ? "Firefox" : /Chrome\//i.test(agent) ? "Chrome" : /Safari\//i.test(agent) ? "Safari" : "Other"; | ||
| const os = /Windows/i.test(agent) ? "Windows" : /Android/i.test(agent) ? "Android" : /iPhone|iPad|iPod/i.test(agent) ? "iOS" : /Mac OS X|Macintosh/i.test(agent) ? "macOS" : /Linux/i.test(agent) ? "Linux" : "Other"; | ||
| const body = JSON.stringify({ site_key: script.dataset.siteKey, path: window.location.pathname || "/", referrer: document.referrer || "", browser, os, device }); | ||
| fetch(script.dataset.endpoint || "/v1/pageviews", { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {}); | ||
| })(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This query fetches every persisted minute bucket for a site, while the homepage chart later only displays the last 36 entries. Once the site has accumulated weeks or months of minute-level rows, each metrics request will scan, allocate, and return the full history before the browser discards almost all of it; add ordering/range filtering or a server-side limit for the recent window.
Useful? React with 👍 / 👎.