December 2025
Decoupling the UI from telemetry
When we started adding telemetry to agentic search, it kept getting mixed into the UI logic. Components typically logged by importing the logging service, reading shared fields from the Redux store, and building the payload in an event handler.
It got to the point where that pattern had spread across more than ten components, and we were still adding new telemetry cases. Some components called the logging service directly. Others dispatched a DOM event and let a parent gather shared fields, reshape the payload, and send another event upward. Either way, there wasn’t a single place to see which actions were logged or how payloads were assembled.
I wanted to decouple UI components from telemetry concerns. I set out to centralize that work in one place, so a component could broadcast when something should be logged and not care how the logging actually happened.
How telemetry used to travel
The most common path was logging from the component itself. Here’s an example result card that logs an interaction when it’s clicked:
import { LightningElement } from 'lwc';import { store } from 'results/state';import { InteractionTypes, LoggingService } from 'results/telemetry'; class ResultCard extends LightningElement { private result: SearchResult; private logger = new LoggingService(); handleClick() { const { viewMode, currentPage } = store.getState(); this.logger.logInteraction({ interactionType: InteractionTypes.RESULT_CLICK, resultId: this.result.id, viewMode, currentPage, timestamp: Date.now() }); }}One click needed a store read, an interaction constant, a logging-service call, and the full payload assembled in the handler.
Every instrumented component repeated that. If a shared field like viewMode changed, each of those handlers had to be updated by hand.
Other components avoided the logging service by dispatching a DOM event and letting a parent finish the job. The parent read shared fields from the store and dispatched a second event with a fuller payload:
import { LightningElement } from 'lwc';import { store } from 'results/state'; class ResultsList extends LightningElement { handleResultClick(event: CustomEvent<{ resultId: string }>) { const { viewMode, currentPage } = store.getState(); this.dispatchEvent( new CustomEvent('resultclick', { detail: { resultId: event.detail.resultId, viewMode, currentPage }, bubbles: true, composed: true }) ); }}The child no longer imported the logging service, but the setup was brittle and tightly coupled to DOM placement.
Telemetry depended on this parent existing and listening, and there was no strict data model for what traveled between them. A result click went in, a service-shaped payload came out. Move the child, and the chain broke.
A shared event protocol
I needed a way for a deeply nested component to reach a handler above it without importing the handler and without asking every parent on the path to know about that handler.
DOM events already bubble through the tree and can cross shadow boundaries, so they became the natural starting point.
A plain CustomEvent is the naive solution. It works, but any component can make up a name or detail shape, which is how payloads started mutating between layers:
this.dispatchEvent( new CustomEvent('resultclick', { detail: { resultId: this.result.id }, bubbles: true, composed: true }));I tried sharing one event name and detail shape through a constant first. Fewer magic strings at the call site:
const TELEMETRY_EVENT = 'telemetry'; this.dispatchEvent( new CustomEvent(TELEMETRY_EVENT, { detail: { action: 'resultClicked', payload: { resultId: this.result.id } }, bubbles: true, composed: true }));It was an improvement, but every call site still had to set bubbles and composed by hand. Miss either one and the event never reaches the top.
The detail shape was also just a "convention." Nothing stopped a component from sending a different action string or payload. Correctness wasn’t guaranteed.
So I moved the shared name and the bubbling flags into a TelemetryEvent class:
interface TelemetryEventDetail { action: string; payload: Record<string, unknown>;} export class TelemetryEvent extends CustomEvent<TelemetryEventDetail> { static readonly NAME = 'telemetry'; constructor(action: string, payload: Record<string, unknown>) { super(TelemetryEvent.NAME, { detail: { action, payload }, bubbles: true, composed: true }); }}Call sites no longer configure events manually. But a public constructor still allows the action and payload to be set incorrectly.
To enforce correctness, I made the constructor private and added static factory methods instead, a pattern inspired by Redux action creators. Each factory owns one action and its payload shape:
type TelemetryEventDetail = | { action: 'resultClicked'; payload: { resultId: string } } | ...; export class TelemetryEvent extends CustomEvent<TelemetryEventDetail> { static readonly NAME = 'telemetry'; private constructor(detail: TelemetryEventDetail) { super(TelemetryEvent.NAME, { detail, bubbles: true, composed: true }); } static forResultClicked(resultId: string) { return new TelemetryEvent({ action: 'resultClicked', payload: { resultId } }); } // ...}The detail type is a discriminated union, so TypeScript checks the action and payload together inside each factory.
Adding a new telemetry case is just another branch on that union and another factory. The import surface stays unchanged.
The factory naming was also intentional. It follows a forAction() convention so that, from the consumer’s perspective, the call reads like natural language:
import { TelemetryEvent } from 'results/telemetry'; this.dispatchEvent( TelemetryEvent.forResultClicked(this.result.id));Components only pass the local data they own. They import TelemetryEvent, call the appropriate factory, and dispatch. They don’t need to worry about how the event is handled.
Listening once at the top
Once every component emitted the same event shape, the page only needed one central listener. I put that in a new root component called TelemetryRouter.
It wraps the entire search page so any nested component’s dispatch can reach it:
<template> <telemetry-router> <agentic-search-page></agentic-search-page> </telemetry-router></template>Under the hood, the router listens for telemetry events, stops them from propagating further, maps each action to a logging service call, and enriches their payloads with shared context from the store:
import { LightningElement } from 'lwc';import { store } from 'results/state';import { InteractionTypes, LoggingService, TelemetryEvent} from 'results/telemetry'; class TelemetryRouter extends LightningElement { private logger = new LoggingService(); private telemetryHandler = this.handleTelemetryEvent.bind(this); connectedCallback() { this.addEventListener( TelemetryEvent.NAME, this.telemetryHandler ); } disconnectedCallback() { this.removeEventListener( TelemetryEvent.NAME, this.telemetryHandler ); } private handleTelemetryEvent(event: TelemetryEvent) { event.stopPropagation(); const { detail } = event; const { viewMode, currentPage } = store.getState(); const context = { viewMode, currentPage, timestamp: Date.now() }; switch (detail.action) { case 'resultClicked': this.logger.logInteraction({ interactionType: InteractionTypes.RESULT_CLICK, ...context, ...detail.payload }); break; // ... } }}The router is the sole consumer of the logging service and its constants. Payload assembly that used to be scattered across multiple UI handlers now lives here.
TelemetryEvent is the shared protocol between the UI components and the router. A component dispatches it, the router handles it, and nothing in between needs to know about it.
Why not send telemetry through the store?
This pattern is close enough to Redux that the question naturally arises: why invent a separate event protocol when components could dispatch store actions and let middleware call the logging service?
Telemetry and state updates aren’t the same set of events. Some logged moments never touch the store. Routing them through Redux either invents telemetry-only actions or piggybacks logging onto real state actions until the two get tangled.
A dedicated protocol keeps those concerns apart. A nested component can dispatch with the local data it already has, without taking a store dependency just to log. The router stays the one place that maps those signals onto the logging service, separate from reducers and store wiring.
What changed in practice
Back to the result card example from earlier. Same click, same local data, but the logging service, interaction constants, and store read are gone:
import { LightningElement } from 'lwc';import { TelemetryEvent } from 'results/telemetry'; class ResultCard extends LightningElement { private result: SearchResult; handleClick() { this.dispatchEvent( TelemetryEvent.forResultClicked(this.result.id) ); }}The intermediate parent work went away too. That results list from earlier no longer listens for a child event, reshapes the payload, and re-dispatches. The event travels straight from the card to the router.
Shared fields and the mapping from action to logging-service call live in the router. Updating them means editing that one component, not going on a scavenger hunt through UI handlers. Components can move under the router without dragging a listener chain with them.
The page finally has a single place to see which actions get logged and how payloads are assembled.
What it cost
A new logging case still needs three changes:
- A new branch on the
TelemetryEventDetailunion - A factory that builds that case in the event class
- A matching handler in the router
Testing also got trickier at the component boundary. A unit test can assert that the right TelemetryEvent was dispatched, but not that the logging service ran. For every instrumented component, proving that means mounting it under a TelemetryRouter, which creates additional setup overhead.
In practice, those costs are a viable tradeoff. The alternative is telemetry mixed back into UI code across an open-ended set of components: tightly coupled to the store, fragile when shared fields change, and costly to extend every time there’s a new case to log.
The UI stays focused on interaction, and telemetry finally has a central home. That was the goal.