Docs Reference
Instrumentation guide
Attributes and naming rules so every element on a page is tracked. For developers and agents.
This guide is written for whoever generates or edits a page: a human, an AI agent, or a JARVIS framework step. Follow it and every element on the page is tracked, every event has a stable name, and segments like "watched 50% of the VSL but did not opt in" work out of the box.
Everything here is deterministic. The same rules are implemented in @retafunnel/shared
(instrumentHtml / validateHtml), in the CLI (pnpm rf instrument, pnpm rf validate) and in the MCP tools
(instrument_html, validate_html). Run the tool, review the change log, fix what it warns about.
1. The script tag#
One tag in <head>, nothing else to install:
<script src="https://t.retafunnel.com/rf.js" data-site="rf_YOUR_SITE_KEY" defer></script>
Optional attributes:
| Attribute | Values | Meaning |
|---|---|---|
data-consent |
auto (default) · implied · strict |
Consent behaviour for this page, overrides the site default |
data-api |
URL | Ingest endpoint, only when self-hosting the Worker |
data-test |
true |
Every session on this deployment is a test session. Use on staging only; remove before going live |
The site key starts with rf_. Get it from the dashboard (Sites) or from the MCP tool ensure_site.
2. What the tracker does on its own#
Without any attribute you already get: pageviews with UTM/fbclid/gclid attribution, scroll depth milestones
(25/50/75/100%), active time (heartbeats every 15 s), clicks on <a> and <button> (auto-named), form start/submit,
HTML5/Vimeo/YouTube video milestones (10/25/50/75/90/100%), Calendly bookings, cross-domain visitor stitching.
Attributes make the names stable and meaningful so you can build segments and reports on them. Auto-named clicks
change whenever copy changes; data-track names do not.
3. Attributes#
| Attribute | Put it on | Event | Notes |
|---|---|---|---|
data-track="cta_hero" |
a, button, any clickable |
click |
Required on every CTA. Unique per page |
data-track-view="pricing_seen" |
section, div |
view |
Fires once when 50% of the element is visible |
data-track-form="optin_main" |
form |
form_start, form_submit (+ identify when the form has an email or phone field) |
Field names are sent, never values. Email/phone are hashed client-side |
data-video-id="vsl_main" |
video, Vimeo/YouTube iframe |
video_play, video_pause, video_progress |
One id per player. The main sales video is always vsl_main |
data-quiz="quiz_main" data-quiz-steps="7" |
quiz container | quiz_start |
data-quiz-steps is the total number of steps |
data-quiz-step="3" |
each step container | quiz_step |
1-based, fires when the step becomes visible/active |
data-quiz-answer="b" |
option button inside a step | recorded as the step answer | snake_case value |
data-quiz-complete |
result / final screen | quiz_complete |
The element shown after the last answer |
data-rf-ignore |
any | — | No auto click tracking inside (cookie banners, carousels…) |
data-rf-autofill |
hidden input named rf_* |
— | Filled by the tracker before submit (section 9) |
data-variant="B" |
the script tag | — | Experiment variant for every session on this page (section 11) |
4. Naming conventions#
- snake_case,
[a-z0-9_]{1,40}. Lowercase letters, digits, underscores. Nothing else. - Prefix by role:
cta_*for calls to action:cta_hero,cta_pricing,cta_checkout,cta_book_call,cta_applynav_*for header/footer/navigation links:nav_about,nav_privacyfaq_*for FAQ toggles:faq_1,faq_refund*_seenfordata-track-view:hero_seen,pricing_seen,testimonials_seenoptin_mainfor the main lead form;form_<n>or a descriptive name for others (form_contact)vsl_mainfor the main video;video_<n>or descriptive (video_testimonial_1) for othersquiz_mainfor the main quiz
- Names are unique per page. Two CTAs with the same text in different sections get different names
(
cta_hero,cta_footer). Duplicates are rejected byvalidate_html. - Use the same names on every page of a funnel for the same role (
cta_heroon every page), so reports can be compared.
5. Recipes by page type#
landing#
- Script in
<head>. - Every CTA:
data-track="cta_<where>"(hero, benefits, pricing, footer…). - Key sections:
data-track-viewon hero, offer/pricing, testimonials, FAQ, guarantee. - FAQ toggles:
data-track="faq_<n>". - Lead form:
data-track-form="optin_main"with<input type="email" name="email">.
vsl#
- Player:
data-video-id="vsl_main"on the<video>or the Vimeo/YouTube iframe. For Vimeo, the iframe src must allow the player API (default embed is fine). For YouTube, addenablejsapi=1to the src (the tracker adds it when missing). - CTA that appears after N minutes:
data-track="cta_vsl"; wrap it in adata-track-view="cta_revealed"section. - Optin or booking under the video:
data-track-form="optin_main"or the Calendly embed (auto-tracked).
quiz#
<div class="quiz" data-quiz="quiz_main" data-quiz-steps="3">
<div class="quiz-step" data-quiz-step="1">
<button data-quiz-answer="under_30">Under 30</button>
<button data-quiz-answer="over_30">Over 30</button>
</div>
<div class="quiz-step" data-quiz-step="2">…</div>
<div class="quiz-step" data-quiz-step="3">…</div>
<div class="quiz-result" data-quiz-complete>…</div>
</div>
- Steps are shown/hidden by your own JS; the tracker watches visibility and the
active/is-activeclass. - If the quiz is driven by JS state rather than DOM steps, use the programmatic API (section 6).
- A quiz that ends in a form: the form is
data-track-form="optin_main"inside thedata-quiz-completeelement.
optin#
<form data-track-form="optin_main">with an email (and optionally phone) field; submit buttondata-track="cta_submit".- Thank-you redirect stays on an allowed origin so the visitor id survives.
- The hidden
rf_*inputs (section 11) are added byinstrument_html; keep them.
lead_form#
- Same as optin, for forms whose leads are worked by sales (calls, CRM) and whose sales close later and elsewhere.
- Email and phone fields when possible: both are hashed and used to match sales (webhook, CSV import, manual).
- The form handler must store the hidden
rf_*values (section 11) with the lead in the CRM. - Funnel template:
create_funnel_from_template("lead_form")(landing → form_start → lead → thank_you → booked_call → purchase).
checkout#
- Buy buttons:
data-track="cta_checkout". - Stripe Checkout: pass the visitor id so the purchase is attributed:or set
rf.ready.then(() => { checkoutUrl.searchParams.set('client_reference_id', rf.visitorId) })client_reference_id: rf.visitorIdwhen creating the session server-side. Stripe's webhook then matches the purchase. - Other processors: send the
visitor_id(or the customer email) toPOST /api/v1/outcomes.
thank_you#
- Script present (the pageview closes the funnel step).
- Calendly embed: nothing to add,
booking_completedis detected from Calendly's postMessage. - Next-step CTAs:
data-track="cta_next".
multi_step_funnel#
- Same names on every page for the same roles. Each page is one funnel step (URL pattern) in
ensure_funnel. - Cross-domain hops: add every domain to the site's
allowed_origins; the tracker appends_rf_vidto outbound links.
6. Programmatic API (when attributes are not enough)#
rf.track('custom_event', { any: 'props' }) // free event, snake_case name
rf.quiz.start('quiz_main', 7)
rf.quiz.step('quiz_main', 3, { answer: 'b' })
rf.quiz.complete('quiz_main', { result: 'profile_a' })
rf.identify({ email, phone, firstName }) // hashed client-side
rf.set({ plan_interest: 'premium' }) // → session_facts.custom, queryable as custom.plan_interest
rf.consent('granted' | 'denied')
rf.testMode(true) // flag this session as a test session
rf.visitorId, rf.sessionId, rf.isTest, rf.version
Calls before the script loads are queued: window.rfq = window.rfq || []; rfq.push(['track', 'x']).
7. Consent#
data-consent |
Before consent | After rf.consent('granted') or a CMP signal |
|---|---|---|
auto (default) |
Anonymous session: no persistent id, no identify, no fbp/fbc | Upgrades in place: persistent visitor id, identify, match keys |
implied |
Full tracking | — |
strict |
Nothing sent | Full tracking |
Cookiebot, Iubenda and Complianz are detected automatically.
8. Test mode#
Before going live, prove every event works without polluting live data:
- Open the page with
?_rf_test=1, or adddata-test="true"on staging, or callrf.testMode(true), or turn on site-level test mode (dashboard → Sites → Test mode, or MCPset_test_mode). - Test sessions are excluded from reports, segments and ad syncs and are purged after 7 days.
- Use
test+<timestamp>@retafunnel.testas the email when submitting forms. - The MCP tools
get_test_planandverify_test_session(or the dashboard test page) give you the expected checklist and what was actually received. See docs/testing.md.
9. Sales matching (hidden fields)#
Sales for lead-form funnels close offline, days or weeks later, in a CRM or on a call. To attribute them back to the
session and the ad, the lead must carry the RetaFunnel ids into the CRM. instrument_html adds these hidden inputs
to every form with data-track-form (option salesFields, default on):
<form data-track-form="optin_main">
<input type="email" name="email">
<input type="tel" name="phone">
<input type="hidden" name="rf_visitor_id" value="" data-rf-autofill>
<input type="hidden" name="rf_session_id" value="" data-rf-autofill>
<input type="hidden" name="rf_utm_source" value="" data-rf-autofill>
<input type="hidden" name="rf_utm_campaign" value="" data-rf-autofill>
<input type="hidden" name="rf_campaign_id" value="" data-rf-autofill>
<input type="hidden" name="rf_adset_id" value="" data-rf-autofill>
<input type="hidden" name="rf_ad_id" value="" data-rf-autofill>
<input type="hidden" name="rf_fbc" value="" data-rf-autofill>
<input type="hidden" name="rf_gclid" value="" data-rf-autofill>
<button type="submit" data-track="cta_submit">Send</button>
</form>
- The tracker fills any input whose
nameoridis one ofrf_visitor_id,rf_session_id,rf_landing_url,rf_utm_source,rf_utm_campaign,rf_campaign_id,rf_adset_id,rf_ad_id,rf_fbc,rf_fbp,rf_gclidonform_startand again right beforeform_submit. It never creates inputs: addrf_landing_url/rf_fbpby hand if wanted. - Idempotent: inputs already present (by name or id) are left alone.
validate_htmlwarns (no_sales_fields) when a form with an email field has none of them. - Your form handler / CRM / Zapier step stores these fields with the lead. When the sale closes, send it back with
the email or phone (webhook
POST /api/v1/outcomes, CSV importPOST /api/v1/outcomes/import, or the Leads page): RetaFunnel matches it on the email/phone hash and attributes it to the person's sessions (first touch, last touch, linear; 90-day window by default). Full procedure: docs/sales-matching.md (MCPdocs://sales-matching). - Stripe Checkout still uses
client_reference_id = rf.visitorId; Calendly bookings are matched on the invitee email.
10. Ad URL templates#
UTMs carry names; ids are what survive renames in Ads Manager. The tracker reads both from the landing URL
(campaign_id, adset_id, ad_id, placement, utm_id) and stores them on the session; the ads report groups by
id and shows the name learned from the URL or pulled from the platform API. Paste these as the ad's URL parameters
(AD_URL_TEMPLATES in the library, GET /api/v1/ad-url-templates, MCP get_ad_url_templates):
| Platform | URL parameters |
|---|---|
| Meta | utm_source=facebook&utm_medium=paid&utm_campaign={{campaign.name}}&utm_term={{adset.name}}&utm_content={{ad.name}}&campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}&placement={{placement}} |
utm_source=google&utm_medium=cpc&utm_campaign={campaignid}&utm_term={keyword}&utm_content={creative}&campaign_id={campaignid}&adset_id={adgroupid}&ad_id={creative} |
|
| TikTok | utm_source=tiktok&utm_medium=paid&utm_campaign=__CAMPAIGN_NAME__&utm_term=__AID_NAME__&utm_content=__CID_NAME__&campaign_id=__CAMPAIGN_ID__&adset_id=__AID__&ad_id=__CID__&placement=__PLACEMENT__ |
Google fallbacks: when campaign_id / adset_id / ad_id / utm_term are absent, the tracker reads
campaignid, adgroupid, creative, keyword. gclid, fbclid and ttclid are always captured.
Nothing to add to the page: the script tag does it. Test with ?utm_source=test&campaign_id=1&adset_id=2&ad_id=3 and
check the attribution event in verify_test_session.
11. Experiments (variant)#
A variant is attached to the session from the URL (?variant=B or ?rf_variant=B), the script tag
(data-variant="B") or the API (rf.variant('B'), same as rf.set({ variant: 'B' })). It is remembered for the
session and stored as session_facts.variant; every report accepts a variant filter and the Experiments report shows
sessions, leads, bookings, purchases, revenue, uplift versus the first variant and a two-proportion z-test p-value
(after 100 sessions per variant). Serve the variant however you like (two URLs, a redirect, a client-side switch) and
keep the same element names on both; names like A/B or control/long_copy ([A-Za-z0-9_-]{1,40}).
Define the experiment with create_experiment(key, variants) so the report has a control.
12. Checklist#
- Script tag in
<head>with the rightdata-site. - Every
<a>/<button>that matters has a uniquedata-track; CTAs start withcta_. - Main form is
data-track-form="optin_main"and has an email or phone field. - Main video is
data-video-id="vsl_main". - Quiz container has
data-quiz+data-quiz-steps; every step hasdata-quiz-step; result hasdata-quiz-complete. - Key sections have
data-track-view="*_seen". - Cookie banner / carousel controls are inside
data-rf-ignore. - Checkout passes
rf.visitorIdasclient_reference_id. - Lead forms contain the hidden
rf_*inputs and the CRM stores them. - Ads use the URL templates (ids + names).
validate_html(orpnpm rf validate page.html) is clean.- Test run done,
get_launch_checklisthas no failing required item, test mode turned off,launch_funnel.
13. Tools#
- CLI:
pnpm rf validate page.html·pnpm rf instrument page.html --site rf_xxx --type landing --write - MCP:
instrument_html,validate_html,get_onboarding_guide,register_page,get_test_plan,verify_test_session,list_funnel_templates,create_funnel_from_template,get_launch_checklist,launch_funnel,get_ad_url_templates - Library:
import { instrumentHtml, validateHtml } from '@retafunnel/shared/instrument',@retafunnel/shared/funnels,/checklist,/experiments,/attribution
Last updated