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.
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.