
The link that closed the measurement loop
Measuring visits without knowing who sent them is measuring half the path. That was the state of the Portfolio until October 24: the visit beacon fired on every navigation, recorded path and referrer, and arrived at the store as a loose number — no origin, no campaign, no medium. The cause was not the beacon. It was the 13 cross links the site had to other projects (DogWalk, Arachne, LifeLog, skills.seu.pet) that carried not a single UTM parameter. Measured by @moedas that day: thirteen exits, zero with UTM. The visit arrived, but the destination did not know where from.
What was missing
The beacon already existed and worked. It sent site, path and ref to /api/hit, which the store recorded in the hits table with site='portfolio'. The problem was the missing field: utm_source, utm_medium, utm_campaign, utm_content. Without them, a visit from LifeLog and a visit from an X post arrived indistinguishable. The visit count grew, but no decision could be made about it — like counting people entering a store without knowing whether they came from the billboard or the flyer.
The tools I use on this project live in the skills store — the first one is free.
The ecosystem rule already existed and was documented: REGRA-AMARRACAO — every link leaving the portfolio carries UTM, and the beacon carries the page’s UTM. The measurement showed the rule was not being followed anywhere. Thirteen links, zero UTM. The gap was not knowledge; it was code.
The 59-line helper
The fix was a new module: src/lib/utm.ts, with two functions. The first, comUtm, takes a URL and options (medium, campaign, optional content, optional source) and returns the URL with UTM parameters added. The second, utmsDaPagina, reads the current query string and extracts the UTM already there — or falls back to utm_source=portfolio when there is none.
export function comUtm(url: string, opcoes: Opcoes): string {
try {
const alvo = new URL(url, "https://portifolio.seu.pet");
alvo.searchParams.set("utm_source", opcoes.source || UTM_SOURCE);
alvo.searchParams.set("utm_medium", opcoes.medium);
alvo.searchParams.set("utm_campaign", opcoes.campaign);
if (opcoes.content) alvo.searchParams.set("utm_content", opcoes.content);
return alvo.toString();
} catch {
return url;
}
}
Four design decisions that matter:
Invalid URL returns as-is. The try/catch around the new URL constructor ensures a malformed URL does not break the link — measurement can never interfere with navigation. If URL throws, the function returns the original string intact.
Relative path resolves against the site. Passing "/projetos" does not crash the constructor — the fixed base https://portifolio.seu.pet absorbs it. The result starts with https://portifolio.seu.pet/projetos? and carries the UTM. A link broken by measurement is the worst kind of bug: silent and invisible.
Existing query is preserved, not duplicated. searchParams.set replaces if it exists and adds if not. A link like https://lifelog.seu.pet/?lang=pt gains UTM without losing lang=pt. Tested explicitly in utm.test.ts.
Giant value is truncated to 64 characters. The destination has a 64-character cap on the field. utmsDaPagina applies .slice(0, 64) to each value before sending. Without it, a URL with a 200-character utm_campaign would be truncated in the database anyway — better to truncate first and keep control.
Where it landed
Two exit points were tagged. In Footer.tsx, the three footer links (DogWalk, Arachne, LifeLog) started using comUtm with medium: "footer", campaign: "portfolio" and content with the project name. In ProjectHangar.tsx, each project card gained a siteHref calculated with medium: "projeto", campaign derived from the repository name (slugified) and content: "card". The card’s exit link now carries the origin.
The beacon also changed. enviarHit gained an optional fourth argument, utms, that overrides what comes from the query string. By default, it reads location.search — whoever arrived with ?utm_source=lifelog keeps the attribution. With no UTM at all, it falls back to utm_source=portfolio. The beacon body gained the utm_source, utm_medium, utm_campaign and utm_content fields via spread: { site, path, ref, ...daPagina, ...utms }.
The tests
utm.test.ts has 69 lines covering the cases that matter: UTM applied correctly, existing query preserved, origin forceable via source, relative path resolved, fallback to portfolio without UTM, attribution preserved for those who arrived from outside, and giant value truncated. hit.test.ts gained 28 new lines with two scenarios: beacon sends current URL’s UTM, and explicit UTM overrides the URL’s.
In total, 97 lines of test for 85 lines of code. Measurements break silently — the ratio is intentional.
What this changes
Before: 13 cross links, zero UTM. Visits arrived at the store with no origin. The visit count grew, but no decision could be made about it.
After: every exit carries utm_source=portfolio, utm_medium and utm_campaign. Every arrival carries the UTM of whoever sent it. The loop closes: the portfolio knows where it sent people, and the destination knows where they came from. The visit count stops being a loose number and becomes attribution.
The helper is small — 59 lines. The impact is large: for the first time, the portfolio can answer “where did this visit come from?” and “where did I send people?”. Two questions that previously had no answer.
Next step
Extend the wiring to the other exit points not yet tagged — links in blog posts, CTA buttons and the resume download card. The helper already exists; it just needs to be applied.
Related entries
Same project, same ground — the stories connect.
Next step
If this was useful, there is more: I write about the same ground in the other projects, and the store holds the tools I use here.
Done — I will send the next article when it is out.
No spam, one-click unsubscribe. I am also here: Skills store · Portfolio