Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1// Property report rendered by minijinja into Typst markup, then compiled to
2// PDF by src/pdf.rs::PdfRenderer. Mirrors the shape of property_print.html
3// (the chromium-era template) so the dashboard ?report=pdf output stays
4// recognisable.
5
6#let dim = rgb("#555")
7#let muted = rgb("#888")
8#let mono = ("JetBrains Mono", "DejaVu Sans Mono", "Liberation Mono")
9
10#set page(
11 paper: "a4",
12 margin: (top: 14mm, bottom: 18mm, left: 14mm, right: 14mm),
13 footer: context {
14 set text(size: 7.5pt, fill: dim)
15 grid(
16 columns: (1fr, auto),
17 align: (left + horizon, right + horizon),
18 [Analytics · self-hosted{% if base_url %} · {{ base_url | typst_md }}{% endif %} · {{ property.name | typst_md }} · {{ date_start | typst_md }} → {{ date_end | typst_md }}],
19 [Page #counter(page).display() of #counter(page).final().first()],
20 )
21 },
22)
23
24#set text(
25 font: ("DejaVu Sans", "Liberation Sans", "Arial"),
26 size: 9.5pt,
27 fill: black,
28)
29
30#set par(leading: 0.5em, justify: false)
31
32#show heading.where(level: 1): set text(size: 22pt, weight: "bold")
33#show heading.where(level: 1): set block(above: 8pt, below: 4pt)
34
35#show heading.where(level: 2): it => block(
36 above: 16pt,
37 below: 6pt,
38 width: 100%,
39 stroke: (bottom: 0.6pt + black),
40 inset: (bottom: 3pt),
41)[#text(size: 11pt, weight: "bold", tracking: 0.6pt, upper(it.body))]
42
43#show heading.where(level: 3): it => block(
44 above: 8pt,
45 below: 3pt,
46)[#text(size: 8.5pt, weight: "bold", tracking: 0.5pt, fill: rgb("#333"), upper(it.body))]
47
48// Header strip
49#grid(
50 columns: (1fr, auto),
51 align: (left + top, right + top),
52 text(size: 8.5pt, tracking: 0.9pt, fill: dim, upper("// Analytics · property report")),
53 text(size: 8pt, fill: dim)[Generated {{ generated_at | typst_md }}],
54)
55
56= {{ property.name | typst_md }}
57
58// Meta dl: 2-col, with thin rules above and below.
59#block(
60 above: 8pt,
61 below: 0pt,
62 width: 100%,
63 stroke: (top: 0.6pt + black, bottom: 0.6pt + black),
64 inset: (top: 6pt, bottom: 6pt),
65)[
66 #grid(
67 columns: (1fr, 1fr),
68 column-gutter: 16pt,
69 row-gutter: 4pt,
70 [
71 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Property ID")) \
72 #text(font: mono, size: 8.5pt)[{{ property.id | typst_md }}]
73 ],
74 [
75 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Operator")) \
76 #text(weight: "semibold")[operator]
77 ],
78 [
79 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Date range")) \
80 #text(weight: "semibold")[{{ date_start | typst_md }} → {{ date_end | typst_md }}] #text(fill: dim)[ ({{ date_range }} days)]
81 ],
82 [
83 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Live users · last 30m")) \
84 #text(weight: "semibold")[{{ total_live_users }}]
85 ],
86 )
87]
88
89{% if filter_url %}
90#v(4pt)
91#box(stroke: 0.6pt + black, inset: (x: 6pt, y: 2pt))[
92 #text(size: 8pt)[*Filter · url:* #text(font: mono)[{{ filter_url | typst_md }}]]
93]
94{% endif %}
95
96== Metrics · period vs previous
97
98#grid(
99 columns: (1fr, 1fr, 1fr, 1fr),
100 gutter: 4pt,
101 {% for card in event_cards %}
102 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
103 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[{{ card.name | typst_md }}]
104 #v(2pt)
105 #text(size: 14pt, weight: "bold")[{{ card.value | typst_md }}]
106 #v(1pt)
107 {% if card.percent_change == 0 %}
108 #text(size: 7.5pt, fill: rgb("#333"))[· no change]
109 {% elif card.percent_change < 0 %}
110 #text(size: 7.5pt, fill: rgb("#333"))[▼ {{ card.percent_change }}% vs previous]
111 {% else %}
112 #text(size: 7.5pt, fill: rgb("#333"))[▲ +{{ card.percent_change }}% vs previous]
113 {% endif %}
114 ],
115 {% endfor %}
116)
117
118== Events over time
119
120{% if chart_polyline %}
121// Two-row grid: row 1 is Y-axis labels (peak count top, 0 bottom) next to
122// the SVG line; row 2 is the X-axis date strip aligned under the SVG only.
123#grid(
124 columns: (auto, 1fr),
125 column-gutter: 4pt,
126 rows: (90pt, auto),
127 align: (right + horizon, left + horizon),
128 block(height: 90pt)[
129 #place(top + right)[#text(size: 6.5pt, fill: dim)[{{ chart_peak_count }}]]
130 #place(bottom + right)[#text(size: 6.5pt, fill: dim)[0]]
131 ],
132 image(
133 bytes("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 100' preserveAspectRatio='none'><line x1='0' y1='99.5' x2='600' y2='99.5' stroke='black' stroke-width='0.4'/><polyline fill='none' stroke='black' stroke-width='0.9' stroke-linejoin='round' stroke-linecap='round' points='{{ chart_polyline }}'/></svg>"),
134 format: "svg",
135 width: 100%,
136 height: 90pt,
137 ),
138 [],
139 grid(
140 columns: (1fr, 1fr, 1fr),
141 align: (left, center, right),
142 text(size: 7.5pt, fill: dim)[{{ chart_label_start | typst_md }}],
143 text(size: 7.5pt, fill: dim)[peak {{ chart_peak_count }} · {{ chart_peak_label | typst_md }}],
144 text(size: 7.5pt, fill: dim)[{{ chart_label_end | typst_md }}],
145 ),
146)
147{% else %}
148#text(size: 8pt, fill: muted, style: "italic")[No events in this range.]
149{% endif %}
150
151// breakable: false keeps each column's table atomic across pages -- a column
152// either fits on the current page or moves to the next as a unit. Avoids the
153// orphan-row-with-re-printed-headers we got when Typst broke a table across
154// page boundaries inside a multi-column grid.
155#let label_count_table(items, label_header: "Label", count_header: "Count", label_mono: false) = {
156 block(breakable: false, table(
157 columns: (1fr, auto),
158 align: (left + top, right + top),
159 inset: (x: 3pt, y: 2pt),
160 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
161 table.header(
162 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper(label_header)),
163 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper(count_header)),
164 ),
165 ..items,
166 ))
167}
168
169#grid(
170 columns: (1fr, 1fr, 1fr),
171 column-gutter: 10pt,
172 row-gutter: 8pt,
173 {% if total_page_views_by_page_url %}
174 [
175 === Top pages · page views
176 #label_count_table(
177 label_header: "URL", count_header: "Views",
178 (
179 {% for item in total_page_views_by_page_url %}
180 text(font: mono, size: 7pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
181 {% endfor %}
182 ),
183 )
184 ],
185 {% endif %}
186 {% if total_events_by_page_url %}
187 [
188 === Top pages · all events
189 #label_count_table(
190 label_header: "URL", count_header: "Events",
191 (
192 {% for item in total_events_by_page_url %}
193 text(font: mono, size: 7pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
194 {% endfor %}
195 ),
196 )
197 ],
198 {% endif %}
199 {% if total_session_starts_by_referrer %}
200 [
201 === Top referrers
202 #label_count_table(
203 label_header: "Referrer", count_header: "Sessions",
204 (
205 {% for item in total_session_starts_by_referrer %}
206 text(font: mono, size: 7pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
207 {% endfor %}
208 ),
209 )
210 ],
211 {% endif %}
212 {% if total_events_by_custom_event %}
213 [
214 === Top custom events
215 #label_count_table(
216 label_header: "Event", count_header: "Count",
217 (
218 {% for item in total_events_by_custom_event %}
219 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
220 {% endfor %}
221 ),
222 )
223 ],
224 {% endif %}
225)
226
227== Visitor breakdown
228
229#let breakdown_table(items, label_header: "Type", count_header: "Events", total: 1) = {
230 block(breakable: false, table(
231 columns: (1fr, auto, auto),
232 align: (left + top, right + top, right + top),
233 inset: (x: 3pt, y: 2pt),
234 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
235 table.header(
236 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper(label_header)),
237 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper(count_header)),
238 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("%")),
239 ),
240 ..items,
241 ))
242}
243
244#grid(
245 columns: (1fr, 1fr, 1fr),
246 column-gutter: 10pt,
247 row-gutter: 8pt,
248 {% if total_events_by_device %}
249 [
250 === Device
251 #breakdown_table(
252 label_header: "Type", count_header: "Events",
253 (
254 {% for item in total_events_by_device %}
255 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}], text(size: 7.5pt, fill: dim)[{{ ((item.count * 100) / breakdown_totals.device) | int }}%],
256 {% endfor %}
257 ),
258 )
259 ],
260 {% endif %}
261 {% if total_events_by_browser %}
262 [
263 === Browser
264 #breakdown_table(
265 label_header: "Name", count_header: "Events",
266 (
267 {% for item in total_events_by_browser %}
268 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}], text(size: 7.5pt, fill: dim)[{{ ((item.count * 100) / breakdown_totals.browser) | int }}%],
269 {% endfor %}
270 ),
271 )
272 ],
273 {% endif %}
274 {% if total_events_by_platform %}
275 [
276 === Platform
277 #breakdown_table(
278 label_header: "Name", count_header: "Events",
279 (
280 {% for item in total_events_by_platform %}
281 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}], text(size: 7.5pt, fill: dim)[{{ ((item.count * 100) / breakdown_totals.platform) | int }}%],
282 {% endfor %}
283 ),
284 )
285 ],
286 {% endif %}
287 {% if total_events_by_screen_size %}
288 [
289 === Screen size
290 #breakdown_table(
291 label_header: "Size", count_header: "Events",
292 (
293 {% for item in total_events_by_screen_size %}
294 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}], text(size: 7.5pt, fill: dim)[{{ ((item.count * 100) / breakdown_totals.screen_size) | int }}%],
295 {% endfor %}
296 ),
297 )
298 ],
299 {% endif %}
300)
301
302{% if top_countries %}
303== Geography
304
305#grid(
306 columns: (1fr, 1fr),
307 column-gutter: 14pt,
308 [
309 === Top countries
310 #label_count_table(
311 label_header: "Country", count_header: "Sessions",
312 (
313 {% for item in top_countries %}
314 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
315 {% endfor %}
316 ),
317 )
318 ],
319 [],
320)
321{% endif %}
322
323{% if total_page_views_by_utm_source or total_page_views_by_utm_medium or total_page_views_by_utm_campaign %}
324== UTM attribution
325
326#grid(
327 columns: (1fr, 1fr, 1fr),
328 column-gutter: 10pt,
329 {% if total_page_views_by_utm_source %}
330 [
331 === Source
332 #label_count_table(
333 label_header: "Source", count_header: "Views",
334 (
335 {% for item in total_page_views_by_utm_source %}
336 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
337 {% endfor %}
338 ),
339 )
340 ],
341 {% endif %}
342 {% if total_page_views_by_utm_medium %}
343 [
344 === Medium
345 #label_count_table(
346 label_header: "Medium", count_header: "Views",
347 (
348 {% for item in total_page_views_by_utm_medium %}
349 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
350 {% endfor %}
351 ),
352 )
353 ],
354 {% endif %}
355 {% if total_page_views_by_utm_campaign %}
356 [
357 === Campaign
358 #label_count_table(
359 label_header: "Campaign", count_header: "Views",
360 (
361 {% for item in total_page_views_by_utm_campaign %}
362 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
363 {% endfor %}
364 ),
365 )
366 ],
367 {% endif %}
368)
369{% endif %}
370
371== Bot traffic
372#v(-4pt)
373#text(size: 8pt, fill: dim)[Excluded from metrics above.]
374
375{% if bot_traffic.total > 0 %}
376#grid(
377 columns: (1fr, 1fr),
378 column-gutter: 14pt,
379 [
380 === Top bots
381 #label_count_table(
382 label_header: "Bot", count_header: "Events",
383 (
384 text(size: 7.5pt, weight: "bold")[Total], text(size: 7.5pt, weight: "bold")[{{ bot_traffic.total }}],
385 {% for item in bot_traffic.top_bots %}
386 text(size: 7.5pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
387 {% endfor %}
388 ),
389 )
390 ],
391 {% if bot_traffic.top_pages %}
392 [
393 === Top pages hit by bots
394 #label_count_table(
395 label_header: "URL", count_header: "Events",
396 (
397 {% for item in bot_traffic.top_pages %}
398 text(font: mono, size: 7pt)[{{ item.label | typst_md }}], text(size: 7.5pt)[{{ item.count }}],
399 {% endfor %}
400 ),
401 )
402 ],
403 {% else %}
404 [],
405 {% endif %}
406)
407{% else %}
408#text(size: 8pt, fill: muted, style: "italic")[No bot events in this range.]
409{% endif %}