Appearance
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):
| What | Fires | Source |
|---|---|---|
| Opens the URL | Yes | handleLinkOpens() |
sendCount(headerClick) | Yes | VisualElementMixin.trackClick() |
sendEvent({ clickedLink, linkUrl }) | Yes | Same |
clicks++ | No | Creative.vue uses @click.self -- broken, see click-self-issues.md |
secondsToActive | Yes (once) | Creative.vue handleFirstInteraction via @click.capture |
numUserActions++ | No | Intentional -- see PR #743 discussion below |
| Flow event | No | Visual elements are not flow components |
| Element identification | No | headerClick 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:
| What | Fires | Source |
|---|---|---|
secondsToActive | Yes (once) | Creative.vue handleFirstInteraction via @click.capture on container |
clicks++ | No | @click.self bug -- see click-self-issues.md |
sendEvent({}) | No | creativeContainerClicked uses @click.self, never fires |
| Everything else | No | handleClick() 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:
1. Link buttons (clickthrough configured)
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 sentSetup required per creative
For N interactive buttons, the designer must:
- Create N Choice operators in the flow
- Add a LibraryScript operator with
choice-click-target - Configure N source/target mappings (e.g.
b2 -> c1,b3 -> c2) - Wire the flow to be circular (choices -> actions -> delay -> back to choices)
- 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 1querySelector()+ 1 programmatic.click() - AbortController for proper cleanup on re-execution
- Re-entrancy guard (
processingflag) 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
numUserActionson visual blocks. This should be sent to Bunny asclicksfor sure tho."
Key points from the discussion:
numUserActionswas originally meant for flow interactions only (choices, form submit, expand)markFirstInteractionis already handled byCreative.vue's capture-phase handler, sosecondsToActivefires correctly- Both agreed the analytics naming needs a rework (
header_clicksis misleading,renderizationsisn't a real word,continuedis vague) - Decision: leave
numUserActionsas-is for visual blocks; focus on Bunny migration whereclickstracks 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:
| Stage | Passes through? | Why |
|---|---|---|
Engine sendEvent() | Yes | ...eventData spread, no filtering |
| Bunny CDN logs | Yes | Base64-encoded JSON payload, all fields preserved |
| Logs-Parser Decoder | Yes | ...decodedAnalytics spread, all fields preserved |
| Logs-Parser Session Aggregation | No | Hardcoded SELECT with ~28 named columns. Unknown fields dropped. |
| Logs-Parser Hourly Aggregation | No | Same -- hardcoded SELECT, ~28 columns only |
| Backend Bunny queries | No | Queries hardcoded Parquet columns |
| Reports UI | No | Only 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:
- Button click -> programmatic choice click
- Choice fires
doSelection()->sendFlow()withmessageType: 'flow' - Flow events use a separate aggregation path (
v3/flow/hourly-buckets) that storesobject_idandhits - The Aggregation API's
/v5/flowendpoint queries this data and reconstructs per-node analytics - 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) ormaxaggregation per session - Boolean values:
flagaggregation - String values: Composite key counter --
sendMetric('button_click', 'Buy Now')createsbutton_click__Buy Nowwith value 1, aggsum
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:
- Session-level:
clicks,numUserActions,secondsToActive-- "something happened" - Type-level:
headerClick,backgroundClickviasendCount-- "a visual element / background was clicked" - 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.
Related Documents
- engine-analytics.md -- Full engine analytics system reference
- click-self-issues.md -- Known
@click.selfbugs affectingclickscounter - goal-reachedend-bug.md -- Similar aggregation gap (engine sends data that pipeline drops)
- ../architecture/click-handling-and-event-propagation.md -- Click event flow through component hierarchy
- ../script-library.md -- Script Library system (choice-click-target is a library script)
- ../todos/flow-link-click-reporting.md -- Related: link clicks in flow not reporting properly
Key Source Files
| File | Repo | Role |
|---|---|---|
src/mixins/VisualElementMixin.ts | Creative-Engine | Shared click handler for all visual elements |
src/components/creative/VisualElements/CreativeButtonBlock.vue | Creative-Engine | Button block component |
src/analytics/analytics.ts | Creative-Engine | sendMetric(), sendCount(), sendEvent() |
src/components/creative/Creative.vue | Creative-Engine | Container click tracking, first interaction |
scripts/flow/choice-click-target.js | Script-Library | Button-to-choice mapping script |
src/services/scriptLibrary.ts | Application-Frontend | Script Library CDN fetch and assembly |
src/pages/.../operators/LibraryScriptOp.vue | Application-Frontend | Script Library operator UI |