"Wieviel wann wo wie die Seite benutzt" - naheliegend wäre ein Analysewerkzeug gewesen, das eine Einwilligung braucht und damit genau die Besucher verliert, die ablehnen. Also andersherum: eine Messung, die niemanden wiedererkennt. Gespeichert wird ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain), kein Datensatz je Aufruf. Keine IP-Adresse, keine Kennung, kein Cookie - auch nicht gehasht, denn der Hash einer IP-Adresse bleibt ein Personenbezug. Ein Test liest die Spaltenliste der Tabelle und vergleicht sie mit dieser Zusage, damit sie beim nächsten Ausbau nicht unbemerkt fällt. Vom Verweis bleibt nur der Host: die volle Adresse kann einen Suchbegriff oder einen Namen tragen. Gezählt wird auf kernel.terminate, nach der Antwort - die Messung darf keine Anfrage verlangsamen, und ein Fehler beim Zählen kostet einen Zähler, nicht eine Seite. Nur GET auf HTML mit 200 oder 404; Verwaltung, Assets, Feed, Sitemap und Einwilligungsseite sind ausgenommen, erkennbare Maschinen über ihre Kennung. Das ist eine Heuristik, deshalb heißt die Zahl auf dem Bildschirm nicht "Besucher". Erfundene Crawler-Adressen laufen gesammelt unter (sonstige), sonst wächst die Tabelle mit jedem geprobten Pfad. Die Diagramme zeichnet der Server als SVG mit Klassen statt Stilattributen: kein Diagrammwerkzeug, kein Skript, und die Richtlinie bleibt streng. Die Zahlen stehen zusätzlich in einer Tabelle, weil ein Balken für einen Screenreader nichts ist. Was die Zahlen nicht sagen, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist die Folge der Entscheidung, nicht eine Lücke. Aufbewahrung: nach 60 Tagen werden die Stunden eines Tages verschmolzen, nach 425 Tagen gelöscht - gut ein Jahr, damit der Vorjahresmonat vergleichbar bleibt. cms:stats:prune macht beides und gehört in einen Cronjob. 279 PHP-Tests / 1119 Assertions unter PHP 8.2.12 und 8.4.22, axe auf zwölf Verwaltungsbildschirmen ohne Verstöße. Dabei kam eine PHP-8.4-Verwarnung zu fputcsv() heraus, jetzt mit ausdrücklichem Escape-Parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
6.8 KiB
Twig
112 lines
6.8 KiB
Twig
{% extends 'admin/shell.html.twig' %}
|
||
{% set nav = 'statistics' %}
|
||
{% block title %}Statistik{% endblock %}
|
||
{% block heading %}Statistik{% endblock %}
|
||
{% block lead %}Gezählt wird auf dem Server, ohne Cookie und ohne IP-Adresse – deshalb braucht diese Messung keine Einwilligung. Zeiten in UTC.{% endblock %}
|
||
{% block actions %}
|
||
<a class="secondary" href="{{ path('admin_statistics_csv', {tage: range}) }}">Als CSV</a>
|
||
{% endblock %}
|
||
{% block content %}
|
||
|
||
<nav class="range-switch" aria-label="Zeitraum">
|
||
{% for days, label in ranges %}
|
||
<a class="{{ days == range ? 'primary' : 'secondary' }}" href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||
{% endfor %}
|
||
</nav>
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2>Seitenaufrufe <span class="count">{{ stats.total }}</span></h2>
|
||
<span class="muted">ab {{ stats.from|date('d.m.Y') }}{% if stats.total > 0 %} · im Schnitt {{ (stats.total / stats.range)|number_format(stats.total / stats.range < 10 ? 1 : 0, ',', '.') }} am Tag{% endif %}</span>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if stats.total == 0 %}
|
||
<p class="muted">Noch keine Aufrufe in diesem Zeitraum. Gezählt wird erst, wenn eine veröffentlichte Seite tatsächlich abgerufen wird – Aufrufe der Verwaltung und erkennbare Suchmaschinen zählen nicht mit.</p>
|
||
{% else %}
|
||
{# Drawn by the server as an SVG: no chart library, no script, and it prints. The bars are
|
||
coloured by class, not by a style attribute, because inline styles are forbidden here.
|
||
The numbers themselves are in the table below, which is what a screen reader reads. #}
|
||
<svg class="chart" viewBox="0 0 {{ stats.days|length * 12 }} 116" role="img"
|
||
aria-label="{{ stats.total }} Aufrufe zwischen {{ stats.from|date('d.m.Y') }} und heute, höchster Tageswert {{ stats.busiest }}" preserveAspectRatio="none">
|
||
{% for entry in stats.days %}
|
||
{% set height = entry.count > 0 ? (entry.count / stats.busiest * 100)|round(1) : 0 %}
|
||
<rect class="chart-bar" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||
{% endfor %}
|
||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ stats.days|length * 12 }}" y2="100.5"></line>
|
||
</svg>
|
||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>höchster Tag: {{ stats.busiest }}</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||
|
||
<details class="chart-table">
|
||
<summary>Zahlen je Tag</summary>
|
||
<div class="table-wrap">
|
||
<table class="table">
|
||
<caption class="visually-hidden">Aufrufe je Tag im gewählten Zeitraum</caption>
|
||
<thead><tr><th scope="col">Tag</th><th scope="col">Aufrufe</th></tr></thead>
|
||
<tbody>
|
||
{% for entry in stats.days|reverse %}
|
||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ entry.count }}</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{% if stats.total > 0 %}
|
||
<div class="card">
|
||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde, nur die letzten {{ compactAfter }} Tage sind stundengenau</span></div>
|
||
<div class="card-body">
|
||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||
{% set busiestHour = max(stats.hours|map(entry => entry.count)|filter(count => count > 0)|default([1])) %}
|
||
{% for entry in stats.hours %}
|
||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||
<rect class="chart-bar" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||
{% endfor %}
|
||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||
</svg>
|
||
<p class="chart-legend"><span>0 Uhr</span><span>12 Uhr</span><span>23 Uhr</span></p>
|
||
<p class="field-help">Ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="stats-columns">
|
||
{% for group in [
|
||
{title: 'Seiten', hint: 'Meistaufgerufen', rows: stats.pages, key: 'path', link: true},
|
||
{title: 'Verweise von außen', hint: 'Domain, die verlinkt hat', rows: stats.referrers, key: 'referrer_host', link: false},
|
||
{title: 'Nicht gefundene Adressen', hint: 'Kandidaten für eine Weiterleitung', rows: stats.missing, key: 'path', link: false}
|
||
] %}
|
||
<div class="card">
|
||
<div class="card-head"><h2>{{ group.title }}</h2><span class="muted">{{ group.hint }}</span></div>
|
||
<div class="card-body">
|
||
{% if group.rows is empty %}
|
||
<p class="muted">Keine Daten im Zeitraum.</p>
|
||
{% else %}
|
||
<ol class="stats-list">
|
||
{% for row in group.rows %}
|
||
<li>
|
||
{% if group.link %}<a href="{{ row[group.key] }}">{{ row[group.key] }}</a>{% else %}<span>{{ row[group.key] }}</span>{% endif %}
|
||
<strong>{{ row.count }}</strong>
|
||
</li>
|
||
{% endfor %}
|
||
</ol>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h2>Was diese Zahlen nicht sagen</h2><span class="muted">Ehrlich statt vollständig</span></div>
|
||
<div class="card-body">
|
||
<p class="muted">Gezählt werden <strong>Aufrufe</strong>, nicht Besucher: ohne Wiedererkennung lässt sich nicht sagen, ob zehn Aufrufe von zehn Personen oder von einer kommen. Es gibt deshalb auch keine Verweildauer, keine Absprungrate, keinen Klickweg durch die Website und kein Land. All das braucht ein Merkmal, das eine Person wiedererkennbar macht – und damit eine Einwilligung.</p>
|
||
<p class="field-help">Erkennbare Suchmaschinen und Überwachungsdienste werden anhand ihrer Kennung ausgeschlossen. Das ist eine Heuristik: wer sich als Browser ausgibt, wird mitgezählt. Die Zahl ist also „Aufrufe, die sich nicht als Maschine gemeldet haben“, nicht „Menschen“. Erfundene Adressen von Crawlern laufen gesammelt unter <code>(sonstige)</code>, damit die Tabelle nicht mit tausenden Zeilen wächst.</p>
|
||
<p class="field-help">Aufbewahrt wird {{ keepDays }} Tage, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. Danach wird gelöscht.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||
{% endblock %}
|