Skip to content

Visual Element Click Tracking Gap

Last updated: 2026-05-30 Context: PR #743 discussion between Nicolay and McSneaky

Summary

Visual elements (Button, Text, Graphic, HTML blocks) have a significant tracking blind spot. When clicked without a clickthrough URL configured, they produce no identifiable analytics -- the system cannot tell which element was clicked, how many times, or whether it triggered anything meaningful. This matters most for Button blocks, which are increasingly used as interactive UI elements (show/hide triggers, navigation controls) rather than simple link buttons.

What Visual Element Clicks Track Today

With clickthrough URL configured

When a visual element has a clickthrough URL (set in Delivery > Visual Element Clickthroughs):

WhatFiresSource
Opens the URLYeshandleLinkOpens()
sendCount(headerClick)YesVisualElementMixin.trackClick()
sendEvent({ clickedLink, linkUrl })YesSame
clicks++NoCreative.vue uses @click.self -- broken, see click-self-issues.md
secondsToActiveYes (once)Creative.vue handleFirstInteraction via @click.capture
numUserActions++NoIntentional -- see PR #743 discussion below
Flow eventNoVisual elements are not flow components
Element identificationNoheaderClick is generic, no block name attached

Without clickthrough URL (the common case for interactive buttons)

When a visual element has no clickthrough URL -- which is the typical case for buttons used as interactive controls:

WhatFiresSource
secondsToActiveYes (once)Creative.vue handleFirstInteraction via @click.capture on container
clicks++No@click.self bug -- see click-self-issues.md
sendEvent({})NocreativeContainerClicked uses @click.self, never fires
Everything elseNohandleClick() returns immediately if no clickthrough URL

The click is effectively invisible to the analytics system. Backend knows a user touched the creative (via secondsToActive), but nothing else.

Why This Matters

Button blocks are used in two ways:

Traditional use case. User clicks, URL opens. Tracked via headerClick count and clickedLink event. Works adequately, though headerClick is a misleading name (historical artifact from when only the header/tagline had clickthroughs).

2. Interactive buttons (no clickthrough, mapped to flow via scripts)

Modern use case. Buttons trigger show/hide, flow navigation, visual state changes. These are mapped to choices using the choice-click-target script library script, which programmatically clicks hidden choice elements when buttons are clicked.

The second use case is increasingly common but has no native tracking support.

The choice-click-target Workaround

How it works

The choice-click-target script (Script-Library: scripts/flow/choice-click-target.js) bridges button clicks to choice clicks using event delegation:

Button click (user)
  -> Event delegation listener on document.body
  -> Finds matching mapping (e.g. b2 -> c1)
  -> querySelector('.c1').click()  (programmatic)
  -> Choice receives click
  -> Choice fires doSelection()
  -> Flow progresses, analytics sent

Setup required per creative

For N interactive buttons, the designer must:

  1. Create N Choice operators in the flow
  2. Add a LibraryScript operator with choice-click-target
  3. Configure N source/target mappings (e.g. b2 -> c1, b3 -> c2)
  4. Wire the flow to be circular (choices -> actions -> delay -> back to choices)
  5. Ensure choices are rendered in the conversation block (even if visually hidden)

Performance analysis

The script is very lightweight. Despite concerns about resource usage, choice-click-target does NOT use MutationObservers or DOM polling:

  • 1 event listener on document.body (event delegation -- one listener regardless of button count)
  • Per click: up to N Element.closest() calls (stops at first match), then 1 querySelector() + 1 programmatic .click()
  • AbortController for proper cleanup on re-execution
  • Re-entrancy guard (processing flag) prevents infinite click loops

With 10 button/choice mappings in a circular flow:

  • Memory: ~1 KB (one listener, one array of 10 objects)
  • CPU per click: 10 closest() traversals worst case (~100 node comparisons), negligible
  • No ongoing cost between clicks -- purely event-driven

The cost is not performance but setup complexity and architectural overhead (hidden choices, circular flows, boilerplate operators).

PR #743 Discussion: Should Visual Elements Increment numUserActions?

In the PR #743 review, McSneaky and Nicolay discussed whether VisualElementMixin.trackClick() should call recordUserAction():

McSneaky's position:

"I'm on the fence with this increasing numUserActions on visual blocks. This should be sent to Bunny as clicks for sure tho."

Key points from the discussion:

  • numUserActions was originally meant for flow interactions only (choices, form submit, expand)
  • markFirstInteraction is already handled by Creative.vue's capture-phase handler, so secondsToActive fires correctly
  • Both agreed the analytics naming needs a rework (header_clicks is misleading, renderizations isn't a real word, continued is vague)
  • Decision: leave numUserActions as-is for visual blocks; focus on Bunny migration where clicks tracks raw clicks properly

The naming confusion chain:

Engine: numUserActions
  -> Sent as: chatbotActions (legacy) / creativeActions (Bunny)
  -> Backend column: creative_actions
  -> Report: "Started" (>0), "Continued" (>1), "Total Interactions" (sum)

McSneaky: "I really-really wish to rework whole analytics on one good day."

Pipeline Constraint: Arbitrary Event Fields Are Dropped

A natural idea is to enrich existing sendEvent() calls with block identification:

javascript
Analytics.sendEvent({
  clickedBlock: 'buttonProperties-3',
  clickedBlockType: 'button',
  clickedBlockName: 'Buy Now',
})

This is trivial in the engine -- sendEvent() spreads all fields via ...eventData. But the data never reaches reports because of how the pipeline works:

StagePasses through?Why
Engine sendEvent()Yes...eventData spread, no filtering
Bunny CDN logsYesBase64-encoded JSON payload, all fields preserved
Logs-Parser DecoderYes...decodedAnalytics spread, all fields preserved
Logs-Parser Session AggregationNoHardcoded SELECT with ~28 named columns. Unknown fields dropped.
Logs-Parser Hourly AggregationNoSame -- hardcoded SELECT, ~28 columns only
Backend Bunny queriesNoQueries hardcoded Parquet columns
Reports UINoOnly displays known metrics

Key files:

  • Logs-Parser/src/Aggregator.js:21-81 -- session aggregation SELECT (drops unknown fields)
  • Logs-Parser/src/Aggregator.js:101-136 -- hourly aggregation SELECT (drops unknown fields)
  • Application-Backend/start/routes/analytics.ts:14-150 -- Bunny query (hardcoded columns)

This means any new event field requires changes in Logs-Parser + Backend, not just the engine. The same constraint applies to sendMetric() -- the custom_metrics MAP column exists in the Aggregator code but the Backend has a fallback that silently drops custom metrics if the column query fails.

This is the same class of problem as the goal/reachedEnd bug: the engine sends the data correctly, but the aggregation pipeline drops it.

Why choice-click-target Actually Works End-to-End

This pipeline constraint explains why the choice-click-target workaround is more than just a hack -- it's the only approach that works without pipeline changes:

  1. Button click -> programmatic choice click
  2. Choice fires doSelection() -> sendFlow() with messageType: 'flow'
  3. Flow events use a separate aggregation path (v3/flow/hourly-buckets) that stores object_id and hits
  4. The Aggregation API's /v5/flow endpoint queries this data and reconstructs per-node analytics
  5. Reports display per-choice hit counts in the flow analytics view

The flow analytics pipeline already supports per-element identification (via object_id). By mapping buttons to choices, you piggyback on this existing pipeline. No Logs-Parser changes, no Backend changes, no new Parquet columns.

Custom Metrics Infrastructure

The engine supports custom metrics via Analytics.sendMetric(name, value, agg?):

  • Numeric values: sum (default) or max aggregation per session
  • Boolean values: flag aggregation
  • String values: Composite key counter -- sendMetric('button_click', 'Buy Now') creates button_click__Buy Now with value 1, agg sum

Custom metrics are:

  • Sent to Bunny via the standard analytics pipeline
  • Queryable via GET /analytics/bunny/metrics/:creative_id (discovers available metrics)
  • Includable in hourly summary queries via custom_metric[] query params
  • Designed for user/script-defined tracking, not built-in engine instrumentation

Current status: The infrastructure exists in Bunny but has pipeline reliability concerns (Backend silently falls back if custom_metrics column query fails). Not yet used for any built-in tracking. Available for scripts via the ANALYTICS.sendMetric() global.

Note: sendMetric is conceptually designed for scripts and designers to define their own metrics -- not for the engine to use internally. Baking sendMetric('button_click', ...) into engine code would blur the line between built-in and custom tracking.

Conclusion

Native button click tracking with element identification requires analytics pipeline work, not just an engine change. The pipeline has three levels of granularity today:

  1. Session-level: clicks, numUserActions, secondsToActive -- "something happened"
  2. Type-level: headerClick, backgroundClick via sendCount -- "a visual element / background was clicked"
  3. Element-level: Flow analytics via sendFlow -- "choice X was selected" (per-node hit counts)

Button clicks fall into a gap: they need element-level identification (level 3) but they're not flow components. The choice-click-target workaround bridges this by making buttons act as flow components (choices), which is architecturally hacky but works end-to-end because the flow pipeline already supports per-element tracking.

A proper fix would add element-level click tracking as a pipeline feature -- a new aggregation path or an extension of the flow analytics path to support non-flow elements. This is a conversation for the analytics rework, not a quick engine patch.

Proposed Solution: Native Button Click Tracking

See todos/ButtonTracking/button-click-tracking.md for the full feature proposal.

Key Source Files

FileRepoRole
src/mixins/VisualElementMixin.tsCreative-EngineShared click handler for all visual elements
src/components/creative/VisualElements/CreativeButtonBlock.vueCreative-EngineButton block component
src/analytics/analytics.tsCreative-EnginesendMetric(), sendCount(), sendEvent()
src/components/creative/Creative.vueCreative-EngineContainer click tracking, first interaction
scripts/flow/choice-click-target.jsScript-LibraryButton-to-choice mapping script
src/services/scriptLibrary.tsApplication-FrontendScript Library CDN fetch and assembly
src/pages/.../operators/LibraryScriptOp.vueApplication-FrontendScript Library operator UI

Internal documentation