Analytics
What the built-in, cookieless analytics collects and how (server-side views, the beacon, tracked links, impressions), why it needs no consent banner, the template helpers, dashboards in analytics/dashboards.yaml, exports, retention and every analytics setting.
Meridian measures its own sites. Page views are recorded on the server as each page renders; outbound clicks, phone reveals and card impressions are recorded through first-party links and a small script the engine serves. Everything is stored in your own database, aggregated into hourly, daily and monthly rollups, and read on the admin’s Insight screens, in exports and in reports.
This is how an event travels from a visitor to a figure:
flowchart LR
subgraph collect["Collection"]
render["Page render<br/>(server)"]
beacon["/_e beacon<br/>(analytics script)"]
tracked["/r/v1 tracked link"]
api["Content API events<br/>(headless)"]
end
collect --> filter{"bots, exclude_ips,<br/>DNT"}
filter --> raw[("Raw events<br/>monthly partitions")]
raw --> rollup["Hourly, daily and<br/>monthly rollups"]
rollup --> insight["Insight dashboards"]
rollup --> export["Exports"]
rollup --> reports["Member and<br/>organisation reports"]The design is privacy first: no cookies, no IP address stored, no user id, no fingerprint. Because no personal data is processed and no cookie is set, the analytics needs no consent banner.
What you get with no work
Analytics is on by default (analytics.enabled: true). With nothing in your templates, every page render records a page_view, every search records its query, and index pages and search filters record what was filtered. Staff with analytics.view see the Insight overview for the site.
To record interactions as well, print the client in your layout:
{{ analytics() }}
</body> It writes one deferred script tag (/_e.js) and nothing in a preview. The script records card impressions and marked interactions, batched into one request to /_e. Without JavaScript nothing it measures is recorded; server-side views and tracked links still work.
What is collected
| Event | Recorded when | How |
|---|---|---|
page_view | A page or index page renders | Server, as the page renders |
listing_view, event_view | A page of type listing or event renders | Server, beside page_view |
newsletter_click | A visitor lands on a document from a link with utm_medium=email or newsletter | Server |
search, search_zero, search_click | A search, a search with no results, a result chosen (with its position) | Server, through the search route |
filter_apply | A search or index page is filtered (the filter keys, never typed text) | Server, or the client for an element marked data-mrd-filter |
website_click, booking_click, phone_reveal, email_click, directions_click, social_click, event_click, download, share | A tracked link is followed | A first-party redirect from track() or reveal(); the client for marked elements |
listing_impression | A card marked data-mrd-impression is half in view for half a second | The client |
offer_impression, promotion_impression, widget_impression, enquiry_submitted, affiliate_click, portal_login, claim_submitted | DMO bundle features | See the DMO bundle |
Every event carries: the time, the site, the locale, the source channel (organic, social, referral, campaign, internal or direct), the medium (the referring host or the campaign), the campaign (utm_campaign, normalised), the device class, the country when a trusted proxy supplies it, and two keys. The keys are an HMAC of the address, user agent and site under a salt that changes every UTC day; the previous day’s salt is deleted when the next is made, so nothing links across days and nothing can be reversed. A visit is one visitor key’s day. Each event of a visit is attributed to the source of the visit’s first event, so no cookie is needed to carry it.
Not collected: the IP address, any cookie, any user or member id, any free text a visitor typed (search queries are kept in the separate search log, without identity), cross-day identity. So there is no returning-visitor metric, no multi-session funnel and no cohort retention.
Filtering
- Bots.
bot_filtering: standarddrops requests whose user agent is on the built-in crawler list;strict(the default) also drops requests claiming to be a browser without anAcceptheader;offkeeps everything. - Your own office.
exclude_ipslists addresses and CIDR ranges that record nothing. They are compared in memory and never stored. - Do Not Track. With
respect_dnt: true, a request withDNT: 1orSec-GPC: 1records nothing. - Repeat views. Repeat views of the same subject by the same visitor key within
dedupe_window(30 minutes) count once when events are rolled up. - Previews and tooling. Previews,
engine-dmo renderand the warm job record nothing.
Template helpers
Tracked outbound links
track(entry, kind, url) returns a first-party, signed /r/v1/ address that records the click and redirects to the target. It works without JavaScript, cannot be spoofed and cannot be used as an open redirect. An empty target returns an empty string, so a template can pass a field that may be absent. Like every address in an href, print it through the url filter; engine-dmo check refuses one that is not. The target may be a string or a link value (its url).
{% if entry.website %}<a href="{{ track(entry, 'website', entry.website)|url }}">Visit the website</a>{% endif %}
{% if entry.bookingUrl.url %}<a href="{{ track(entry, 'booking', entry.bookingUrl.url)|url }}">Book</a>{% endif %} kind | Records |
|---|---|
website (default) | website_click |
booking | booking_click |
directions | directions_click |
social | social_click |
event | event_click |
download | download |
share | share |
reveal(entry, kind, value) does the same for a telephone number (kind phone, making a tel: link) or an email address (email, a mailto: link):
{% if entry.phone %}<a href="{{ reveal(entry, 'phone', entry.phone)|url }}">Call {{ entry.phone }}</a>{% endif %} The target must be an ordinary web, mail or telephone address. The entry’s organisation is stamped on the event (DMO bundle), so a business’s figures survive a listing moving between organisations.
Marked elements
With {{ analytics() }} on the page:
| Markup | Records |
|---|---|
data-mrd-impression="<document id>" on a card | listing_impression once per page when half of it has been in view for 500ms |
<a> or <button> with data-mrd-event="<event>" and data-mrd-subject="<id>" (and data-mrd-subject-type when the subject is not a listing) | That event when clicked, for an interaction that does not leave the page |
data-mrd-filter="<key>" on a filter control | filter_apply when it is turned on; the key is yours, never what was typed |
The beacon accepts only interaction events (impressions, clicks, reveals, shares, downloads, filters and search events); page views cannot be reported through it. Cards patched into the page later are watched too.
Insight and dashboards
The admin’s Insight screen shows a site’s dashboard over a date range with a comparison. Without a dashboards file, the overview shows visits, page views, listing views, member referrals, page views over time, the top listings and the top zero-result searches.
Declare your own dashboards, and the figures staff enter by hand, in config/analytics/dashboards.yaml:
# config/analytics/dashboards.yaml
metrics:
- { key: box_office_visits, kind: entered, label: { en: Box office visitors }, period: month, unit: people }
dashboards:
- key: default
name: { en: Overview }
default_range: 30d
compare: previous_year
widgets:
- { type: metric, metric: visits, size: 1 }
- { type: metric, metric: page_views, size: 1 }
- { type: metric, metric: box_office_visits, size: 1 }
- { type: timeseries, metric: page_views, breakdown: source, interval: day, size: 4 }
- { type: table, metric: page_views, dimension: page, limit: 10, size: 2 }
- { type: table, metric: search_zero, dimension: query, limit: 10, size: 2, highlight: true }
- { type: funnel, steps: [page_views, downloads], size: 2 } Run engine-dmo check --fix to write the entered metric’s id.
Dashboard keys
| Key | Meaning |
|---|---|
key | Required, unique. default replaces the overview. |
name | Per locale. |
default_range | today, 7d, 30d, 90d, 12m, this_month, previous_month, this_quarter, previous_quarter, this_financial_year, previous_financial_year, previous_year. |
compare | previous_year, previous_period or none. |
widgets | At least one. |
Widget keys
| Key | Meaning |
|---|---|
type | metric (one figure), table (a metric broken down by a dimension), timeseries (over time), funnel (two or more counted steps), list. |
metric | A metric key. Not on a funnel. |
dimension | A table’s breakdown: one the metric allows. |
breakdown | A timeseries split by source or device. |
interval | A timeseries’ day or month. |
steps | A funnel’s metrics, in order, at least two counted events. |
limit | Rows, 1 to 100. |
order | top or bottom. |
size | Columns, 1 to 4. |
highlight | Draw attention to the widget. |
label | Per locale, in place of the metric’s name. |
Metrics
| Metric | Dimensions |
|---|---|
visits | source, device, locale |
page_views | page, source, device, locale, campaign |
listing_impressions | listing, source |
listing_views | listing, source, device, campaign |
event_views | event, source |
event_clicks | event |
downloads | |
shares | page |
widget_impressions | listing |
newsletter_clicks | listing, campaign |
website_clicks | listing, source, campaign |
phone_reveals, email_clicks, directions_clicks, booking_clicks, social_clicks | listing, source |
enquiries | listing, source, campaign |
member_referrals_total | listing, referral_type, source, campaign |
searches, search_zero | query |
search_unclicked | query |
search_clicks, search_ctr, search_position |
The DMO bundle’s memberships module adds these:
| Metric | Value | Dimensions |
|---|---|---|
members_active | members at the end of the range | tier |
members_new, members_lapsed | count | |
renewal_rate | a share, shown as a percentage | |
renewals_due | due at the end of the range | |
referrals_per_member | an average | |
members_zero_referrals | a list of members with no referrals, for a list widget |
That is the whole catalogue: these keys are what a dashboard widget, an organisation report’s KPI and a funnel step may name, besides your entered metrics. A funnel’s steps must be counted events: the metrics from page_views to enquiries in the first table. No command prints the catalogue; this table is it for this version. A widget naming a metric the bundle does not compile is refused. engine-dmo check reports every mistake at its line: an unknown metric (with a suggestion), a dimension the metric does not have, a funnel with one step, a size or limit out of range.
Entered metrics
A figure no website measures, such as visitor numbers from a survey, is declared under metrics: with kind: entered:
| Key | Meaning |
|---|---|
key | Its own key; not one the engine measures. |
label | Required, per locale. |
period | month, quarter, year or financial_year. |
unit | Shown with the figure. |
description | Per locale. |
Staff with analytics.enter record a value per period on the Insight screen’s entered figures page. An entered figure is always shown as entered, never as measured.
Exports
The Insight screen exports the dashboard on screen as CSV or JSON (analytics.export). The command line does the same:
engine-dmo analytics export --site main --range 30d --format csv --out overview.csv
engine-dmo analytics export --site main --metric page_views --from 2026-09-01 --to 2026-09-30 --format json
engine-dmo analytics export --dashboard board --range this_financial_year --compare previous_year --as analyst@example.org | Flag | Meaning |
|---|---|
--site | Site key (default: the default site). |
--dashboard | A dashboard key (default: the overview). |
--metric | One metric instead, as a single tile. |
--range | A preset, as default_range. |
--from, --to | A custom range, YYYY-MM-DD, inclusive, in the site’s time zone. |
--compare | previous_year, previous_period or none. |
--format | csv (default) or json. |
--out | A file; standard output when empty or -. |
--as | Export as this staff account, which needs analytics.export on the site. |
Every export is audited.
Retention and rollups
Raw events are kept for raw_retention_days (90) in monthly partitions, dropped a whole month at a time once every row in it is past the window. Hourly rollups and the search log are kept for 400 days. Daily and monthly rollups are aggregated, identify nobody and are kept. Rollups and retention run on the engine’s scheduler; the commands run them by hand:
| Command | What it does |
|---|---|
analytics rollup --from --to | Roll raw events into the hourly, daily and monthly tables for a range. |
analytics recompute --from --to | Rebuild every rollup level from raw events for a range, under the rules in force now. Idempotent. |
analytics prune [--dry-run] | Apply retention. |
analytics partitions [--drop-expired] | List the monthly raw partitions, create them ahead, drop expired ones. |
analytics collection --site --days | The events a site received by collection path (rendered, beacon, api) over the last 1 to 90 days, and a headless site’s collection health. |
The DMO bundle adds member reports, the monthly digest and scheduled organisation reports (Member reports).
Headless sites
A headless frontend has no server-rendered views, so the content API hands it a signed view token with each document it resolves. The frontend reports the view through the beacon (proxied under its own domain) or from its server through the content API’s events operation, and the engine records exactly the events a rendered page would. See the content API.
Permissions
| Permission | Allows | Built-in roles |
|---|---|---|
analytics.view | The Insight screens | Administrator, Editor, Analyst, Auditor (and Partnership manager in the DMO bundle) |
analytics.export | Exports | Administrator, Analyst (and Partnership manager) |
analytics.enter | Recording entered figures | Administrator, Analyst |
Configuration reference
Every key is under analytics: in config/engine.yaml.
| Key | Default | Meaning |
|---|---|---|
enabled | true | Record anything at all. Off, analytics() prints nothing. |
ingest_buffer | 10000 | Events held in memory before writing; a full buffer drops events and counts the drops, never slowing a page. |
salt_rotation | daily | The only value. |
raw_retention_days | 90 | 30 to 400. |
hourly_retention_days | 400 | At least 30. |
search_log_retention_days | 400 | At least 30. |
reporting_timezone | UTC | The zone for a site whose site.yaml sets no timezone. |
financial_year_start | from the default site’s country | The month, 1 to 12, the financial year starts in. |
bot_filtering | strict | off, standard or strict. |
dedupe_window | 30m | |
respect_dnt | false | Honour DNT: 1 and Sec-GPC: 1. |
exclude_ips | Addresses and CIDR ranges, separated by commas or spaces. | |
country_header | The header a trusted proxy or CDN writes the visitor’s country into, such as CF-IPCountry. Read only from server.trusted_proxies, and refused without them. Empty records no country. | |
statement_timeout | 10s | Bounds report and tile queries; must be below database.statement_timeout. |
pdf.renderer | auto | auto, browser or builtin: how a report becomes a PDF (DMO bundle reports). |
pdf.browser_binary | A Chromium binary; empty searches the path. | |
pdf.timeout | 1m |
Redirects and URLs
How addresses stay stable: slugs, the automatic redirects on a slug change, trash or move, redirect and 410 rules in routes.yaml, importing a CSV of redirects from an old site, the order rules are matched in, and how missing pages are recorded.
AI assistance
How to connect your own AI provider (Azure OpenAI, OpenAI, Anthropic, Mistral or any OpenAI-compatible endpoint), which authoring tasks AI drafts and where, the review queue, budgets and the data boundary, prompt overrides, public disclosure and the ai commands.