All posts
Engineering

Instrumenting a Funnel Without Writing JavaScript

Two HTML attributes, no deploy, and the edges where instrumentation quietly stops recording: consent timing, back/forward cache, and the per-element scroll cap.

Jay Patel13 min read
data-traceten-goaldata-traceten-scrollone goalno deploy

The reason most teams do not have a working funnel is not that funnels are hard. It is that instrumenting one requires a code change, a review, a deploy, and a person who owns all three, and the person who wants the funnel is usually not that person. So the funnel gets specified, sits in a backlog, and by the time it ships the question that motivated it has been answered badly by intuition.

A useful funnel can be built out of two HTML attributes and no JavaScript at all. This is how, and (more usefully) where the edges are, because the edges are where instrumentation quietly stops recording and nobody notices for a month.

The three-step funnel, in full

The worked example, end to end. A pricing page, a pricing table, a signup button.

Step one: a page visit. Nothing to add to the page. Pageviews are already recorded, and a funnel step matching starts_with /pricing catches /pricing and /pricing/enterprise alike.

Step two: did they actually look at the table? One attribute on the section:

<section id="pricing" data-traceten-scroll="scroll_to_pricing">
  <!-- your pricing table -->
</section>

The goal fires the moment any part of that section enters the viewport.

Step three: did they sign up? One attribute on the button:

<button data-traceten-goal="signup">Start free trial</button>

Three steps, two attributes, no JavaScript. And the funnel now answers something a pageview chart cannot: how many people who landed on pricing actually reached the table, and how many of those signed up. The gap between step one and step two is a layout problem. The gap between two and three is a pricing or copy problem. Without step two those two very different failures are one number.

Where each step comes from

Only steps two and three need markup, and neither needs a code path. The first step is free because pageviews are already recorded.

01

Landed on pricing

A page step, matching on the path. No instrumentation required.

02

Saw the table

`data-traceten-scroll` on the section. Fires once per element, per page load.

03

Clicked signup

`data-traceten-goal` on the button. Clicks on children count, up to six ancestor levels.

Click goals: the four behaviours worth knowing

Clicks on children count. The snippet walks up to six ancestor levels from whatever was actually clicked, so a click on the <span> inside your button still fires the goal. You do not need the attribute on the innermost element, and you should not put it there.

Repeat clicks within one second are deduplicated, per element. A double-click is one goal. An impatient triple-click is one goal.

The nearest attribute wins, and there is no fallback. One click fires at most one goal. If the nearest data-traceten-goal holds an invalid or reserved name, nothing fires: an outer element's valid goal is not used as a substitute. This is the behaviour to remember when debugging a goal that stopped working after somebody nested a new wrapper inside it.

A goal and a click label are found in the same pass. An element carrying data-traceten-goal inside a wrapper carrying data-traceten records both: the goal, and the click event with the wrapper's label. Two events, two endpoints, both billable. If you only want one, do not put both attributes on the same path.

The one click goals genuinely miss

A page restored from the browser's back/forward cache has no click listener. It is removed when the page is hidden and is not reinstalled on restore. Scroll goals are unaffected and keep working on a restored page. If a click goal has to survive a back-button return, call traceten.track() from your own handler instead.

Scroll goals: the cap, and the trap inside it

A scroll goal fires at most once per element, per page load. Scroll past the section, scroll back up, scroll down again: one goal.

That is not tidiness. Scroll goals are the highest-volume kind of goal and every goal is a billable event on your plan. A goal that re-fired on every re-entry would charge you several times for one person reading one page, and would make the number useless as a funnel step, because the step's count would be a function of scrolling style rather than of readership.

The cap is per element, not per goal name. This is the part that costs money quietly. Put the same data-traceten-scroll="scroll_to_pricing" on a mobile variant and a desktop variant of one section, and both fire on the same pageview, and both are billed, even though the visitor only ever sees one of them. Your funnel step is then inflated by a factor that depends on how many variants you rendered.

Put the attribute on a single element and style that one, or give the variants different names so at least you can see what is happening.

Single-page apps

An element is a DOM node, and the guarantee follows the node rather than the URL.

Navigating away and back in a single-page app usually rebuilds the section as a fresh node, and a fresh node counts again: two pageviews, two completions, which is the right answer. A route change arms every goal element that has not yet fired, including ones the new route just rendered. A hand-called traceten("pageview") on a route that did not actually change fires nothing new.

The one visible edge: if your app keeps the very same node mounted across routes (a shared layout, a persistent sidebar), that node fires once and then never again, however many times the URL changes around it.

There is also a scan boundary worth knowing. Elements added to the DOM after load are picked up on the next route change, not immediately. There is no document-wide mutation observer, deliberately: watching every customer page continuously is a cost we are not willing to put on your site for this feature. If you inject a section from your own code and need it counted straight away, fire it with traceten.track().

Naming, and the eleven names you cannot have

A goal name must match ^[a-z][a-z0-9_]*$: one to sixty-four characters, starting with a lowercase letter.

Valid: signup, demo_booked, scroll_to_pricing, trial_started_v2. Invalid: Signup (uppercase), demo-booked (hyphen), 2fa_enabled (leading digit), _internal (leading underscore).

Names are case-sensitive and never normalised. Signup is not a mis-spelled signup; it is rejected.

An invalid name is dropped silently in the browser. The snippet checks before sending, so a typo in an attribute costs you the goal and never sends a bad request or affects anything batched alongside it. In a server SDK the same name throws immediately instead, because a server-side typo is a bug you want to see in development rather than discover in a report.

Eleven names are reserved: payment, free_trial, trial_started, trial_converted, subscription_started, subscription_upgraded, subscription_downgraded, subscription_renewed, subscription_cancel_scheduled, subscription_reactivated, subscription_ended. They belong to the Stripe and Shopify integrations, which emit them for real payment events. If your own subscription_started mixed into the one Stripe sends, your revenue funnel would be counting two different things under one name and nothing would look broken. The attributes and the SDK goal() refuse them.

Which trigger for which moment

All four produce the same kind of goal and all four work as funnel steps. The difference is what has to exist for them to fire.

TriggerFires whenNeeds a deploy?Watch out for
data-traceten-goalAn element is clicked, including its childrenMarkup onlyNothing fires after a back/forward cache restore
data-traceten-scrollA section enters the viewportMarkup onlyOnce per element, so duplicated variants double-bill
traceten.track()Your own JavaScript decidesYesNothing, and it survives the consent window that attributes do not
goal() in a server SDKSomething completes on your backendYesvisitorId is required: a server has no cookie to read

When a goal is not recorded

Instrumentation that silently records nothing is worse than none, so this list is worth reading once in full. Nothing is sent when:

  • The visitor denied consent, or consent is still unresolved under consentDefault: "pending".
  • The visitor has Do Not Track enabled, or the _traceten_optout cookie set.
  • The goal name is invalid or reserved.
  • For scroll goals, the browser has no IntersectionObserver.
  • For click goals, the page was restored from the back/forward cache.
  • For both HTML triggers, a consent tool on the page has not answered yet.

That last one is the divergence worth designing around. The click listener and the scroll observer are installed only once consent resolves, so a click in the window before your consent platform decides records nothing. A traceten.track() call in that same window is kept, because an explicit API call is treated as intentional while the decision is in flight, but kept means buffered in memory until consent resolves, not sent immediately, so no cookie is written in that window either.

The window is short. It also covers exactly the moment a visitor lands and clicks the hero button. If a goal has to survive it, call track() from your own handler.

Properties, and the line you must not cross

Any attribute starting with data-traceten-goal- becomes a property, kebab-case converted to snake_case:

<button data-traceten-goal="signup" data-traceten-goal-plan-type="pro" data-traceten-goal-seats="5">
  Start free trial
</button>

sends signup with { "plan_type": "pro", "seats": "5" }. The same works on a scroll element.

The browser takes the first ten attributes in document order and drops any whose key exceeds forty characters, so ten long-keyed attributes can crowd out short-keyed ones you cared about more. Put what matters early and keep the names short.

Now the part that matters more than any of it.

Property keys and values are both stored, and both read back

The properties endpoint and the dashboard both return every key you have sent and that key's most common values. Before storage, Traceten drops a property whose key is exactly one of eight names (email, phone, name, password, token, ssn, credit_card, card_number) and redacts email, phone, card and national-ID patterns inside string values. That is the whole of it. Keys are never scanned, only matched against those eight, so data-traceten-goal-alice-chen="1" stores a property key of alice_chen and shows it back to your whole team. There is no pattern for a personal name or a postal address, because no reliable one exists.

Put plans, tiers, page names, categories and A/B variants in properties. Do not put anything that identifies a person, in a key or in a value. This is not a setting to be configured, and Traceten cannot detect it for you. It is the same reasoning behind collecting no PII on the default path at all: the guarantee that holds is the one that does not depend on a pattern matcher being clever.

One more asymmetry worth knowing. An HTML attribute cannot record revenue. Attribute values are always strings and value_cents is only read as revenue when it is a number, so data-traceten-goal-value-cents="4900" stores an ordinary property holding the string "4900" and the goal carries no money. To attach revenue, call traceten.track() with a number, or send it from a server SDK. currency does work from an attribute, because a three-character string is promoted to the top-level field.

Server-side goals, for the moments a browser cannot see

Some steps do not happen in a browser. A payment settling, a trial provisioning, a document finishing its processing queue: these are backend events, and instrumenting them from the client means firing a goal when you asked rather than when it happened.

traceten.goal("trial_provisioned", {
  visitorId: "123e4567-e89b-42d3-a456-426614174000",
  properties: { plan: "pro" },
});

visitorId is required and there is no way around it. A server has no Traceten cookie to read, so without the identifier the goal cannot be connected to the visit that produced it, and a goal not connected to a visit cannot be a funnel step, because a funnel is a statement about one visitor moving through stages.

The practical pattern is to read the visitor ID in the browser at the moment the user starts the action, send it to your backend with the rest of the request, and pass it back when the work completes. It is a small amount of plumbing and it is the difference between a funnel that ends at "clicked submit" and one that ends at "actually got the thing."

6 levels

Ancestor walk from the clicked node. Put the attribute on the button, not on the span inside it

children count

1 second

Click dedupe window, per element. A double-click is one goal

per element, not per name

200 names

Automatic goal registration ceiling per site. Past it, new names stop registering automatically though events still record

explicit creation is uncapped

Verify it, then build the funnel

Open your site with devtools filtered to conversions. Click the element, or scroll to it. You should see one POST with your goal name in the body. If it is missing, work down the "not recorded" list above. In practice it is consent timing or a name that does not match the pattern.

Then put the goals in sequence, pick a window shorter than your date range, and read what the numbers actually mean before you read the numbers. Instrumentation is the easy half.

Frequently asked

Add data-traceten-goal to the element that gets clicked, or data-traceten-scroll to a section that should count when it comes into view. Both are HTML attributes read by the tracking snippet, so a marketing change needs a markup edit rather than a code deploy. Both work as funnel steps immediately, with no goal to create in advance: the first event with a new name registers it automatically.

Sources & further reading

  1. 01Intersection Observer API, MDN Web Docs
  2. 02Back/forward cache, MDN Web Docs
  3. 03Data attributes, MDN Web Docs
  4. 04Do Not Track, MDN Web Docs
Share