Skip to content

Commit 607b3ac

Browse files
committed
feat: enhance analytics accuracy and privacy compliance
- Replace unique visitors estimation with accurate "soft fingerprint" counting - Improve IP anonymization to support IPv6 /64 prefix masking for GDPR compliance - Add API Reference link to main navigation - Switch PM2 runtime from Node to Bun for better performance - Update dependencies: better-auth, @funish/basis, @hypequery/clickhouse, and others
1 parent 3a0e122 commit 607b3ac

7 files changed

Lines changed: 150 additions & 42 deletions

File tree

app/composables/useLinkAnalytics.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ export function useLinkAnalytics(linkId: string, externalRange?: Ref<Range>) {
102102
);
103103
}
104104

105+
// Handle unique visitors from count result
106+
if (countResult.data && "uniqueVisitors" in countResult.data) {
107+
uniqueVisitors.value = Number(
108+
(countResult.data as { uniqueVisitors: string | number }).uniqueVisitors,
109+
);
110+
}
111+
105112
// Handle timeseries
106113
if (timeseriesResult.data && "data" in timeseriesResult.data) {
107114
timeseries.value = (timeseriesResult.data as { data: TimeseriesData[] }).data;
@@ -136,10 +143,6 @@ export function useLinkAnalytics(linkId: string, externalRange?: Ref<Range>) {
136143
if (utmSourcesResult.data && "data" in utmSourcesResult.data) {
137144
utmSources.value = (utmSourcesResult.data as { data: DimensionData[] }).data;
138145
}
139-
140-
// Calculate unique visitors (approximate)
141-
// Proper implementation needs DISTINCT query, using estimate for now
142-
uniqueVisitors.value = Math.floor(totalClicks.value * 0.7);
143146
} catch (e) {
144147
error.value = e instanceof Error ? e.message : "Failed to fetch analytics";
145148
} finally {

app/layouts/app.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ const items = computed<NavigationMenuItem[]>(() => {
2222
active: route.path.startsWith("/dashboard"),
2323
icon: "i-lucide-layout-dashboard",
2424
},
25+
{
26+
label: "API Reference",
27+
to: "/api/reference",
28+
active: route.path === "/api/reference",
29+
icon: "i-lucide-code",
30+
target: "_blank",
31+
},
2532
];
2633
2734
return baseItems;

bun.lock

Lines changed: 54 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,21 @@
2727
"postinstall": "nuxt prepare && basis git setup"
2828
},
2929
"devDependencies": {
30-
"@better-auth/api-key": "1.5.3",
30+
"@better-auth/api-key": "1.5.4",
3131
"@clickhouse/client-web": "1.18.1",
32-
"@funish/basis": "0.2.9",
33-
"@hypequery/clickhouse": "1.5.0",
34-
"@iconify-json/lucide": "1.2.95",
32+
"@funish/basis": "0.2.13",
33+
"@hypequery/clickhouse": "1.6.0",
34+
"@iconify-json/lucide": "1.2.96",
3535
"@iconify-json/simple-icons": "1.2.72",
3636
"@nuxt/content": "3.12.0",
3737
"@nuxt/ui": "4.5.1",
3838
"@nuxtjs/i18n": "10.2.3",
39-
"@types/node": "25.3.3",
39+
"@types/node": "25.3.5",
4040
"@types/pg": "8.18.0",
4141
"@types/ua-parser-js": "0.7.39",
4242
"@unovis/vue": "1.6.4",
4343
"@vueuse/nuxt": "14.2.1",
44-
"better-auth": "1.5.3",
44+
"better-auth": "1.5.4",
4545
"date-fns": "4.1.0",
4646
"geoip0": "0.0.12",
4747
"ioredis": "5.10.0",

pm2.config.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export const apps = [
22
{
33
name: "JS.GS",
4-
script: "node",
5-
args: "--env-file=.env .output/server/index.mjs",
4+
script: "bun",
5+
args: "--bun run .output/server/index.mjs",
66
},
77
];

server/utils/analytics/track.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,62 @@ export function getClientIP(event: H3Event): string {
6363
}
6464

6565
/**
66-
* Anonymize IP address by removing the last octet
66+
* Anonymize IP address for privacy compliance
67+
* - IPv4: Replace last octet with 0 (e.g., 192.168.1.100 -> 192.168.1.0)
68+
* - IPv6: Use /64 prefix mask (e.g., 2001:0db8:85a3::8a2e:0370:7334 -> 2001:db8:85a3::)
6769
*/
6870
function anonymizeIP(ip: string): string {
69-
const parts = ip.split(".");
70-
if (parts.length === 4) {
71-
parts[3] = "0";
72-
return parts.join(".");
71+
// IPv4 anonymization - replace last octet
72+
const ipv4Parts = ip.split(".");
73+
if (ipv4Parts.length === 4) {
74+
ipv4Parts[3] = "0";
75+
return ipv4Parts.join(".");
7376
}
74-
// For IPv6 or other formats, return as-is
77+
78+
// IPv6 anonymization - use /64 prefix
79+
if (ip.includes(":")) {
80+
try {
81+
// Split IPv6 address by ::
82+
const parts = ip.split("::");
83+
84+
if (parts.length === 2) {
85+
// Compressed IPv6 format (contains ::)
86+
const prefix = parts[0];
87+
88+
// Handle case where prefix might be undefined or empty
89+
if (!prefix) {
90+
// If prefix is empty, the IPv6 starts with ::, return :: (anycast)
91+
return "::";
92+
}
93+
94+
const segments = prefix.split(":");
95+
96+
// Keep first 4 segments (64 bits) for /64 prefix
97+
if (segments.length >= 4) {
98+
const prefix64 = segments.slice(0, 4).join(":");
99+
return `${prefix64}::`;
100+
}
101+
return `${prefix}::`;
102+
} else {
103+
// Full IPv6 format
104+
const segments = ip.split(":");
105+
if (segments.length >= 8) {
106+
// Keep first 4 segments (64 bits) for /64 prefix
107+
const prefix64 = segments.slice(0, 4).join(":");
108+
return `${prefix64}::`;
109+
} else if (segments.length >= 4) {
110+
// Keep first 4 segments
111+
const prefix64 = segments.slice(0, 4).join(":");
112+
return `${prefix64}::`;
113+
}
114+
}
115+
} catch {
116+
// If parsing fails, return original IP (fallback)
117+
console.warn("Failed to anonymize IPv6 address:", ip);
118+
}
119+
}
120+
121+
// Fallback: return original IP if anonymization fails
75122
return ip;
76123
}
77124

shared/utils/auth/link/routes/get-analytics.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from "zod";
44
import type { Link } from "../../../../types/link";
55
import { canAccessLink } from "../permissions";
66
import { chdb } from "../../../../../server/utils/database";
7-
import { toStartOfInterval } from "@hypequery/clickhouse";
7+
import { toStartOfInterval, rawAs } from "@hypequery/clickhouse";
88

99
// GroupBy options matching Dub.co API
1010
const AnalyticsGroupBySchema = z.enum([
@@ -39,7 +39,8 @@ export const getAnalytics = () => {
3939
use: [sessionMiddleware],
4040
metadata: {
4141
openapi: {
42-
description: "Retrieve analytics for a link",
42+
description:
43+
"Retrieve analytics for a link. Unique visitors are calculated using a privacy-compliant 'soft fingerprint' combining anonymized IP, browser version, OS version, device vendor, and model. This provides better accuracy than IP-only counting while remaining GDPR compliant.",
4344
responses: {
4445
"200": {
4546
description: "Analytics retrieved successfully",
@@ -93,9 +94,25 @@ export const getAnalytics = () => {
9394
// Execute query based on groupBy
9495
switch (groupBy) {
9596
case "count": {
96-
const result = await buildBaseQuery().count("id", "totalClicks").execute();
97+
const countResult = await buildBaseQuery().count("id", "totalClicks").execute();
98+
99+
// Query unique visitors using "soft fingerprint" combination
100+
// Combines: ip (anonymized) + browserMajor + osVersion + deviceVendor + deviceModel
101+
// This provides better accuracy than IP alone while remaining GDPR compliant
102+
const uniqueResult = await buildBaseQuery()
103+
.select([
104+
rawAs(
105+
"uniqExact(concat(ip, '|', browserMajor, '|', osVersion, '|', deviceVendor, '|', deviceModel))",
106+
"uniqueVisitors"
107+
),
108+
])
109+
.execute();
110+
97111
return ctx.json({
98-
totalClicks: result[0]?.totalClicks ?? 0,
112+
totalClicks: countResult[0]?.totalClicks ?? 0,
113+
uniqueVisitors: uniqueResult[0]?.uniqueVisitors
114+
? Number(uniqueResult[0].uniqueVisitors)
115+
: 0,
99116
});
100117
}
101118

0 commit comments

Comments
 (0)