MuninMunin
Sign inStart free
Home/Journal/Which article closed the deal?
Field note · 8 min read

Which article closed the deal?

You wrote forty articles last year. One of them was read three times by somebody who then booked a call and signed. In most stacks that fact exists — it is just split across two systems that were never introduced. Here is what it takes to ask the question in one query, and the four tools that answer it.

Seen through an open doorway from a dim room, a row of identical twine-tied paper bundles on a pale wood floor with a single shaft of daylight falling on just one of them — of everything published, the one that did the work.
One of them did the work.

Every company that writes anything eventually has the same argument. Someone asks whether the blog is worth it. Someone else says traffic is up. A third person points out that traffic is not revenue. And then everybody looks at two dashboards — one that knows how many people read the post and one that knows who signed — and discovers that neither of them can put those two facts in the same sentence. The argument ends where it started, and the blog survives on faith for another quarter.

Can you tell which blog post led to a sale?

Only if two things are true: your analytics records a per-person journey rather than aggregate counts, and that journey resolves to the same customer record your deals live on. Most stacks fail the second test rather than the first. The page views exist, the closed-won deal exists, and the join between them is a person nobody stored.

The question is worth stating precisely, because "attribution" gets used for several different jobs. This is not last-touch attribution on an ad click, and it is not a marketing-mix model. It is a much smaller and much more answerable question: for this named contact who became a customer, what did they read, in what order, before they got in touch.

Why can't Google Analytics answer this?

It can hold the shape of the answer and then throw it away. GA4 is user-scoped and event-level, so a per-person journey is architecturally possible. The problem is retention: on a standard property, user-level and event-level data can be kept for 2 months or 14 months — those are the only two options — and the default is 2 months. Longer windows of 26, 38 and 50 months are Analytics 360 only, and 360 pricing is not published anywhere on Google's own site.

That matters because B2B sales cycles are longer than the retention window. Someone reads three articles in March, comes back in October, and signs in November. On a default GA4 property the March reads were deleted in May. The retention control also bites harder as you grow: when a standard property becomes "Large," the event-level retention setting is automatically reduced to 2 months and older data is permanently deleted.

And even inside the window, GA4 does not know your deal stages. You are exporting from one system and joining in a third.

What about Plausible and Fathom?

They are excellent, and they cannot answer this question — not as a gap, but as the whole design. It is worth being precise about how deliberate it is, because it is the most principled engineering in this category.

Plausible counts a unique visitor as hash(daily_salt + website_domain + ip_address + user_agent), rotates and deletes the salt every 24 hours, and never stores the raw IP or user agent. Their data policy is unambiguous: "All data is isolated to a single day, a single website and a single device." Fathom does the same thing and goes further, rounding timestamps up to the nearest hour specifically so their own access logs cannot be cross-referenced against their browsing database. Both are cookie-free. Both are, on the privacy axis, doing something Munin does not do.

Plausible does ship a feature called User Journeys on the Business plan, and it is easy to misread the name. It is aggregate path exploration — pick a page, see what people did one step and two steps later, with pages auto-grouped by directory and drop-off counts alongside. No individual is exposed at any point, because no individual exists in the data to expose.

So the honest framing is a fork, not a ranking. If your requirement is that your analytics must never hold person-level data at all, buy one of these two and stop reading; that is the product for that requirement, and Munin is not it. If you need to know which article a named customer read before they signed, no amount of aggregate tooling will get you there, and the question becomes where that person-level record should live.

  • PlausibleAGPL-3.0 community edition, cloud from $9/month for 10,000 monthly pageviews on Starter, EU-hosted in Germany from an Estonian company. Aggregate only, by design. Worth knowing before you self-host: the repo has an extra/ directory holding the Enterprise Edition, and Plausible's own release notes say it is published source-available but "we do not grant rights for anyone else to use, distribute or otherwise exploit these features."
  • FathomProprietary, no public source. From $45/month for 500,000 pageviews, all plans including at least 50 sites. EU Isolation routes EU visitors to EU servers and strips their IPs before anything leaves the region; non-EU traffic goes to US servers. Canadian company on AWS. Aggregate only, by design.
  • MatomoGPL-3.0 core, and the core repo genuinely has no proprietary directory in it — premium plugins ship separately under the InnoCraft EULA rather than hiding inside the public tree. Cloud from 29/month for 50,000 hits; On-Premise Community is free forever with unlimited hits and users. Does person-level properly: Visitor Profile, Visitor Log and User ID are on every plan, and session recording replays a visitor's page views one after another. Cloud data sits in Frankfurt.
  • Munin analyticsPerson-level and consent-gated, on the same Postgres row as the CRM contact, the support thread and the CMS entry. analytics_get_contact_journey returns one contact's chronological page-view and search timeline, and resolves activity from before they identified themselves. MIT throughout, EU-hosted on Cloud, €0 to start.

How does the journey get attached to a person?

In three steps, and the interesting one is the third.

The tracker is anonymous first. analytics_create_tracker returns a public trackerKey and an identityVerificationSecret shown once. Page views arrive carrying an opaque visitor_id and nothing else — no name, no email, no contact.

Identity is signed, not asserted. When someone signs in, the browser reads its own id with window.mn.getVisitorId(), your server signs HMAC("${externalId}:${visitorId}") with the tracker secret, and the browser calls window.mn.identify(externalId, userHash). The hash is bound to that one browser, so an observed hash cannot be replayed to attach a different visitor to that identity. Mismatches are silently dropped.

And the past comes with them. This is the part that makes the question answerable at all. Linking a visitor backfills end_user_id onto that visitor's view and search events from the last 30 days, in the same transaction as the link — and analytics_get_contact_journey resolves the bridge at read time, so even older anonymous reads surface on the timeline. The three articles somebody read a fortnight before they ever gave you their email are not lost; they attach the moment there is a person to attach them to.

javascriptidentify once, after sign-in
const go = () => {
  const visitorId = window.mn.getVisitorId();
  // POST { externalId, visitorId } to your server, get back userHash, then:
  window.mn.identify(externalId, userHash);
};

// The tracker loads async. Gate on the flag or the event, never poll.
window.mn?.ready
  ? go()
  : document.addEventListener('munin:ready', go, { once: true });

Person-level means lawful basis, not just a checkbox

A journey tied to a named person is personal data, and Munin models that as database state rather than policy: contacts carry consent_lawful_basis, do_not_contact and unsubscribed_at, and the segment surface enforces them below the agent. Deleting a reader's history is a delete on analytics_view_events by visitor_id — the view tokens carry no visitor identity of their own.

How do you know it was that article and not that URL?

Because the view is keyed to the entry, not the path. Every CMS delivery response ships a _tracking block containing a signed token bound to one entry id, and rendering <article data-mn-entry-token={entry._tracking.token}> records a read against that id. Rename the slug, move it under a new route, translate it into a second locale — the analytics layer never notices, because it was never keyed on the URL.

Run the URL tracker alongside it and you get two events per view: one subject_type='page' for funnel questions, one subject_type='cms_entry' for content questions. They answer different things and stay separable at query time.

One trap worth naming, because it quietly ruins the data and nobody catches it for months: never put the token on cards in an index. Tag the cards on your journal index and every index load records a read for every entry on it, so your most-read list silently becomes whatever happens to sit near the top of the homepage. Impressions are a different question with a different subject type.

The page views existed. The closed-won deal existed. The join between them was a person nobody stored.

What can you actually ask once it's wired up?

The useful questions turn out not to be the ones a marketing dashboard is shaped around.

  • 01What did this customer read before they signed? analytics_get_contact_journey with the contactId, chronological, including the anonymous stretch before they identified.
  • 02Which entries actually get read? analytics_list_top_subjects with subjectType: "cms_entry" — keyed by entry id, so a slug rename doesn't reset the count.
  • 03Is anyone finishing this one? analytics_get_subject_engagement returns dwell and read depth per entry, which is a more honest signal than views on a long article.
  • 04What did people search for and find nothing? analytics_list_zero_result_searches is the shortest path from analytics to a content plan — it is a list of articles your readers have already asked you to write.
  • 05Where does the drop happen? analytics_get_funnel groups conversion steps by identified end-user, so a journey that crosses the anonymous-to-known boundary counts as one person rather than two.

What does it change about how you write?

Less than you would hope in the first month, and more than you would expect by the third.

The first-order effect is that the argument about whether the blog works stops being an argument. You can name the entries that preceded closed deals. Usually it is not the ones anybody predicted, and usually it is fewer than you would like — two or three pieces doing most of the work, and a long tail that gets traffic and closes nothing.

The second-order effect is the one that compounds. Zero-result searches are a to-do list written by the people you are trying to reach, and they sit in the same system as the knowledge base that fills itself from resolved support threads and the CRM that fills itself in from the conversations you were already having. A question asked in support, searched for on the site, and never answered anywhere is a very loud signal when all three of those records are one query apart. That is the whole reason we put six modules behind one schema rather than syncing four products.

And because it is a tool catalogue rather than a dashboard, the question doesn't have to have been anticipated by whoever drew the screen. "Which articles did our closed-won deals from last quarter read, and which of those articles has nobody updated since?" is not a report anybody ships. It is two tool calls.

What Munin's analytics module does not include

No heatmaps, no session recording, no A/B testing, and no tag manager. Matomo ships all of those and has for years — if visual behaviour analysis is the job, that is the product for it.

Munin's analytics is narrower on purpose: page views, searches, entry engagement, funnels and per-contact journeys, on the same record as the deal and the support thread. Not more numbers, fewer joins.

Frequently asked questions

How do I find out which blog post led to a customer? You need per-person journey data that resolves to the same record your deals sit on. In Munin, sign an identity hash server-side, call window.mn.identify() once after sign-in, then read analytics_get_contact_journey with the contact id — it returns that person's chronological page-view and search timeline, including reads from before they identified themselves.

Does Plausible or Fathom let me see an individual visitor's journey? No, and that is deliberate rather than a limitation to work around. Plausible isolates all data to a single day, site and device using a salt it deletes every 24 hours; Fathom deletes its visitor hashes at midnight and rounds timestamps to the hour. Plausible's "User Journeys" feature is aggregate path exploration, not a named person's trail.

Is Munin's analytics cookieless? No. The tracker stores an opaque visitor id in localStorage under mn.vid, shared with the chat widget. That persistent identifier is what makes a cross-session journey possible, and it is the trade being made — it is why Munin can answer a question Plausible and Fathom structurally cannot.

How long does Munin keep analytics data? Self-hosted, as long as you keep it — the events are rows in your own Postgres. For comparison, GA4 on a standard property offers 2 or 14 months for user-level and event-level data, defaulting to 2, with 26/38/50 months available only on Analytics 360.

Can I track CMS entries if my site is a static export? Yes. Every delivery response includes a _tracking block with a pre-signed pixel URL that is not time-limited, so a static build that bakes the URL keeps working. The pixel records views only — no visitor id, so no per-person dedupe, dwell or read depth. If the page can run the tracker, let it.

What is the open-source alternative to Google Analytics? For aggregate, privacy-first traffic stats, Plausible (AGPL-3.0) and Matomo (GPL-3.0) are both strong, and Matomo self-hosts free with unlimited hits. Munin is MIT and answers a different question: not how many people read it, but which named customer did.

The short version

  • "Which article closed the deal" needs two things: a per-person journey, and that journey resolving to the same record the deal sits on. Most stacks have the first and not the second.
  • GA4 can hold the shape of the answer but defaults to deleting user-level data after 2 months, with 14 months the only other standard option — shorter than most B2B sales cycles.
  • Plausible and Fathom cannot answer it at all, by design: daily-rotating salts, no persistent identifiers, no individual to query. If that is your requirement, they are the right product.
  • Matomo does person-level well — Visitor Profile, Visitor Log and User ID on every plan, GPL-3.0, and no proprietary directory hiding in the core repo.
  • Munin ties the journey to the CRM contact with a visitor-bound signed hash, and backfills 30 days of prior anonymous reads the moment the link exists.
  • CMS views are keyed to the entry id rather than the URL, so slug renames, route moves and translations don't reset the count.

Mint a tracker, put the entry token on your article template, and ask for one customer's journey — Munin Cloud is free for that, and the analytics tool reference has the wiring.

Traffic tells you something was read. Only the record tells you it mattered.

Kjell Rune Monsø, founder.