first commit
This commit is contained in:
commit
734b8bd922
7 changed files with 672 additions and 0 deletions
127
src/clientAnalytics.js
Normal file
127
src/clientAnalytics.js
Normal 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
160
src/server.ts
Normal 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}`)
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue