first commit

This commit is contained in:
Soph :3 2025-10-31 15:14:14 +02:00
commit 734b8bd922
7 changed files with 672 additions and 0 deletions

127
src/clientAnalytics.js Normal file
View file

@ -0,0 +1,127 @@
(() => {
const API = "some_misc_string"; // this is replaced when JS is requested, do not worry
const SITE = "some_site_string"; // same with this
console.log("fuckass_analytics v1 " + API + " on site " + SITE)
const SESSION_ID = crypto.randomUUID();
const USER_ID =
localStorage.getItem("analytics_uid") ||
(() => {
const id = crypto.randomUUID();
localStorage.setItem("analytics_uid", id);
return id;
})();
const common = {
userId: USER_ID,
sessionId: SESSION_ID,
site: SITE,
url: location.href,
title: document.title,
referrer: document.referrer || null,
lang: navigator.language,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
screen: `${screen.width}x${screen.height}`,
ua: navigator.userAgent,
platform: navigator.platform,
};
const send = (type, data = {}) => {
(async () => {
const res = await fetch(API + "/some-cool-endpoint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, t: Date.now(), ...common, ...data }),
keepalive: true,
});
console.log(res)
})();
};
const start = performance.now();
const urlParams = new URLSearchParams(location.search);
const utm = Object.fromEntries(
[...urlParams.entries()].filter(([k]) => k.startsWith("utm_"))
);
send("pageview", { utm });
let maxScroll = 0;
window.addEventListener("scroll", () => {
const percent =
(window.scrollY + window.innerHeight) / document.body.scrollHeight;
maxScroll = Math.max(maxScroll, percent);
});
document.addEventListener("click", (e) => {
const el = e.target.closest("button,a,input,[data-track]");
if (!el) return;
send("click", {
tag: el.tagName,
label:
el.getAttribute("data-track") ||
el.textContent?.trim()?.slice(0, 60) ||
el.id ||
el.getAttribute("href") ||
el.name ||
"",
path: location.pathname,
});
});
document.addEventListener("submit", (e) => {
const f = e.target;
const fields = [...f.elements]
.filter((x) => x.name)
.map((x) => x.name);
send("form_submit", { fields, path: location.pathname });
});
document.addEventListener(
"play",
(e) =>
send("media_play", { tag: e.target.tagName, src: e.target.currentSrc }),
true
);
document.addEventListener(
"pause",
(e) =>
send("media_pause", { tag: e.target.tagName, src: e.target.currentSrc }),
true
);
window.trackEvent = (name, props = {}) => send("custom", { name, ...props });
if ("PerformanceObserver" in window) {
const vitals = {};
const obs = (entryType, key) =>
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entryType === "layout-shift" && !entry.hadRecentInput) {
vitals.CLS = (vitals.CLS || 0) + entry.value;
} else if (entryType === "largest-contentful-paint") {
vitals.LCP = entry.renderTime || entry.loadTime;
} else if (entryType === "first-input") {
vitals.FID = entry.processingStart - entry.startTime;
}
}
}).observe({ type: entryType, buffered: true });
try {
obs("layout-shift");
obs("largest-contentful-paint");
obs("first-input");
} catch {}
window.addEventListener("beforeunload", () => {
if (Object.keys(vitals).length) send("web_vitals", vitals);
});
}
window.addEventListener("beforeunload", () => {
send("session_end", {
duration_ms: performance.now() - start,
scroll_depth: Math.round(maxScroll * 100),
});
})
})();

160
src/server.ts Normal file
View file

@ -0,0 +1,160 @@
import express, { type Request } from "express";
import bodyParser from "body-parser";
import geoip from "geoip-lite";
import {UAParser} from "ua-parser-js";
import client from "prom-client";
import cors from "cors";
import { readFileSync } from "node:fs";
import { minify } from "uglify-js";
const app = express();
const clientJS =
minify(readFileSync("src/clientAnalytics.js", "utf8")).code;
app.use(bodyParser.json({ limit: "512kb" }));
app.use(
cors()
);
app.options("*path", (req, res) => {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type");
res.sendStatus(204);
});
const register = new client.Registry();
client.collectDefaultMetrics({ register });
const eventCounter = new client.Counter({
name: "web_event_total",
help: "Aggregated event counts",
labelNames: ["site", "type", "country", "device", "browser", "os"],
});
const durationHist = new client.Histogram({
name: "web_session_duration_seconds",
help: "Session durations",
labelNames: ["site"],
buckets: [1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600],
});
const scrollHist = new client.Histogram({
name: "web_scroll_depth_percent",
help: "Scroll depth percentages",
labelNames: ["site"],
buckets: [0, 25, 50, 75, 100],
});
const clsHist = new client.Histogram({
name: "web_vitals_cls",
help: "Cumulative Layout Shift",
labelNames: ["site"],
buckets: [0, 0.1, 0.25, 0.5, 1],
});
const lcpHist = new client.Histogram({
name: "web_vitals_lcp_seconds",
help: "Largest Contentful Paint (s)",
labelNames: ["site"],
buckets: [0.5, 1, 2.5, 4, 6, 10],
});
const fidHist = new client.Histogram({
name: "web_vitals_fid_ms",
help: "First Input Delay (ms)",
labelNames: ["site"],
buckets: [0, 10, 50, 100, 200, 500],
});
const outboundCounter = new client.Counter({
name: "web_outgoing_clicks_total",
help: "Outgoing link clicks by target domain",
labelNames: ["site", "target_domain"],
});
register.registerMetric(outboundCounter);
register.registerMetric(eventCounter);
register.registerMetric(durationHist);
register.registerMetric(scrollHist);
register.registerMetric(clsHist);
register.registerMetric(lcpHist);
register.registerMetric(fidHist);
function enrich(req: Request, uaString: string) {
let xfr = req.headers["x-forwarded-for"];
if (Array.isArray(xfr)) xfr = xfr.join(".");
const ip =
xfr?.split(",")[0] ||
req.socket.remoteAddress ||
"unknown";
const geo = geoip.lookup(ip);
const ua = new UAParser(uaString || "").getResult();
return {
country: geo?.country || process.env.FAKE_COUNTRY || "??",
device: ua.device.type || "desktop",
browser: ua.browser.name || "Unknown",
os: ua.os.name || "Unknown",
};
}
app.post("/some-cool-endpoint", (req, res) => {
const e = req.body;
console.log(e)
const { country, device, browser, os } = enrich(req, e.ua);
let site = e.site;
if(site === undefined) {
try {
site = new URL(e.url).hostname;
} catch (err) {
}
}
eventCounter.labels(site, e.type, country, device, browser, os).inc();
if (e.type === "click" && e.label) {
try {
const targetDomain = new URL(e.label).hostname;
outboundCounter.labels(site, targetDomain).inc();
} catch (err) {
}
}
if (e.type === "session_end" && e.duration_ms)
durationHist.labels(site).observe(e.duration_ms / 1000);
if (e.type === "session_end" && e.scroll_depth)
scrollHist.labels(site).observe(e.scroll_depth);
if (e.type === "web_vitals") {
if (e.CLS) clsHist.labels(site).observe(e.CLS);
if (e.LCP) lcpHist.labels(site).observe(e.LCP / 1000);
if (e.FID) fidHist.labels(site).observe(e.FID);
}
res.sendStatus(204);
});
app.get("/js", async (req, res) => {
const partial_url = req.protocol + "://" + req.get("host");
const url = new URL(partial_url + req.url);
const id = url.searchParams.get("site") || url.hostname;
res.set('Content-Type', 'text/javascript')
res.end(clientJS.replace(
"some_misc_string", partial_url
).replace("some_site_string", id)
)
})
app.get("/metrics", async (req, res) => {
let xfr = req.headers["x-forwarded-for"];
if (Array.isArray(xfr)) xfr = xfr.join(".");
const ip =
xfr?.split(",")[0] ||
req.socket.remoteAddress ||
"unknown";
if(process.env.DEBUG) console.log("metrics required by " + ip)
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});
const port = process.env.PORT || 8080;
app.listen(port, () =>
console.log(`Analytics collector running on http://localhost:${port}`)
);