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_apply
    • nav_* for header/footer/navigation links: nav_about, nav_privacy
    • faq_* for FAQ toggles: faq_1, faq_refund
    • *_seen for data-track-view: hero_seen, pricing_seen, testimonials_seen
    • optin_main for the main lead form; form_<n> or a descriptive name for others (form_contact)
    • vsl_main for the main video; video_<n> or descriptive (video_testimonial_1) for others
    • quiz_main for 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 by validate_html.
  • Use the same names on every page of a funnel for the same role (cta_hero on 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-view on 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, add enablejsapi=1 to the src (the tracker adds it when missing).
  • CTA that appears after N minutes: data-track="cta_vsl"; wrap it in a data-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-active class.
  • 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 the data-quiz-complete element.

optin#

  • <form data-track-form="optin_main"> with an email (and optionally phone) field; submit button data-track="cta_submit".
  • Thank-you redirect stays on an allowed origin so the visitor id survives.
  • The hidden rf_* inputs (section 11) are added by instrument_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:
    rf.ready.then(() => { checkoutUrl.searchParams.set('client_reference_id', rf.visitorId) })
    
    or set client_reference_id: rf.visitorId when creating the session server-side. Stripe's webhook then matches the purchase.
  • Other processors: send the visitor_id (or the customer email) to POST /api/v1/outcomes.

thank_you#

  • Script present (the pageview closes the funnel step).
  • Calendly embed: nothing to add, booking_completed is 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_vid to 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']).

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 add data-test="true" on staging, or call rf.testMode(true), or turn on site-level test mode (dashboard → Sites → Test mode, or MCP set_test_mode).
  • Test sessions are excluded from reports, segments and ad syncs and are purged after 7 days.
  • Use test+<timestamp>@retafunnel.test as the email when submitting forms.
  • The MCP tools get_test_plan and verify_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 name or id is one of rf_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_gclid on form_start and again right before form_submit. It never creates inputs: add rf_landing_url / rf_fbp by hand if wanted.
  • Idempotent: inputs already present (by name or id) are left alone. validate_html warns (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 import POST /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 (MCP docs://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}}
Google 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#

  1. Script tag in <head> with the right data-site.
  2. Every <a>/<button> that matters has a unique data-track; CTAs start with cta_.
  3. Main form is data-track-form="optin_main" and has an email or phone field.
  4. Main video is data-video-id="vsl_main".
  5. Quiz container has data-quiz + data-quiz-steps; every step has data-quiz-step; result has data-quiz-complete.
  6. Key sections have data-track-view="*_seen".
  7. Cookie banner / carousel controls are inside data-rf-ignore.
  8. Checkout passes rf.visitorId as client_reference_id.
  9. Lead forms contain the hidden rf_* inputs and the CRM stores them.
  10. Ads use the URL templates (ids + names).
  11. validate_html (or pnpm rf validate page.html) is clean.
  12. Test run done, get_launch_checklist has 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