Meridian handbook
Features

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

EventRecorded whenHow
page_viewA page or index page rendersServer, as the page renders
listing_view, event_viewA page of type listing or event rendersServer, beside page_view
newsletter_clickA visitor lands on a document from a link with utm_medium=email or newsletterServer
search, search_zero, search_clickA search, a search with no results, a result chosen (with its position)Server, through the search route
filter_applyA 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, shareA tracked link is followedA first-party redirect from track() or reveal(); the client for marked elements
listing_impressionA card marked data-mrd-impression is half in view for half a secondThe client
offer_impression, promotion_impression, widget_impression, enquiry_submitted, affiliate_click, portal_login, claim_submittedDMO bundle featuresSee 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: standard drops requests whose user agent is on the built-in crawler list; strict (the default) also drops requests claiming to be a browser without an Accept header; off keeps everything.
  • Your own office. exclude_ips lists addresses and CIDR ranges that record nothing. They are compared in memory and never stored.
  • Do Not Track. With respect_dnt: true, a request with DNT: 1 or Sec-GPC: 1 records 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 render and the warm job record nothing.

Template helpers

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 %}
kindRecords
website (default)website_click
bookingbooking_click
directionsdirections_click
socialsocial_click
eventevent_click
downloaddownload
shareshare

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:

MarkupRecords
data-mrd-impression="<document id>" on a cardlisting_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 controlfilter_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

KeyMeaning
keyRequired, unique. default replaces the overview.
namePer locale.
default_rangetoday, 7d, 30d, 90d, 12m, this_month, previous_month, this_quarter, previous_quarter, this_financial_year, previous_financial_year, previous_year.
compareprevious_year, previous_period or none.
widgetsAt least one.

Widget keys

KeyMeaning
typemetric (one figure), table (a metric broken down by a dimension), timeseries (over time), funnel (two or more counted steps), list.
metricA metric key. Not on a funnel.
dimensionA table’s breakdown: one the metric allows.
breakdownA timeseries split by source or device.
intervalA timeseries’ day or month.
stepsA funnel’s metrics, in order, at least two counted events.
limitRows, 1 to 100.
ordertop or bottom.
sizeColumns, 1 to 4.
highlightDraw attention to the widget.
labelPer locale, in place of the metric’s name.

Metrics

MetricDimensions
visitssource, device, locale
page_viewspage, source, device, locale, campaign
listing_impressionslisting, source
listing_viewslisting, source, device, campaign
event_viewsevent, source
event_clicksevent
downloads
sharespage
widget_impressionslisting
newsletter_clickslisting, campaign
website_clickslisting, source, campaign
phone_reveals, email_clicks, directions_clicks, booking_clicks, social_clickslisting, source
enquirieslisting, source, campaign
member_referrals_totallisting, referral_type, source, campaign
searches, search_zeroquery
search_unclickedquery
search_clicks, search_ctr, search_position

The DMO bundle’s memberships module adds these:

MetricValueDimensions
members_activemembers at the end of the rangetier
members_new, members_lapsedcount
renewal_ratea share, shown as a percentage
renewals_duedue at the end of the range
referrals_per_memberan average
members_zero_referralsa 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:

KeyMeaning
keyIts own key; not one the engine measures.
labelRequired, per locale.
periodmonth, quarter, year or financial_year.
unitShown with the figure.
descriptionPer 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
FlagMeaning
--siteSite key (default: the default site).
--dashboardA dashboard key (default: the overview).
--metricOne metric instead, as a single tile.
--rangeA preset, as default_range.
--from, --toA custom range, YYYY-MM-DD, inclusive, in the site’s time zone.
--compareprevious_year, previous_period or none.
--formatcsv (default) or json.
--outA file; standard output when empty or -.
--asExport 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:

CommandWhat it does
analytics rollup --from --toRoll raw events into the hourly, daily and monthly tables for a range.
analytics recompute --from --toRebuild 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 --daysThe 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

PermissionAllowsBuilt-in roles
analytics.viewThe Insight screensAdministrator, Editor, Analyst, Auditor (and Partnership manager in the DMO bundle)
analytics.exportExportsAdministrator, Analyst (and Partnership manager)
analytics.enterRecording entered figuresAdministrator, Analyst

Configuration reference

Every key is under analytics: in config/engine.yaml.

KeyDefaultMeaning
enabledtrueRecord anything at all. Off, analytics() prints nothing.
ingest_buffer10000Events held in memory before writing; a full buffer drops events and counts the drops, never slowing a page.
salt_rotationdailyThe only value.
raw_retention_days9030 to 400.
hourly_retention_days400At least 30.
search_log_retention_days400At least 30.
reporting_timezoneUTCThe zone for a site whose site.yaml sets no timezone.
financial_year_startfrom the default site’s countryThe month, 1 to 12, the financial year starts in.
bot_filteringstrictoff, standard or strict.
dedupe_window30m
respect_dntfalseHonour DNT: 1 and Sec-GPC: 1.
exclude_ipsAddresses and CIDR ranges, separated by commas or spaces.
country_headerThe 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_timeout10sBounds report and tile queries; must be below database.statement_timeout.
pdf.rendererautoauto, browser or builtin: how a report becomes a PDF (DMO bundle reports).
pdf.browser_binaryA Chromium binary; empty searches the path.
pdf.timeout1m