repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

12.2 KB · 410 lines · Typst Raw History
  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 %}