TL;DR
A tracking link is a real page URL plus ?mt= and UTM parameters, minted in the workspace and
posted in public. The visitor lands on the actual page, the code is recorded, then the parameters
are stripped from the address bar. Events are stored raw; link status is applied only when the
daily rollup runs. Pipeline (revenue) figures are gated off and, when enabled, are invented sample
data until WEB-112 ships.
Status (2026-10-13): mint, capture, strip, rollup, dashboard and lead conversion attribution are shipped on branch
feat/tracking-links(Jira WEB-115). The pipeline phase cannot ship live: it is blocked on the Salesforce sync (WEB-112) and is off by default. Design:docs/superpowers/specs/2026-10-09-tracking-links-design.md.
This page serves two readers. Operators (marketing) want the runbook and how to read the numbers. Engineers want the lifecycle, and the reasoning behind choices that look wrong until you know why. Each rule below is stated with its reason, because the code can only show what it does.
Related pages: the capture side lives in Lead Attribution & Journey, the event pipeline in Content Engagement Telemetry, and the dashboard and route contracts in Lead Attribution & Tracking Links.
?mt=, not a /go/ redirect shortener#A shortener (matters.ai/go/abc) that 301s to the page was the obvious design and was rejected.
SEO was the stated priority, and a redirect hop costs on two fronts:
So the posted URL is the page, e.g. https://matters.ai/blogs/x?utm_source=linkedin&utm_medium=social&utm_campaign=q4&mt=7K2M9QXD4T.
The cost is a longer URL, which is why the parameter is two characters (TRACKING_PARAM = "mt",
lib/tracking/url.ts) and why mt carries identity: analytics joins on the code, never on the UTM
labels, which are editable.
Two consequences that must not be undone:
lib/tracking/code.ts. A code is 10 characters of Crockford base32:
0123456789ABCDEFGHJKMNPQRSTVWXYZ, which is the 32-symbol alphabet minus I, L, O and U.
| Choice | Reason |
|---|---|
I, L, O are never generated | They read as 1, 1, 0. normalizeTrackingCode maps them into 1/1/0 on read, which is safe only because no issued code contains them: a code read aloud or retyped from a flyer can never turn into a different valid code, only back into the real one. |
U is never generated | Crockford excludes it (accidental obscenities). It is not remapped: a code containing U normalises to nothing and is treated as not-a-code. |
| 10 characters | 32^10 = 2^50 possibilities. That is unguessable enough that the campaign list cannot be enumerated by scanning the keyspace, and still short enough to keep a posted URL legible. |
CSPRNG, not Math.random | randomInt is crypto-grade and rejection-sampled, so the alphabet stays uniform. A modulo over 32 would be biased and narrow the real entropy. |
| Case-insensitive on read | Input is trimmed and upper-cased first, because a code may be retyped or pasted through something that changes case. |
Unique index on mt | ensureTrackingLinkIndexes creates it lazily (no I/O at import time). Collision retry (mintWithRetry) is only correct because the index makes a duplicate insert fail rather than succeed silently. |
mt_unknown (UNKNOWN_TRACKING_CODE) is deliberately not a valid code: it contains an
underscore and is the wrong length, so it fails TRACKING_CODE_PATTERN. Any filter on codes must
allow it explicitly, or the "record the mangled paste" behaviour is silently discarded.
mint, post, arrive, resolve, record, strip, roll up, display, convert, reconcile. Each step and the file that owns it:
| # | Step | What happens | Owner |
|---|---|---|---|
| 1 | Mint | Operator picks a destination from the catalog and a channel preset. A unique code is generated, the URL is built and stored in tracking_links. renderedUrl is written once. | mintTrackingLink in lib/services/tracking-link.service.ts; buildTrackedUrl in lib/tracking/url.ts; destinations in lib/tracking/destinations.ts |
| 2 | Post | The URL is pasted into a post, email or flyer. From here it is in public and permanent. | (outside the system) |
| 3 | Arrive | The visitor requests the page. The proxy decides whether this is a real page navigation worth a lookup. | shouldResolveArrival in lib/tracking/resolve.ts, called from proxy.ts |
| 4 | Resolve | Registry lookup, cached, bounded by a 250 ms deadline and a miss cache. The result sets a 30-minute httpOnly mai_mt cookie only to fill a gap (see below). The page is served unchanged. | resolveArrivalWithin, lib/tracking/miss-cache.ts, resolveTrackingLink; cookie in lib/tracking/arrival-cookie.ts |
| 5 | Record | The client records the landing pageview. Its code is parsed from the pageview's own href; the ingest route fills from the cookie if the URL carried none. | lib/telemetry/build-event.ts, app/api/telemetry/collect/route.ts, lib/attribution/parse.ts |
| 6 | Strip | After the pageview is captured, mt and utm_* are removed from the address bar. The original query is snapshotted so lead forms still see it. | cleanAddressBar in lib/attribution/collect.ts; lib/tracking/arrival-query.ts; called by TelemetryRecorder.tsx |
| 7 | Roll up | Daily cron folds yesterday's events into one row per (code, UTC day), resolving status and unknowns, then totals counters onto the registry (a Mongo $group in trackingLinkCounters, not an in-memory read of every day's rows). | app/api/cron/telemetry/rollup/route.ts; lib/tracking/rollup.ts, rollup-registry.ts, counters.ts |
| 8 | Display | Workspace dashboard reads the daily rows and the registry. | lib/tracking/performance.ts; loadTrackingPerformance in the service; components under apps/workspace/app/(workspace)/marketing-attribution/ |
| 9 | Convert | A lead form submission records first-touch and last-touch codes on the lead from the visitor's journey. | attributionFromJourney in lib/tracking/conversion.ts; the lead routes; lib/services/sales/lead-field-map.ts |
| 10 | Reconcile | Lead and opportunity data is matched back to links to show pipeline. Gated off, see below. | lib/tracking/pipeline.ts, lib/services/tracking-pipeline.service.ts |
The proxy resolves the code server-side and sets mai_mt. The telemetry ingest route reads it and
stamps it on the first pageview only when the pageview's own URL carried no code. This covers a
visitor whose JavaScript was blocked at landing, or whose browser stripped the query before scripts
ran. The URL's own code always wins, because it is the explicit signal for that navigation and the
cookie may predate it.
resolveArrival never throws. This code runs in proxy.ts on a URL a stranger controls, so the only
acceptable failure is degraded attribution. On a lookup error or timeout it keeps the raw,
well-formed code rather than writing mt_unknown: an unreachable database is not evidence the
code is unknown, and status is settled later at rollup. (Writing the sentinel there would
permanently mis-record a visitor on a valid link whenever the database blipped.)
The gate (shouldResolveArrival) admits only a GET document navigation carrying a well-formed
mt. It excludes /api/, assets (by an extension allowlist, so /webinars/q3.2026 is still a
page), prefetch and RSC requests. Without it every asset request carrying mt would cost a database
read. It is not a defence against a client that forges headers: curl with Sec-Fetch-* set passes.
Repeats are bounded by MissCache (5,000 hashed codes, 60 s TTL, true LRU). A flood of distinct
random codes still costs one findOne each and needs edge protection (WEB-119).
Do not cache misses in resolveTrackingLink
resolveTrackingLink deliberately throws on a miss so null never reaches cachedFetch. Its
memory layer never evicts (WEB-118), and this path is public and unauthenticated: caching null
would let anyone grow process memory with random well-formed codes. The bounded MissCache in
front of it is the sanctioned way to absorb repeats. Do not "optimise" the throw into a return null.
| Status | The posted link | Counted in reports | In the registry list |
|---|---|---|---|
active | works | yes | shown |
paused | still works | no | shown, marked |
archived | works | no | hidden by default |
DELETE route) and a code is never reused. A URL posted in
public is permanent: people will keep clicking it for years. Reusing its code would silently merge
two campaigns' data. Archiving is how you retire a link.● active, ‖ paused with a dashed
border, ○ archived and dimmed).TrackingLinkTable.tsx) that sets it back to active. The API accepts the same change through
PATCH with status: "active".This is the most important design decision to preserve. The capture path stores events raw,
with whatever code the URL carried and no status check. Status is applied later, in
resolveBucketCode (lib/tracking/rollup.ts):
| Code seen on the event | Reported as |
|---|---|
mt_unknown | the mt_unknown bucket |
| no registry row | the mt_unknown bucket |
registry row, active | its own code |
registry row, paused/archived | dropped (counts nothing) |
Why not enforce at ingest? Events are raw facts, and status is a reporting concern. Enforcing it at
ingest would destroy the record: a paused link's traffic would never be stored, so you could not
later answer "did anyone still click that paused link?". Resolving in the collect route would also
add a database read to a hot public path, and rewriting the URL in the proxy would erase what was
actually visited. The rollup takes a status Map as an argument so it stays pure and
database-free, and one function owns both the "drop" and the "rewrite to unknown" decisions so they
cannot drift.
Status is read when the day is rolled, not when the event happened
The cron folds yesterday at 01:20 UTC and reads live registry status at that moment. So a
day counts only if the link is active when that day is folded. Pausing at 14:00 means that same
day's earlier visits are not reported either, and days already folded keep their rows (pausing
never rewrites history, so past reports stay reproducible). Resuming does not back-fill the days
it was paused: only yesterday is ever folded, and raw events are kept for 90 days
(RAW_RETENTION_DAYS). There is no backfill parameter on the cron. See the runbook
warning before pausing a link.
The proxy treats an archived link as mt_unknown, but that only feeds the cookie. In
reports, a visit on an archived link whose URL carried the real code is dropped, not shown as
mt_unknown, because the rollup sees the real code and its archived status.
renderedUrl is immutable#renderedUrl records the URL actually posted. The edit endpoint (updateTrackingLinkSchema)
omits it, mt and targetPath, and .strip() discards any renderedUrl a caller sends. Editing a
campaign name, content, term, placement, notes or tags relabels the link in reports but does not
change what was posted.
This is deliberate. The link is already in public; rebuilding renderedUrl would make the stored
record disagree with the URL in the wild. So after editing a campaign name, the copy button still
yields the old utm_campaign, which is the truth about what was posted. Minting a URL with new UTMs
means minting a new link: use Duplicate to channel. Retargeting a code to a different page is
likewise not possible, since it would rewrite the meaning of every click already recorded.
Only the landing pageview carries a code (the later pageviews have a stripped URL), so the rollup treats the two kinds of event differently:
visits counts only pageviews carrying their own code. The recorder fires a pageview on every
route change, so counting inherited ones would report five visits for a five-page session and make
a link's score scale with how much people browse after clicking it.The client-supplied trackingCode on non-pageview events (acceptClientTrackingCode in
build-event.ts) is validated but nothing in the client populates it today; session attribution
is what actually works. Do not rely on it.
uniqueVisitors is visitor-days, an upper bound. It is summed per day, so the same person
returning on two days counts twice. A true cross-day unique would need the raw events, which the
list view does not justify reading. The UI says "visitor-days" and never "unique visitors".MIN_VISITS_FOR_RATE); below that a dash is shown, since a percentage
from a handful of visits is noise. A rate above 100% is a counting bug, not a success: it is
clamped for display and flagged above INCONSISTENT_RATE_THRESHOLD (1.05) rather than silently
accepted.date"), not at today. The cron folds
yesterday only, so today has no row, and drawing it as zero would read as a traffic collapse on
every link, every day.video_progress
producer never emits a depth, so the count is always 0, and "0 completions" claims nobody finished
a video. It stays hidden until something emits real progress.weight: row.visits in
lib/tracking/performance.ts), so a 500-visit day outweighs a 1-visit day. It is still a median of
per-day medians, not a median over every visit, so read engaged time as indicative.A comparison of the link's read rate against the page's overall read rate was built and then removed. The two figures are not like-for-like in two independent ways:
The bias is built in, not sampling noise, so a "better/worse than the page" verdict would be wrong with confidence. A "rest of page" baseline is not computable because the daily rows do not hold the link's own pageviews or path-only reads. The page therefore shows both rates side by side with no verdict, plus the link's share of the page's traffic. A confident wrong number is worse than no number: do not reintroduce one without fixing both the numerator and the denominator.
mt_unknown#Shown in its own Unrecognised codes card and excluded from every campaign, channel and page total. It means a visitor arrived with a well-formed 10-character code that matched no registry row. Causes, in rough order:
What to do: open the card, find the date range, and look at the referrers and landing pages in the raw telemetry for that window to see which post it came from. If it is a real post, re-mint the link and replace it. If it is a steady trickle, a mangled link is live somewhere. A spike on one day with no matching post is probing, which is bounded by the miss cache and needs no action.
A truncated code (wrong length) is not well-formed, so it is not recorded as mt_unknown:
the visit lands as an ordinary visit with whatever utm_* survived, or as direct. The dashboard card says so as well.
A tracked URL and the clean page are the same content at two URLs, so every page must canonicalise
to the clean one or the parameters cost ranking signal. Two pre-existing holes were closed while
building this module, and tests/seo/canonical.test.ts exists so they do not reopen:
canonical: "./" in app/root-metadata.ts. It is "./" and not "/": Next resolves
"./" against the requested pathname (query string excluded), giving a true per-route
self-canonical. A literal "/" resolves to the homepage, which would have declared webinars,
datasheets, whitepapers, newsroom, about, careers, tools and demo as duplicates of /. That is
actively harmful, where the original bug was merely absent signal.canonical: "/blogs" in app/blogs/layout.tsx that every descendant inherited (the
cause of WEB-94). It was removed. The /blogs listing is a client component and cannot export
metadata, so it now lives in client-page.tsx behind a server page.tsx that owns the listing's
bare-path canonical. The two 404 patches stay: a missing post must not be indexed.The test asserts on the real exported metadata, resolved through Next's own URL resolver, not on
file text, so it fails for the right reason. Adding a route that sets its own canonical is fine;
removing ./ from root metadata is not.
TRACKING_PIPELINE_SAMPLE_DATA_ENABLED (default off, parsed in packages/lib/src/config/env.ts,
read by isPipelineAvailable in lib/services/tracking-pipeline.service.ts).
MockPipelineStore, fed by
buildSampleOpportunities: invented opportunities in GBP and INR. The phase cannot ship live.loadTrackingPipeline returns { status: "unavailable" } before touching the
registry or generating anything; a test asserts getDb is never called. The honest default is
"this feature is not available", not "here is some invented money with a caveat".org:marketing:view, so before the flag existed a merge
would have shown fabricated revenue to every role holder in production. The label alone was not a
safe guard because it can fail open.salesforce, the response is Zod-validated so a
missing store is a load error rather than figures with no provenance, and the marker is repeated
per money cell so a cropped screenshot still carries it.null, never a coerced zero, and sort by count, not money.NODE_ENV guard: enabling the flag is an unambiguous deliberate act, and
a guard would block demoing the panel in staging. Do not set it in production. Going live is a
change to resolvePipelineStore only; callers read the PipelineStore interface.attributionFromJourney (lib/tracking/conversion.ts) is the single payload every lead route
writes: first-touch and last-touch codes, sources and campaigns, plus touchCount. Both ends
are kept because neither alone is honest for a visitor who found you on Reddit and converted three
weeks later from a newsletter.
null, non-objects and junk, clamps text
to 200 characters, and accepts only a code matching TRACKING_CODE_PATTERN or mt_unknown. A
conversion must never fail for want of attribution.attributionFromJourney.$setOnInsert for first-touch fields; $set
only non-empty last-touch fields). A re-download from a new device has no journey and must not wipe
the stored first touch.lead-journey.ts: size and depth caps) and an oversized one is dropped,
not rejected, so the conversion survives.firstTrackingCode and lastTrackingCode. The
bare trackingCode and mt aliases are deliberately absent: field mapping resolves dot paths on
the leaf, so a bare alias would map a last-touch code onto the first-touch field and make every
lead look single-touch.Mint. Workspace, Lead Attribution, Tracking Links. Choose the destination from the picker. The mint and bulk routes also verify it server-side
(assertMintableDestination, lib/tracking/destination-guard.ts): a path that is not in the destination
catalogue is rejected with a 400, so a direct API call cannot mint a link to a 404. The catalogue is
the compile-time pages (the sitemap's static routes in lib/seo/static-routes.ts, the platform and
key-feature menu pages, and the listing pages in lib/tracking/static-destinations.ts) plus the
published content loaders: blog posts, webinars, glossary terms, datasheets, whitepapers, active
campaign pages (/c/*), author pages, and the WordPress industry, use-case, key-feature and
platform pages. /platform and /key-features are not offered because next.config redirects both
to the homepage. Case studies and /tools/* (a "moved to the workspace" notice) are listed as
unsupported in the picker. A path with no owning source can never produce the retryable 502. If the content source that
would hold the path is down, the mint fails closed with a retryable 502 (a code is permanent, a
retry is cheap), and an outage in an unrelated source does not block it. a channel preset (curated, so Reddit and
reddit never become two channels), a campaign and the exact placement. Use Bulk for several
channels; a partial failure returns 207 with the codes already issued and the channels still
owed, so retry only those (a blind full retry would create duplicate links, since codes are
permanent).
Retire a campaign. Set each link to Archive (or Pause if it may return), early in the day (see the warning below: a mid-day change drops that whole day). Do not look for Delete: it does not exist. The posted URLs keep working and the visits are still stored; they stop being reported from the next daily fold. History already rolled stays.
Pausing or archiving mid-day drops that whole day's visits
If you pause or archive a link at 14:00, the entire day disappears from reports, including the visits from before the pause. This surprises people, so read it before pausing.
active at that moment. Pause at any point during a day and that day is never counted for the
link. Days folded earlier keep their rows. Resuming does not restore the paused days.RAW_RETENTION_DAYS), so the visits are
not lost and a backfill can recover them. Today there is no backfill entry point: the cron
folds yesterday only and takes no day argument, so recovering a day needs an engineer to run the
fold for that date (and the link must be active while it runs). Tell engineering the link code and
date range within 90 days.Correct a mislabelled link.
A link's numbers look wrong. Work down this list:
date", and
yesterday is not folded until 01:20 UTC. Wait before concluding anything.paused or archived link counts nothing. Was it paused when the day was folded?
(See the status callout: it is the fold time that matters.)mt_unknown. If many real links do, suspect a registry or lookup problem
rather than the links: the rollup folds a code with no row into unknown, and the cron throws
rather than misreport when it cannot read statuses. Check the cron logs.conversions field on TrackingLink (always 0, never updated) and its CSV
column were removed rather than ship a number that was wrong. Leads do carry first-touch and
last-touch codes (lib/tracking/conversion.ts), but they live in several collections, so a count
needs a cross-collection aggregation that does not exist yet. Until it does, conversions per link
can only be read from the lead records themselves.TRACKING_LIST_MAX_LIMIT). Both now show
a banner when the registry holds more; the oldest links are the ones left out, including from the
CSV export.ws-btn-primary (accent on accent)
and ws-chip--yellow. This module therefore signals state with non-colour cues: status glyphs
with border and opacity, selected range/group toggles as the ink pair with a border and underline,
and the sample-data chip as glyph, dashed border and words. Do not move any of these back onto an
accent or yellow token.x-forwarded-for
value (WEB-119), so treat it as accident protection only. Authorization (checkRole) is the real
gate on every registry route.getAllCategories returns only WordPress's default 10 categories (WEB-120), so special
categories ranked past 10 are not seen by the blog picker. It matches live site behaviour today and
will follow when that is fixed.cachedFetch's memory layer is unbounded (WEB-118); hence the throw-on-miss above.| Concern | File |
|---|---|
| Code format | lib/tracking/code.ts |
| URL build, read, strip | lib/tracking/url.ts |
| Types and status | lib/tracking/types.ts, lib/tracking/schema.ts |
| Registry service and cache | lib/services/tracking-link.service.ts |
| Arrival gate and resolve | lib/tracking/resolve.ts, lib/tracking/miss-cache.ts, proxy.ts |
| Cookie | lib/tracking/arrival-cookie.ts, app/api/telemetry/collect/route.ts |
| Strip and arrival query | lib/attribution/collect.ts, lib/tracking/arrival-query.ts |
| Rollup and counters | lib/tracking/rollup.ts, rollup-registry.ts, counters.ts, the telemetry rollup cron |
| Reporting rules | lib/tracking/performance.ts |
| Conversion | lib/tracking/conversion.ts |
| Pipeline (gated) | lib/tracking/pipeline.ts, lib/services/tracking-pipeline.service.ts |
| Canonical | app/root-metadata.ts, app/blogs/page.tsx, tests/seo/canonical.test.ts |