Product tours that survive your next deploy
on this page ▾
Every product tour is a promise that a card will appear next to a specific button. The tool takes that promise as a CSS selector — usually typed by hand, by whoever is building the tour, from memory or from the browser inspector. Then someone restyles the page, the selector stops matching, and the promise breaks silently: the step disappears, the tour keeps "working", and nobody finds out until a customer asks why the onboarding skips a beat.
We shipped tours in Kilden a while ago. What we shipped this month is the part that makes them hold: a picker that writes the selector for you, a preview that doesn't burn the tour, and an alarm for when the page changes anyway.
Nobody should be typing selectors
The first version asked authors for a selector. It worked and it was miserable. You had to leave the dashboard, open your own site, open devtools, find the element, copy something, decide whether the thing you copied was stable, and come back. Every step of every tour.
So the SDK grew an author mode: open your site with a signed ticket in the URL fragment, click the element, and the selector lands back in the editor. No browser extension — a lazy chunk of the SDK you already have installed, downloaded only when the ticket is there, and mounted only after the server says the ticket is real.
That last part matters more than it sounds. The toolbar is an editor overlaid on your production site. If a visitor could trigger it by pasting a URL, we'd have shipped a defacement tool. So the fragment carries a short-lived JWT signed by the panel, the SDK asks our servers to verify it before rendering a pixel, and the verified response carries the only origin the toolbar will ever talk back to. The fragment is attacker-writable; the signature is not.
The interesting part: which selector?
Click a button and there are dozens of selectors that match it right now. Almost all of them are wrong — not incorrect, just fragile. The picker's real job is ranking, and the ranking is a claim about what tends to survive a deploy:
1. #id — if it's stable, nothing beats it
2. [data-kilden-tour="..."] — an attribute that exists FOR the tour
3. [data-*] — other data attributes (framework ones blocklisted)
4. [aria-label], [name], [role] — semantics change less often than looks
5. :kilden-text("Take a number")— the visible text (weak)
6. #app > div:nth-child(2) > … — structural position (weak)And one rule with no exceptions: CSS classes are never used. In a Tailwind codebase bg-orange-500 px-6 rounded-lg describes how a button looks today, which is precisely the thing a designer changes on a Tuesday. A class-based selector is a bet against your own design system.
Level 3 needs a blocklist, because frameworks scribble their own state into data-*. data-state="open", data-radix-collection-item, data-v-4f3a1b — those look like stable hooks and are anything but; the first flips when a menu closes, the last changes when Vue recompiles. We keep a list, and it will keep growing.
Levels 5 and 6 are marked weak in the UI, with the honest explanation: this one depends on copy or layout, so it can break. When the picker can only reach a weak anchor, it suggests the fix in the exact form you can paste:
<button data-kilden-tour="take-a-number">Take a number</button>That's the whole recommendation. One attribute, and the anchor stops being a guess about your DOM and becomes a contract with it.
The one thing CSS can't say
Level 5 is a problem: there is no CSS selector for "the button whose text is Take a number". :contains() was dropped from the spec decades ago and never came back.
So the runtime learned one extension of its own:
button:kilden-text("Take a number")The resolver splits it, runs querySelectorAll on the CSS base, and filters by each element's own trimmed text. It lives in one shared module used by both the tour runtime and the picker, so what the picker previews is what the tour resolves. And it degrades honestly: an older SDK that doesn't know the extension sees invalid CSS, matches nothing, and skips the step — the same behavior as any selector that stops matching. No exception, no broken page.
Seen once, and never again
Here's a bug in the shape of a feature. Tours default to showing once per person — obviously right for visitors, and completely wrong for the person building the tour. You publish, you go look at it on your site, and now your browser has it marked as seen. Iterating means clearing storage, or opening a private window, over and over.
The fix reuses the picker's machinery. The panel signs a ticket that carries the unit as currently edited — saved or not — and your page opens with it. The SDK verifies the ticket (same endpoint, same secret: the signature covers every claim, so the server never had to learn what a preview is), then renders through the real renderers, but inert:
- nothing is written to the seen-state storage;
- no
$events are recorded — what would have been tracked is mirrored to the console instead; - frequency, URL rules, delays and triggers are all bypassed, because preview means "show me now";
- a floating bar offers Replay, and the replay starts at the step you were editing, not at step one.
The unit being previewed doesn't exist server-side at all. It rides inside a signed 60-second token, which is a nice property: you can preview a draft you never saved, and there is nothing to clean up afterwards.
The alarm
The picker makes good anchors. It cannot make anchors immortal — one day someone renames the button.
The runtime already handled that part gracefully: a selector that doesn't resolve waits three seconds for late-rendering SPAs, then skips the step. Visitors are never stuck. But "gracefully" and "silently" were the same thing, and silence is how a broken tour survives for a month.
The signal was already in the data. Every step emits $tour_step_viewed, so a step's reach is a number we've been collecting all along. A step that anchors to something and is reached by nobody, while the tour keeps starting, is broken. Two flavors, and telling them apart matters:
- never seen — zero reach across the whole window. It never matched; the selector was wrong from the start.
- stopped — people reached it before, nobody recently. Something changed on the page.
The second one is the one you actually care about, and a single all-window count can't see it: last week's views mask this week's zero. So the funnel query carries a second column — the same unique-people count restricted to the trailing seven days — and the difference between the two columns is the whole diagnosis.
Both flags require a minimum number of tour starts in the matching window. A tour nobody has triggered produces zeros for entirely innocent reasons, and an alarm that fires on low traffic is an alarm people learn to ignore. Silence under low traffic is absence of data, not a diagnosis.
What it costs
Nothing, in the sense that matters: tours have no meter of their own. They cost the events they emit, like any other event. There's no per-tour-view price and no per-seat price, because the whole thing sits on the same pipeline as your analytics — the tour's funnel is queried with the same engine as everything else, keyed to the same people, with the same identity.
That's the argument for tours living inside the CDP instead of beside it. The targeting is your real cohorts, not a copy of them that drifts. The drop-off funnel is a funnel. And when a step breaks, the thing that notices is the analytics you already had.
The tours guide has the practical version: how to build one, how to anchor it, and why data-kilden-tour is worth the one-line diff.