Skip to content

[Feature] Add Track component for declarative analytics tracking#497

Open
titouanmathis wants to merge 6 commits intodevelopfrom
feature/track-component
Open

[Feature] Add Track component for declarative analytics tracking#497
titouanmathis wants to merge 6 commits intodevelopfrom
feature/track-component

Conversation

@titouanmathis
Copy link
Contributor

@titouanmathis titouanmathis commented Jan 28, 2026

🔗 Linked issue

#495

❓ Type of change

  • 📖 Documentation (updates to the documentation, readme or JSdoc annotations)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Implements a generic, declarative component to standardize analytics tracking compatible with GTM/dataLayer, GA4, Segment, and custom backends.

Features:

  • Track component with data-on:* event syntax (click, submit, view, mounted, etc.)
  • TrackContext component for hierarchical context data merging
  • Event modifiers (.prevent, .stop, .once, .debounce, .throttle, etc.)
  • IntersectionObserver-based impression tracking (view event)
  • CustomEvent support with $detail.* placeholder syntax
  • Configurable dispatcher via setTrackDispatcher()
  • Default dispatcher pushes to window.dataLayer (GTM)

📝 Checklist

  • I have linked an issue or discussion.
  • I have added tests (if possible).
  • I have updated the documentation accordingly.
  • I have updated the changelog.

@codecov
Copy link

codecov bot commented Jan 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 52.32%. Comparing base (52f37b3) to head (be329a1).

❗ There is a different number of reports uploaded between BASE (52f37b3) and HEAD (be329a1). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (52f37b3) HEAD (be329a1)
unittests 5 4
Additional details and impacted files
@@              Coverage Diff               @@
##             develop     #497       +/-   ##
==============================================
- Coverage      67.96%   52.32%   -15.65%     
  Complexity        20       20               
==============================================
  Files             77        4       -73     
  Lines           1998       86     -1912     
  Branches         357        0      -357     
==============================================
- Hits            1358       45     -1313     
+ Misses           558       41      -517     
+ Partials          82        0       -82     
Flag Coverage Δ
unittests 52.32% <ø> (-15.65%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 73 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link

github-actions bot commented Jan 28, 2026

Export Size

@studiometa/ui

Name Size Diff
Track 1.22 kB +1.22 kB (+100.00%) 🔺
getTrackDispatcher 126 B +126 B (+100.00%) 🔺
TrackContext 116 B +116 B (+100.00%) 🔺
setTrackDispatcher 51 B +51 B (+100.00%) 🔺
Unchanged

@studiometa/ui

Name Size Diff
AbstractFrameTrigger 1.74 kB -
AbstractPrefetch 366 B -
AbstractScrollAnimation 3.66 kB -
AbstractSliderChild 600 B -
Accordion 1.77 kB -
AccordionItem 1.75 kB -
Action 1.11 kB -
AnchorNav 3.85 kB -
AnchorNavLink 3.74 kB -
AnchorNavTarget 125 B -
AnchorScrollTo 2.53 kB -
animationScrollWithEase 763 B -
CircularMarquee 550 B -
Cursor 650 B -
DataBind 697 B -
DataComputed 856 B -
DataEffect 837 B -
DataModel 780 B -
Draggable 1.64 kB -
Fetch 2.34 kB -
Figure 1.72 kB -
FigureShopify 1.98 kB -
FigureTwicpics 2.26 kB -
FigureVideo 1.87 kB -
FigureVideoTwicpics 2.44 kB -
Frame 3.47 kB -
FrameAnchor 1.84 kB -
FrameForm 1.92 kB -
FrameLoader 1.45 kB -
FrameTarget 1.75 kB -
FrameTriggerLoader 1.46 kB -
Hoverable 953 B -
LargeText 713 B -
LazyInclude 322 B -
Menu 2.33 kB -
MenuBtn 140 B -
MenuList 1.9 kB -
Modal 1.99 kB -
ModalWithTransition 2.09 kB -
Panel 2.38 kB -
PrefetchWhenOver 408 B -
PrefetchWhenVisible 417 B -
ScrollAnimation 3.79 kB -
ScrollAnimationChild 3.91 kB -
ScrollAnimationChildWithEase 4.51 kB -
ScrollAnimationParent 3.98 kB -
ScrollAnimationTarget 3.85 kB -
ScrollAnimationTimeline 3.92 kB -
ScrollAnimationWithEase 4.39 kB -
ScrollReveal 1.63 kB -
Sentinel 129 B -
Slider 2.3 kB -
SliderBtn 817 B -
SliderCount 650 B -
SliderDots 1.86 kB -
SliderDrag 269 B -
SliderItem 998 B -
SliderProgress 961 B -
Sticky 771 B -
Tabs 1.38 kB -
Target 86 B -
Transition 1.41 kB -
withDeprecation 166 B -
withScrollAnimationDebug 2.04 kB -
withTransition 1.39 kB -

@titouanmathis titouanmathis force-pushed the feature/track-component branch 5 times, most recently from b28de35 to 6c31f84 Compare January 28, 2026 21:25
titouanmathis and others added 3 commits January 29, 2026 14:49
Implements #495 - A generic, declarative component to standardize analytics
tracking compatible with GTM/dataLayer, GA4, Segment, and custom backends.

Features:
- Track component with data-on:* event syntax (click, submit, view, mounted, etc.)
- TrackContext component for hierarchical context data merging
- Event modifiers (.prevent, .stop, .once, .debounce, .throttle, etc.)
- IntersectionObserver-based impression tracking (view event)
- CustomEvent support with $detail.* placeholder syntax
- Configurable dispatcher via setTrackDispatcher()
- Default dispatcher pushes to window.dataLayer (GTM)

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
- Test getNestedValue returning undefined on non-object path traversal
- Test .detail modifier for full event.detail merging

Co-authored-by: Claude <claude@anthropic.com>
@titouanmathis titouanmathis force-pushed the feature/track-component branch from 509f0b1 to cf3d7c5 Compare January 29, 2026 13:50
Co-authored-by: Claude <claude@anthropic.com>
@antoine4livre
Copy link
Contributor

antoine4livre commented Jan 29, 2026

@titouanmathis Great addition !

Do you think it would be relevant to do away with JSON by abandoning the simplified notation from Action in favor of a simpler API, since there's rarely more than one listener for data layers, it seems to me?

<button
  data-component="Track"
  data-option-on="click"
  data-option-data:event="cta_click"
  data-option-data:location="header"
  data-option-data:button_text="Subscribe"
  class="px-4 py-2 bg-blue-400 text-white rounded hover:bg-blue-500">
  Subscribe
</button>

In general, I'm not a big fan of having to use single quotes in HTML attributes to be able to use JSON.
That said, if it's common to directly copy-paste JSON objects, my suggestion no longer applies.

@titouanmathis
Copy link
Contributor Author

@antoine4livre you are right that having multiple event listeners on a single element might be rare, but the use of JSON allow us to send complex data structure to the data layer (arrays, nested objects, etc.), which is common for e-commerce tracking (GA4's ecommerce.items for example).

I am considering adding support for JSON5 at the framework level for all options, which would enable writing less verbose JSON-like attributes:

<!-- JSON -->
<div data-component="Foo" data-option-config='{ "key": "value" }'>

<!-- JSON5 -->
<div data-component="Foo" data-option-config="{ key: 'value' }">

This would come with a size cost for the framework, but it might be useful enough to outweigh this.

@titouanmathis
Copy link
Contributor Author

⚠️ Review: Potential Conflict with Action Component

Issue: data-on:* Attribute Namespace Collision

Both the Track (new) and Action (existing) components use the same data-on:* attribute pattern but with completely different value semantics:

Component Attribute Example Value Type
Action data-on:click="target.$el.textContent = 'Clicked'" JavaScript expression
Track data-on:click='{"event": "cta_click"}' JSON object

Conflict Scenarios

  1. Using both components on the same element:

    • Track will try to JSON.parse() Action's JS code → Parse error
    • Action will try to interpret Track's JSON as effect definition → Undefined behavior
  2. Developer confusion: Same attribute name with different meanings across components

  3. No runtime isolation: Both components iterate over all data-on:* attributes without distinguishing between them

Recommended Solutions

  1. Rename Track's attribute prefix (preferred):

    • Use data-track:click instead of data-on:click
    • Or data-on-track:click to keep the on semantic
  2. Alternative: Use different attribute for tracking data:

    • Keep event in attribute name: data-track-click='{"event": "..."}'
    • Or single attribute: data-track='{"on": "click", "event": "..."}'
  3. Document mutual exclusivity (minimum): If keeping current API, document that Track and Action cannot coexist on the same element.

titouanmathis and others added 2 commits February 14, 2026 12:26
The Action component already uses data-on:* attributes with JS expression
values. Using the same prefix for Track (with JSON values) would cause
namespace collisions. Using data-track:* makes the API unambiguous.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
@perruche
Copy link
Contributor

Hey !
I agree with @antoine4livre that using JSON seems like a bad idea at first glance :

  • liquid is quite terrible at handling it and manipulate it
  • this seems really error prone, and errors would be silent?. Eg my product name include ' or " will break everything ?

Suggestions:

  • Use the js-toolkit capabilities to handle arrays / object, by using the Parent/child pattern
  • Make every HTML data attribute, handle only their own unique source of truth (will also be way easier to read by humans)
  • Rely heavely on the JS to be able to type everything (and unlock the ability to send good errors feedback to the console.

Exemple:

<div data-component="Track" 
  data-option-event="view_item_list" 
  data-option-trigger="mounted">
  {% for product in collection.products %}
    <div
      data-component="TrackItem"
      data-option-item-id="{{ product.id }}"
      data-option-item-name="{{ product.title }}"
      data-option-item-price="{{ product.price | money_without_currency }}"
      data-option-item-anything="MY VALUE">
    </div>
  {% endfor %}
</div>

Downside of this is multiple "useless" HTML elements

Questions:

  • isn't this super dangerous to expose all the data into the HTML ? Like won't we have someone expose user emails directly into this ?
  • How does component work under the GDPR ?
  • Could also be awesome to have a debug mode that have good formatting to see all events clearly

Up for discuting this in depth if needed

@titouanmathis
Copy link
Contributor Author

Hey @perruche, thanks for the detailed feedback!

On JSON complexity

I hear you, but the datalayer often requires complex payloads (nested objects, arrays) that are hard to map 1:1 to flat HTML attributes. GA4's ecommerce.items is a good example. I'm considering adding JSON5 support at the framework level which would make this less verbose and more forgiving with quotes (see my reply to Antoine).

I'll review how we can have both approach available and get back to you.

On the Parent/Child pattern

This is actually already available via TrackContext, which provides hierarchical context data that gets merged into child Track components. So you can already split data across multiple elements if that fits your use case better.

On security

The component uses the exact same data that is already present in the datalayer — it's only a structured API to push data to it. No new data is exposed, so no new risk.

On GDPR

Same answer: the component only pushes to the datalayer (or any other dispatcher configured via setTrackDispatcher), it doesn't collect or store anything on its own.

On debug mode

This could be implemented with the $log/$debug methods from js-toolkit, or with a custom dispatcher like in the docs example (packages/docs/components/Track/stories/basic/app.js) which updates a debug element with the full dataLayer content.

Any opinion on which solution to implement?

@antoine4livre
Copy link
Contributor

antoine4livre commented Feb 17, 2026

After a second read, I agree that this component should be able to listen to multiple events on the same element.

The data attribute should be different from the action’s one as mentioned. data-track:click seems good to me.

What about an application/json <script> tag used as a ref or child inside the component for the data object? Maybe with a naming or selector system to have more than one data structure linked to different events.

Concerning the debug mode, $log solution could be enough while GTM provides a browser extension to check this, I guess?

@perruche
Copy link
Contributor

This is honestly painfully heartbreaking to get sent an AI response

Especially when the content is meh?


GA4's ecommerce.items is a good example.

It's actually quite not a good example ecommerce.items can only take strings and numbers : see the doc

and how to pass multiple categories to an item

items: [
    {
      item_brand: "Google",
      item_category: "Apparel",
      item_category2: "Adult",
      item_category3: "Shirts",
      item_category4: "Crew",
      item_category5: "Short sleeve"
   }
]

(packages/docs/components/Track/stories/basic/app.js) which updates a debug element with the full dataLayer content.

Linking the wrong url, file only exist in the PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants