Compare commits

..
2 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 30412cb0ea Analyse: Verweildauer bei internem Seitenwechsel sofort senden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:32:21 +00:00
ClaudeandClaude Sonnet 5.5 ab4c53d6df Analyse: Statistik-Abruf auf richtigen Pfad + Vertragstest
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:32:21 +00:00
6 changed files with 150 additions and 8 deletions

No files matched your search

+18 -1
View File
@@ -214,11 +214,26 @@ function main() {
}
});
const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); });
const onLeave = safe(function () {
function sendEn() {
if (enSent || !allowed()) return;
enSent = true;
send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(),
sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) });
}
const onLeave = safe(sendEn);
// Interner Seitenwechsel per Link: en sofort senden, der Beacon beim pagehide geht in Chromium oft verloren.
// Bubbling-Listener am Dokument: laeuft nach den Handlern der Seite, defaultPrevented ist dann bekannt.
const onLinkClick = safe(function (e) {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || (typeof e.button === 'number' && e.button !== 0)) return;
let a = e.target;
while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
if (!a || hosts.indexOf(e.target) !== -1) return;
const tg = String(a.getAttribute('target') || '').toLowerCase();
if ((tg && tg !== '_self') || a.getAttribute('download') !== null) return;
const u = new URL(String(a.href || a.getAttribute('href') || ''), WIN.location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
if (u.origin !== WIN.location.origin || u.pathname === WIN.location.pathname) return;
sendEn();
});
const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); });
@@ -234,6 +249,7 @@ function main() {
if (tracking) return;
tracking = true;
DOC.addEventListener('click', onClick, { capture: true, passive: true });
DOC.addEventListener('click', onLinkClick, { passive: true });
DOC.addEventListener('visibilitychange', onVis);
WIN.addEventListener('pagehide', onLeave);
WIN.addEventListener('scroll', onScroll, { passive: true });
@@ -241,6 +257,7 @@ function main() {
function stopTracking() {
tracking = false;
DOC.removeEventListener('click', onClick, { capture: true });
DOC.removeEventListener('click', onLinkClick);
DOC.removeEventListener('visibilitychange', onVis);
WIN.removeEventListener('pagehide', onLeave);
WIN.removeEventListener('scroll', onScroll);
+18 -1
View File
@@ -101,11 +101,26 @@ function main() {
}
});
const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); });
const onLeave = safe(function () {
function sendEn() {
if (enSent || !allowed()) return;
enSent = true;
send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(),
sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) });
}
const onLeave = safe(sendEn);
// Interner Seitenwechsel per Link: en sofort senden, der Beacon beim pagehide geht in Chromium oft verloren.
// Bubbling-Listener am Dokument: laeuft nach den Handlern der Seite, defaultPrevented ist dann bekannt.
const onLinkClick = safe(function (e) {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || (typeof e.button === 'number' && e.button !== 0)) return;
let a = e.target;
while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
if (!a || hosts.indexOf(e.target) !== -1) return;
const tg = String(a.getAttribute('target') || '').toLowerCase();
if ((tg && tg !== '_self') || a.getAttribute('download') !== null) return;
const u = new URL(String(a.href || a.getAttribute('href') || ''), WIN.location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
if (u.origin !== WIN.location.origin || u.pathname === WIN.location.pathname) return;
sendEn();
});
const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); });
@@ -121,6 +136,7 @@ function main() {
if (tracking) return;
tracking = true;
DOC.addEventListener('click', onClick, { capture: true, passive: true });
DOC.addEventListener('click', onLinkClick, { passive: true });
DOC.addEventListener('visibilitychange', onVis);
WIN.addEventListener('pagehide', onLeave);
WIN.addEventListener('scroll', onScroll, { passive: true });
@@ -128,6 +144,7 @@ function main() {
function stopTracking() {
tracking = false;
DOC.removeEventListener('click', onClick, { capture: true });
DOC.removeEventListener('click', onLinkClick);
DOC.removeEventListener('visibilitychange', onVis);
WIN.removeEventListener('pagehide', onLeave);
WIN.removeEventListener('scroll', onScroll);
+47 -1
View File
@@ -185,7 +185,7 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } };
const win = {
innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
location: { hostname: 'mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
location: { hostname: 'mein.de', origin: 'https://mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
addEventListener(t, f) { (winL[t] ||= []).push(f); },
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); },
};
@@ -431,3 +431,49 @@ test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegat
assert.ok(hostEl(h));
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused);
});
// ---------- Verweildauer bei internem Seitenwechsel ----------
const ACC = { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 };
const lnk = (href, attrs = {}) => { const a = makeEl('a'); a.setAttribute('href', href); for (const k in attrs) a.setAttribute(k, attrs[k]); a.parentNode = makeEl('body'); return a; };
const enCount = (h) => h.sent.filter((s) => s.d.t === 'en').length;
test('w.js: interner Link-Klick sendet en sofort, pagehide danach nicht doppelt', () => {
const h = harness({ stored: ACC });
h.ck(lnk('/kontakt'));
assert.equal(enCount(h), 1);
assert.deepEqual(h.sent.map((s) => s.d.t), ['pv', 'ck', 'en']);
const en = h.sent.at(-1).d;
assert.equal(en.path, '/start'); assert.equal(en.scroll, 55);
h.leave();
assert.equal(enCount(h), 1);
h.ck(lnk('https://mein.de/andere?x=1#a'));
assert.equal(enCount(h), 1);
});
test('w.js: kein fruehes en bei externem Link, Anker, gleicher Seite, Modifier, Ziel/Download, defaultPrevented', () => {
const h = harness({ stored: ACC });
h.ck(lnk('https://anders.de/x'));
h.ck(lnk('#oben'));
h.ck(lnk('/start#unten'));
h.ck(lnk('/kontakt'), { ctrlKey: true });
h.ck(lnk('/kontakt'), { metaKey: true });
h.ck(lnk('/kontakt'), { shiftKey: true });
h.ck(lnk('/kontakt'), { button: 1 });
h.ck(lnk('/kontakt', { target: '_blank' }));
h.ck(lnk('/datei.pdf', { download: '' }));
h.ck(lnk('/kontakt'), { defaultPrevented: true });
h.ck(lnk('mailto:a@b.de'));
assert.equal(enCount(h), 0);
h.leave();
assert.equal(enCount(h), 1);
});
test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
const h = harness();
h.ck(lnk('/kontakt'));
assert.equal(enCount(h), 0);
const g = harness({ stored: ACC });
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
g.ck(lnk('/kontakt'));
assert.equal(enCount(g), 0);
});
+1 -1
View File
@@ -51,7 +51,7 @@ export function createAnalyticsClient(opts = {}) {
return request('DELETE', sitePath(siteKey))
},
getStats(siteKey, range) {
return request('GET', `${sitePath(siteKey)}/stats?range=${encodeURIComponent(range)}`)
return request('GET', `/a/admin/stats/${encodeURIComponent(siteKey)}?range=${encodeURIComponent(range)}`)
},
// Format (R3): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, byte-gleich zu analytics/src/heat.js
heatToken({ siteKey, path, vp, now = Date.now() }) {
+4 -4
View File
@@ -42,14 +42,14 @@ test('deleteSite und getStats', async () => {
const c = mk()
await c.deleteSite('abcdefghij123456')
assert.equal(calls[0].method, 'DELETE')
const s = await c.getStats('abcdefghij123456', '7d')
const s = await c.getStats('abcdefghij123456', '7')
assert.deepEqual(s, { pv: 3 })
assert.equal(calls[1].url, '/a/admin/sites/abcdefghij123456/stats?range=7d')
assert.equal(calls[1].url, '/a/admin/stats/abcdefghij123456?range=7')
})
test('Fehler tragen status, ohne Token in der Meldung', async () => {
reset(() => ({ status: 500, body: { error: 'kaputt' } }))
await assert.rejects(mk().getStats('abcdefghij123456', '7d'), (e) => {
await assert.rejects(mk().getStats('abcdefghij123456', '7'), (e) => {
assert.equal(e.status, 500)
assert.ok(!e.message.includes('tok-secret'))
return true
@@ -71,7 +71,7 @@ test('Timeout wird als Fehler gemeldet', async () => {
const slow = http.createServer(() => {})
await new Promise((r) => slow.listen(0, '127.0.0.1', r))
const c = mk({ url: `http://127.0.0.1:${slow.address().port}`, timeoutMs: 50 })
await assert.rejects(c.getStats('abcdefghij123456', '7d'), (e) => {
await assert.rejects(c.getStats('abcdefghij123456', '7'), (e) => {
assert.ok(e.status >= 500)
assert.ok(!e.message.includes('tok-secret'))
return true
+62
View File
@@ -0,0 +1,62 @@
// Vertragstest: echtes Portal-Client gegen die echten Collector-Server (im Prozess, In-Memory-DB).
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { createAnalyticsClient } from '../server/services/analyticsClient.js'
import { createAdminServer, createPublicServer } from '../analytics/src/server.js'
import { openDb } from '../analytics/src/db.js'
const SITE = 'abcdef0123456789abcd'
const DOMAIN = 'kunde.example.com'
const config = { adminToken: 'contract-admin-token', heatSecret: 'contract-heat-secret', distDir: new URL('../analytics/client/dist', import.meta.url).pathname, baseHost: 'project.webklar.com' }
let db, admin, pub, client, pubUrl
const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`)))
before(async () => {
db = openDb(':memory:')
const adminServer = createAdminServer({ config, db })
admin = adminServer
pub = createPublicServer({ config, db })
const adminUrl = await listen(admin)
pubUrl = await listen(pub)
client = createAnalyticsClient({ url: adminUrl, adminToken: config.adminToken, heatSecret: config.heatSecret })
})
after(() => { admin.close(); pub.close() })
const post = (body) => fetch(`${pubUrl}/a/e`, {
method: 'POST', body: JSON.stringify({ site: SITE, ...body }),
headers: { 'Content-Type': 'text/plain', Origin: `https://${DOMAIN}`, 'X-Forwarded-For': '203.0.113.7', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile Safari' },
})
test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', async () => {
await client.putSite(SITE, { projectId: 'p1', domains: [DOMAIN], active: true })
await post({ t: 'pv', path: '/', ref: 'google.com', vp: 'd' })
await post({ t: 'pv', path: '/', ref: '', vp: 'd' })
await post({ t: 'pv', path: '/kontakt', ref: '', vp: 'd' })
await post({ t: 'ck', path: '/', vp: 'd', x: 0.25, y: 0.5, sel: 'body:nth-of-type(1) > a:nth-of-type(1)' })
await post({ t: 'ck', path: '/', vp: 'd', x: 0.75, y: 0.1, sel: 'body:nth-of-type(1) > a:nth-of-type(2)' })
await post({ t: 'ct', path: '/', kind: 'tel' })
await post({ t: 'bn', a: 'accept' })
const stats = await client.getStats(SITE, '7')
assert.equal(stats.totals.pv, 3)
assert.equal(stats.totals.contacts, 1)
assert.equal(stats.series.length, 7)
assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3)
assert.deepEqual(stats.pages, [{ key: '/', count: 2 }, { key: '/kontakt', count: 1 }])
assert.deepEqual(stats.refs, [{ key: '', count: 2 }, { key: 'google.com', count: 1 }])
assert.equal(stats.banner.accept, 1)
assert.ok(Array.isArray(stats.devices) && Array.isArray(stats.countries) && Array.isArray(stats.contacts))
const token = client.heatToken({ siteKey: SITE, path: '/', vp: 'd' })
const heat = await fetch(`${pubUrl}/a/heat?token=${encodeURIComponent(token)}`)
assert.equal(heat.status, 200)
const { points } = await heat.json()
assert.equal(points.length, 2)
assert.deepEqual(points.map((p) => p.x).sort(), [0.25, 0.75])
await client.deleteSite(SITE)
await assert.rejects(client.getStats(SITE, '7'), (e) => e.status === 404)
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events_raw').get().n, 0)
})