SCAL-333657: track which host event is triggered and with which parameters - #635
SCAL-333657: track which host event is triggered and with which parameters#635sastaachar wants to merge 18 commits into
Conversation
SCAL-333657 trigger() uploaded `visual-sdk-trigger-<HostEvent>` with no properties, so the only answerable question was whether a host event fired at all, across ~90 separate Mixpanel event names. Which parameters customers actually use, which embed component triggered, and how the trigger resolved were all invisible. Adds a `visual-sdk-host-event` upload, fired once when a trigger settles or bails out, with `hostEvent` as a property so one report can rank host events and their parameters. The existing per-event upload keeps its name for existing dashboards and now carries the same properties. Payload values never leave the browser: a value is reported as its `typeof` (`name:string`), booleans included. SDK enum members are the one exception (`operator:EQ`) - a fixed token from our own contract - and only when the key is a known enum-valued parameter and the value matches that enum exactly. Key names are reported only when they read as code identifiers, since a payload can be keyed by a customer column name, and error messages are never uploaded - only a status and our own EmbedErrorCodes. Two further fixes fall out of this: - A trigger the embedded app never answered was indistinguishable from a successful one, because processTrigger resolves, rather than rejects, with Error(TRIGGER_TIMED_OUT). It now reports status 'timed-out'. - The pre-init event queue in mixpanel-service was unbounded, and with `disableSDKTracking` set initMixpanel never runs, so the queue grew for the lifetime of the page. Capped at 100. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Browser verification showed a passthrough getter reporting `route: 'ui-passthrough'` while the message was ultimately carried by the legacy channel, because getDataWithPassthroughFallback falls back when the app returns no usable response. The caveat was documented for custom handlers only; it applies to the getter branch as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SCAL-333657
Review catch. For the four host events dispatched through a custom handler —
Pin, SaveAnswer, UpdateFilters, DrillDown — a real 30s timeout was reported
as a generic `status: 'error'`, which undercounted exactly the signal this
work exists to surface.
processTrigger resolves, rather than rejects, with Error(TRIGGER_TIMED_OUT).
An Error has no `.find`, so handleHostEventWithParam saw a missing response
and threw a plain `{ error: 'No answer found' }`, losing the timeout before
trigger() could classify it. The `settled instanceof Error` check in the
success path never ran, because the promise had rejected.
The thrown shape is unchanged, so nothing a host application catches today
moves; an `isTimeout` flag rides alongside it and trigger() reports
'timed-out'. The timeout predicate now lives once, in processTrigger, and
both call sites share it.
Also from review:
- The paramShape doc comment still claimed booleans are reported as-is; they
have been reduced to `boolean` since the typeof-not-value rule landed.
- `route` is declared `HostEventRoute | undefined`, matching the branches that
report before dispatch ever happens.
- Tests for the 'custom-handler' and 'ui-passthrough' routes, the legacy
fallback, and a regression test for the timeout above — verified to fail
without the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces comprehensive telemetry for host events, capturing event triggers, parameter shapes (with customer data redacted), routing paths, and resolution statuses (success, error, timeout) to Mixpanel. It also caps the pre-initialization Mixpanel event queue to prevent memory leaks when tracking is disabled. Feedback on the changes suggests using optional chaining on the parameters object in host-event-client.ts to prevent potential runtime crashes if it is nullish.
| const response = raw?.find?.((r) => r.error || r.value); | ||
|
|
||
| if (!response) { | ||
| const error = `No answer found${parameters.vizId ? ` for vizId: ${parameters.vizId}` : ''}.`; |
There was a problem hiding this comment.
To prevent potential runtime TypeError crashes if parameters is ever nullish (e.g., null or undefined), use optional chaining (parameters?.vizId) when checking for the presence of vizId.
| const error = `No answer found${parameters.vizId ? ` for vizId: ${parameters.vizId}` : ''}.`; | |
| const error = 'No answer found' + (parameters?.vizId ? ' for vizId: ' + parameters.vizId : '') + '.'; |
Review summaryReviewed the new host-event / embed-event telemetry pipeline ( One correctness finding, posted inline on No breaking changes to the public API surface — Style guideNo documentation findings in this diff — no |
commit: |
…omments SCAL-333657 Review feedback, seven points. Fold the data into the existing upload instead of adding a second one. The per-event `visual-sdk-trigger-<HostEvent>` name is unchanged, but it now fires once when the trigger settles and carries the parameters, outcome and duration, so there is one upload per trigger rather than two. Report how long a trigger took, and emit `visual-sdk-host-event-no-response` alongside the per-event upload when the embedded app never answers. Track embed events too. `executeCallbacks` is the single point every embed event passes through, so one hook there covers all of them, reporting the event, its payload shape, whether the host application has a handler registered, and the event's own status. Embed payloads are large and full of customer data, so they get the same shape-only treatment as host events, with a tighter path cap. Keep it off the critical path. Nothing is walked or built when a host application has set `disableSDKTracking`, telemetry is reported after the host's own handlers have run, and the payload walk is deferred to requestIdleCallback, so an event on a hot path costs the caller a closure. Drop the comments. The one that remains is a note on the enum map, which is hand-maintained and does not scale: an unlisted enum parameter silently degrades to `string` and nothing fails, so it needs a better answer than a list someone has to remember to update. The module covers both event kinds now, so hostEventTelemetry is eventTelemetry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reshaped per feedback, in 2. Send the extra data on the existing event — done, and it was the better call. The separate aggregate event is gone. 3. Duration, and a no-response event. Every upload now carries 4. Embed events tracked. 5. Types only, no values, for embed payloads too. Same shape summariser, with a tighter cap (20 paths vs 40) because embed payloads are much larger. A 6. Non-blocking, minimal cost. Three things: nothing is walked or built when a host application sets 1. Comments removed. All of them, from the new code — including the JSDoc stubs. Lint stays at 0 errors, so nothing required them. 7. The enum map — kept, with the note you asked for. It is the one comment left in the file, and it says what is wrong with it: the map is hand-maintained, an enum parameter nobody adds silently degrades to VerificationFull suite 1729 passed (47 files), Browser harness 37/37, now including embed events driven over the real postMessage path rather than a direct call — so the whole chain ( One incidental finding: |
| embedEvent: eventType, | ||
| payload: data, | ||
| embedComponentType: this.viewConfig?.embedComponentType, | ||
| handlerCount: callbacks.length, |
There was a problem hiding this comment.
handlerCount counts every handler registered for eventType (plus EmbedEvent.ALL), not the ones that actually ran for this dispatch. The forEach above only invokes callbackObj.callback when callbackObj.options.start matches dataStatus (line 1441-1445) — e.g. a caller that registers one { start: true } handler and one default (end) handler for the same EmbedEvent will see handlerCount: 2 reported on every single dispatch, even though only one of the two handlers ran for that particular start/end payload.
Since MessageOptions.start is a documented, supported way to subscribe separately to start/end (src/types.ts MessageOptions), this isn't a hypothetical: any embed that splits start/end handling for the same event type will get telemetry that overstates "whether anyone listens" for that specific payload.
Suggest counting only the handlers that were actually invoked, e.g. track a counter inside the forEach instead of using callbacks.length.
| const responder = this.createEmbedEventResponder(eventPort, eventType); | ||
| callbackObj.callback(data, responder); | ||
| } | ||
| }); |
There was a problem hiding this comment.
reportEvent's buildProps closure (in src/utils/eventTelemetry.ts) is only evaluated later, inside a requestIdleCallback/setTimeout deferral — by design, so telemetry never blocks the host application's handler (covered by the "does not block the host application handler" test). But data is captured by reference: callbackObj.callback(data, responder) on line 1442 hands the same object to every registered handler before this deferred read happens. If any handler mutates data in place (e.g. normalizing/redacting a field on the object it was given), describePayload(data) will describe the post-mutation shape, not what was actually dispatched to on() listeners at line 1442. Not exploitable, but worth a code comment or a shallow-clone-before-reporting if the shape is meant to reflect what handlers received.
SCAL-333657 Registration uploads were mostly noise: for a full-height Liveboard with two host handlers, 12 `visual-sdk-on-*` uploads went out and only 2 of them were the host application's. The other 10 were the SDK registering its own plumbing — auth, session timeout, iframe height, embed coordinates. `isRegisteredBySDK` was meant to mark those, but it did not work: - `V1Embed.on` overrode the method with a three-parameter signature and dropped the flag on the way to `TsEmbed.on`, so every internal registration on a Liveboard, Pinboard or App embed arrived marked as a host registration. - The full-height handlers in `liveboard.ts` and `app.ts` never passed it. With the flag repaired and honoured, that case goes from 12 uploads to 2, and what remains answers a real question: which embed events host applications subscribe to. Occurrence tracking is unaffected — `visual-sdk-embed-event` still reports every embed event that actually arrives. The `on()` upload also now goes through `reportEvent`, so it respects `disableSDKTracking` and stays off the critical path like the rest. Three specs asserted the old two-argument `on()` call and are updated to the four-argument form. The behaviour they cover — which handlers get registered for a full-height embed — is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Registration noise cut, in Measured, for a full-height Liveboard with two host handlers:
Both fixed, and the What survives is the signal worth having: which embed events host applications actually subscribe to. Occurrence tracking is untouched — One thing to flag on the diff: three existing specs asserted the old two-argument Full suite 1731 passed (47 files), harness 34/34. |
…payload first SCAL-333657 Review catches on the embed event hook. `handlerCount` used every handler registered for the event type, not the ones this dispatch invoked. `MessageOptions.start` is a supported way to subscribe to start and end separately, so an embed registering one of each for the same event reported `handlerCount: 2` on both dispatches when one handler ran each time — overstating the "is anyone listening" signal it exists to give. The payload is now described before the handlers run, rather than inside the deferred upload. Handlers receive the same object by reference, so one that normalises a field in place would otherwise have changed the shape being reported. Describing first also means the shape is what was dispatched. Only the upload stays deferred, which is the part that must not block; the walk itself is bounded to 20 key paths and 3 levels. Nothing is described at all when tracking is off, so the checks stay in front of the work. Also `parameters?.vizId` when building the "no answer found" message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Description rewritten to cover the embed-event half properly — event table, the While doing that I found three review comments I had not read. Two were real; addressed in
The deferred walk could describe a mutated payload. Correct, and it was a hazard I had considered and waved off — handlers get the payload by reference and run before the deferred read, so a handler normalising a field in place would change the shape being reported. Fixed by describing the payload synchronously, before the handlers, and deferring only the upload. That is the better split anyway: the walk is bounded (3 levels, 20 key paths) and cheap, while the Mixpanel call is the part that must never sit in front of a host handler. The "does not block" test still passes.
Full suite 1732 passed (47 files), lint 0 errors, harness 34/34. |
SCAL-333657 Both directions can be responded to, and neither response was being captured. A trigger resolves with whatever the embedded app sends back, and an embed event hands the host application a responder — `on(ApiIntercept, responder)` — so the interesting part of an interception is the answer, not the question. Two events now tell the whole story of one exchange: `visual-sdk-host-event`, once per trigger: the host event, its parameter shape, the dispatch route, the outcome, how long it took, whether the app responded, and the shape of what came back. `visual-sdk-embed-event`, once per embed event: the event, its payload shape, how many handlers ran, whether the event could be responded to at all, whether the host application responded, how long that took, and the shape of the response. `canRespond` comes from the presence of a MessagePort, which is what makes a response possible, so only events that can actually be answered are waited on — five seconds, then the exchange is recorded as unanswered. Everything else is reported as soon as the handlers have run. Response shapes go through the same summariser as payloads, so a response reports `responseKeys` and `responseShape` and never a value. `visual-sdk-trigger-<HostEvent>` goes back to exactly what it was before this branch: fired at call time, no properties. The new event carries the data instead, so no existing report changes meaning. That also retires `visual-sdk-host-event-no-response`, whose only signal is now `responded: false` on the story. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Two response-aware events, in
Only events that can actually be answered are waited on. Responses are shape-only too — Five new tests: a responded exchange with the response value asserted absent, an event that is never responded to (fake timers across the five-second window), an event that cannot be responded to, the response shape of a trigger, and the legacy per-event upload being untouched. Full suite 1737 passed (47 files), lint 0 errors, browser harness 39/39 including the real 30s timeout and no customer value in any of 34 host + embed uploads. One judgement call to flag: five seconds is a guess. It is long enough for an intercept handler that does its own |
| @@ -1435,10 +1477,23 @@ export class TsEmbed { | |||
| // payload | |||
| (!callbackObj.options.start && dataStatus === embedEventStatus.END) | |||
| ) { | |||
| invokedHandlers += 1; | |||
| const responder = this.createEmbedEventResponder(eventPort, eventType); | |||
| callbackObj.callback(data, responder); | |||
| callbackObj.callback(data, (payload: any) => { | |||
| reportEmbedEvent({ payload, at: Date.now() }); | |||
| return responder(payload); | |||
| }); | |||
There was a problem hiding this comment.
Correctness: handlerCount can undercount when a handler responds synchronously.
invokedHandlers is read by reportEmbedEvent at the moment it's called, but the count is only final once the whole forEach has finished. If an earlier handler in callbacks calls its responder synchronously (e.g. the documented EmbedEvent.ApiIntercept / OnBeforeGetVizDataIntercept pattern of calling responder(...) directly inside the callback), reportEmbedEvent fires — and sets reported = true — before later handlers in the loop have been invoked. The final reportEmbedEvent() call after the loop then becomes a no-op (reported is already true), so the upload permanently reports handlerCount as of the responding handler's position in the array, not the true number of handlers this dispatch ran.
Concretely: register two handlers for EmbedEvent.ApiIntercept where the first synchronously calls responder(...) and the second does not — handlerCount is reported as 1 even though both handlers were invoked. This is the exact metric the prior fix ("count only the handlers a dispatch ran") was meant to make accurate, so it's worth closing the gap — e.g. read invokedHandlers after the forEach completes (or snapshot it in a microtask/Promise.resolve().then() before reporting) rather than at response time.
…line SCAL-333657 Review catch, and a regression from the commit that made handlerCount accurate in the first place. Reporting from inside the responder meant a handler that answers synchronously — the documented ApiIntercept and OnBeforeGetVizDataIntercept pattern — uploaded the event mid-loop, freezing handlerCount at that handler's position and turning the post-loop report into a no-op. Two intercept handlers where the first answers inline reported 1, not 2. The responder now records the response and reports only once the dispatch loop has finished, so the count is final either way: a synchronous answer is reported immediately after the loop, an asynchronous one when it arrives, and an absent one after the response window. Only the first response is recorded, which matches the port: the first answer is the one the embedded app receives. Regression test verified to fail without the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Good catch, and an uncomfortable one — this was a regression from the commit that made Reporting from inside the responder meant a handler answering synchronously — the documented The responder now records the response and reports only once the dispatch loop has finished. All three paths keep a final count: a synchronous answer reports immediately after the loop, an asynchronous one when it arrives, an absent one after the response window. Only the first response is recorded, which matches the port — the first answer is the one the embedded app actually receives. Regression test verified to fail without the fix: Full suite 1738 passed (47 files), lint 0 errors. Worth noting for whoever reviews this: |
SCAL-333657 Splitting this work up, and this branch keeps only the first slice: which host event is triggered and which parameters it uses. No existing SDK behaviour changes. `visual-sdk-trigger-<HostEvent>` keeps its name, its timing and its call site; it just carries properties now, where before it carried none. One existing file is touched, by ten lines. Everything else is removed from this branch and preserved at bb33dd4 for the follow-up PRs: the outcome and duration of a trigger, the response story for both directions, embed event tracking, the registration-noise work, the custom-handler timeout fix, and the pre-init queue cap. Dropped outright, per review: `isRegisteredBySDK`. The flag was broken, and propagating it meant `{ start: false }, true` at every internal call site to reach a positional parameter past a default. Whether a host application listens to an embed event is better answered by counting the handlers a dispatch actually ran, which needs no flag and belongs with the embed event work anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Split, in No existing SDK code behaviour changes. The whole diff against
Everything else is preserved at Two threads above are now moot because the code they point at is not in this PR: the Full suite 1714 passed (47 files), lint 0 errors. |
SCAL-333657
Far too much code for the question being asked. The recursive walker, depth
and path caps, truncation flag, payload-type union and cyclic-payload handling
all existed to serve nesting that "which parameters are used" does not need.
The payload is now dumped as one flat key-to-type map, built with lodash
mapValues/mapKeys over the top level:
{ vizId: 'string', operator: 'EQ', values: 'array(2)' }
An array payload reads its parameters from the first element, which is what
UpdateRuntimeFilters and UpdateFilters send, so an enum member is still
visible where it matters. No recursion, so nothing to bound and no cyclic
payload to survive.
189 lines to 64, and the spec 238 to 93.
Note on lodash: it has no "type of value" helper, and this repo's own
getTypeFromValue returns answer-column types, not JavaScript ones. So the
leaf check is still typeof plus Array.isArray; lodash contributes the object
mapping and isPlainObject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SCAL-333657 Review catch, and it was real. Verified against this branch before the fix: a payload with a throwing getter made `trigger()` reject with `Error: no telemetry for you`, so a host application lost a working host event to a telemetry side effect. Building the properties and uploading them now happen inside one guarded helper, because either half can throw — `describeParams` reads the payload's own properties, and `uploadMixpanelEvent` calls into mixpanel. A failure is logged at debug level and the trigger carries on. This matters more once the upload moves inside the promise chain: a throw there rejects the chain, so a successful trigger would surface to the host application as a failure and be reported as an error at the same time. Two tests, both verified to fail without the guard: a payload that cannot be described, and an upload that throws. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SCAL-333657 The rule is about values, not key names: send no value unless it is an SDK enum member. Key names are the SDK's own parameter names, so they go as they are and the identifier check comes out. Two lodash calls become one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Key redaction removed ( The whole module is now 71 lines: const paramType = (value, key) => {
if (value === null) return 'null';
if (Array.isArray(value)) return `array(${value.length})`;
if (typeof value === 'string' && ENUM_PARAMS[key]?.includes(value)) return value;
return typeof value;
};
export const describeParams = (payload) => {
const params = Array.isArray(payload) ? payload[0] : payload;
return isPlainObject(params) ? mapValues(params, paramType) : {};
};No value is ever sent unless it is an SDK enum member, which is the one thing this has to get right:
The one thing I kept that is not strictly minimal is the try/catch around building and uploading. It earns its place: I verified on this branch that without it, a payload with a throwing getter makes Full suite 1705 passed (47 files), lint 0 errors. The diff against |
SCAL-333657 `columns: 'array(2)'` said how many but not what. It now reports the element types, so `['Region', 'Revenue']` becomes `['string', 'string']` and a mixed array reads `['object', 'null', 'number']`. Capped at ten elements, because a filter can carry a thousand values and sending a thousand copies of `'string'` to Mixpanel would be waste. A nested array reports as `'array'` rather than expanding, so there is still no recursion. That absence of recursion is what makes a circular payload a non-event: only the top level is read, values are turned into type strings, and the payload itself is never handed to Mixpanel, so nothing can walk a cycle. Two tests pin it — an object holding itself, and an array containing itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Array element types instead of a count ( Capped at ten elements — a filter can carry a thousand values, and a thousand copies of Circular payloadsSafe, and worth saying why: because nothing recurses. Only the top level is read, each value becomes a type string, and the payload itself is never handed to Mixpanel — so there is no cycle for anything to walk, including the Two tests pin it — an object holding itself and an array containing itself — rather than leaving it as a claim. This is a property the earlier recursive version had to buy with depth and path caps; dropping the recursion made it free. A payload that throws while being read is a different matter, and that is what the try/catch covers: verified on this branch that without it, a throwing getter makes Full suite 1708 passed (47 files), lint 0 errors. |
SCAL-333657
The type map now mirrors the payload all the way down instead of stopping at
the top level:
{ points: [{ x: 1 }, null, 7] } -> { points: [{ x: 'number' }, 'null', 'number'] }
{ filter: { column: 'Region',
applicability: { level: 'TAB' } } }
-> { filter: { column: 'string',
applicability: { level: 'TAB' } } }
Recursing means cycles have to be handled rather than being impossible, so a
value already on the current path reports `circular` and is not followed. The
set is per path, not global, so a shared object referenced twice is still
described twice.
`level` goes back into the enum map: `Applicability.level` is a real enum in
filter and parameter payloads, and nesting is what made it reachable.
Arrays stay capped at ten elements. There is no depth limit — cycle detection
is what guarantees termination — so a pathologically deep payload would cost a
large property rather than a hang, and a stack overflow would be swallowed by
the guard around the upload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Nested objects, in Recursing means cycles now have to be handled rather than being impossible, so a value already on the current path reports Writing the nested test caught a gap: Arrays stay capped at ten elements. There is no depth cap: cycle detection is what guarantees termination, so a pathologically deep payload costs a large property rather than a hang, and a stack overflow would be swallowed by the guard around the upload rather than reaching the host application. Say the word if you want a depth cap anyway. Full suite 1709 passed (47 files), lint 0 errors. |
SCAL-333657 `seen` implied every object visited, which is not what it is: entries are removed on the way out, so it only ever holds the objects between the payload and the value being described. Renamed to `ancestors`, with a comment saying why it exists at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SCAL-333657 `JSON.parse(JSON.stringify(payload))` up front, then walk the copy. The copy cannot contain a cycle, so the ancestors set and the `circular` marker are gone, and functions and undefined values are dropped on the way through instead of needing a case each. What it costs, so the trade is on the record. The round trip is the most expensive thing in this path — a full serialise and parse of the payload on every trigger, where the walk alone allocated nothing. And it throws rather than degrading: a circular payload, a throwing getter or a BigInt now yields no parameters at all, where the guarded walk would have described the parts it could reach. The reason is logged at debug level. An empty parameter map therefore has two meanings — a host event with no parameters, and a payload that could not be serialised. Worth a marker property if that ambiguity ever costs anything. Verified against Node rather than assumed: a function value drops its key, a function inside an array becomes null, undefined drops, a Date becomes a string, Map and Set become empty objects, and circular, throwing getters and BigInt all throw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Done in Behaviour verified against Node rather than assumed:
Two things worth having on the record, since this is a trade rather than a free win: It is now the most expensive operation in this path. A full serialise and parse of the payload on every trigger, where the walk alone allocated nothing. For a filter carrying a thousand values that is a large string plus a large object, built and thrown away to read types off it. It throws rather than degrading. A circular payload used to report Neither is a blocker, and functions really are handled now, which the walk did not do — a function value would have reported Full suite 1709 passed (47 files), lint 0 errors. Module is 93 lines. |
…le-safe SCAL-333657 The top level went through its own mapValues while everything below it went through describeValue, which did the same thing. Now the clone is handed straight to describeValue. describeValue has no cycle guard, and that is only safe because describeParams serialises first — a clone cannot hold a cycle. It is not exported, so nothing can reach it with a raw payload, and a comment now says so rather than leaving the next reader to work out why the guard went away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces host event parameter telemetry to safely report which host events are triggered and their parameter structures (types and enum members) without leaking actual customer values. It adds the hostEventTelemetry utility along with comprehensive unit and integration tests. The review feedback highlights potential runtime serialization issues: specifically, JSON.stringify will throw a TypeError when encountering BigInt values (which are supported in runtime filters), and empty or undefined payloads can cause JSON.parse to throw a SyntaxError. Addressing these issues by adding early guards and a custom JSON replacer will ensure robust telemetry reporting.
| const describeValue = (value: unknown, key: string): ParamTypes => { | ||
| if (value === null) { | ||
| return 'null'; | ||
| } | ||
| if (typeof value === 'string' && ENUM_PARAMS[key]?.includes(value)) { | ||
| return value; | ||
| } | ||
| if (Array.isArray(value)) { | ||
| return value.slice(0, MAX_ARRAY_TYPES).map((item) => describeValue(item, key)); | ||
| } | ||
| if (isPlainObject(value)) { | ||
| return mapValues(value as Record<string, unknown>, describeValue); | ||
| } | ||
| return typeof value; | ||
| }; |
There was a problem hiding this comment.
Handle BigInt values and prevent serialization failures
BigInt is a supported type in RuntimeFilter values (as defined in src/types.ts). However, JSON.stringify throws a TypeError when encountering a BigInt value. This causes the entire parameter telemetry for the host event to fail and return {}.
We can handle BigInt values gracefully by using a custom replacer function in JSON.stringify to map bigint values to a placeholder string (e.g., "__bigint__"), and then mapping that placeholder back to "bigint" in describeValue.
const describeValue = (value: unknown, key: string): ParamTypes => {
if (value === null) {
return 'null';
}
if (value === '__bigint__') {
return 'bigint';
}
if (typeof value === 'string' && ENUM_PARAMS[key]?.includes(value)) {
return value;
}
if (Array.isArray(value)) {
return value.slice(0, MAX_ARRAY_TYPES).map((item) => describeValue(item, key));
}
if (isPlainObject(value)) {
return mapValues(value as Record<string, unknown>, describeValue);
}
return typeof value;
};| export const describeParams = (payload: unknown): Record<string, ParamTypes> => { | ||
| let params; | ||
| try { | ||
| /* | ||
| * The round trip drops functions and undefined, and cannot produce a | ||
| * cycle, so the walk below needs no cycle guard. It throws instead on a | ||
| * circular payload, a throwing getter or a BigInt, which is what the | ||
| * catch is for: no parameters are reported, and the reason is logged. | ||
| */ | ||
| params = JSON.parse(JSON.stringify(Array.isArray(payload) ? payload[0] : payload)); | ||
| } catch (e) { | ||
| logger.debug('Could not describe host event payload', e); | ||
| return {}; | ||
| } | ||
| return isPlainObject(params) ? describeValue(params, '') as Record<string, ParamTypes> : {}; | ||
| }; |
There was a problem hiding this comment.
Guard against nullish/empty payloads and support BigInt serialization
- Avoid throwing exceptions for control flow: When
payloadisundefinedor[],JSON.stringify(undefined)returnsundefined, which causesJSON.parseto throw aSyntaxError. This clutters debug logs withCould not describe host event payloaderrors for perfectly normal empty payloads. Adding early guards for nullish/empty payloads avoids this overhead. - Support
BigIntserialization: Use a custom replacer function inJSON.stringifyto serializebigintvalues as"__bigint__"to prevent serialization crashes.
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return '__bigint__';
}
return value;
};
export const describeParams = (payload: unknown): Record<string, ParamTypes> => {
if (payload === undefined || payload === null) {
return {};
}
const target = Array.isArray(payload) ? payload[0] : payload;
if (target === undefined || target === null) {
return {};
}
let params;
try {
params = JSON.parse(JSON.stringify(target, replacer));
} catch (e) {
logger.debug('Could not describe host event payload', e);
return {};
}
return isPlainObject(params) ? describeValue(params, '') as Record<string, ParamTypes> : {};
};|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces host event telemetry tracking via Mixpanel, adding a new utility hostEventTelemetry.ts and corresponding tests. It replaces direct uploadMixpanelEvent calls in TsEmbed with reportHostEvent to capture structured telemetry data while scrubbing customer values. The review feedback identifies two issues in hostEventTelemetry.ts: first, describeValue incorrectly returns 'object' for object payloads instead of the mapped object; second, describeParams does not unwrap array payloads to describe the first element's shape as expected by the tests.
| if (typeof value === 'object') { | ||
| Object.keys(value).forEach(key => { | ||
| if (!PRESERVED_FIELDS.includes(key)) | ||
| (value as any)[key] = describeValue((value as any)[key]) | ||
| }); | ||
| } | ||
|
|
||
| return typeof value; |
There was a problem hiding this comment.
The describeValue function has a bug where it returns the string 'object' for any object payload, instead of returning the object with its property values replaced by their types. This causes the telemetry to report incorrect data for object-based payloads.
The function should return the modified object itself after recursively describing its properties.
if (typeof value === 'object') {
Object.keys(value).forEach(key => {
if (!PRESERVED_FIELDS.includes(key)) {
(value as any)[key] = describeValue((value as any)[key]);
}
});
return value as { [key: string]: ParamTypes };
}
return typeof value;| logger.debug('Could not describe host event payload', e); | ||
| return {}; | ||
| } | ||
| return describeValue(params); |
There was a problem hiding this comment.
The describeParams function doesn't correctly handle payloads that are arrays of objects, such as for the UpdateRuntimeFilters event. The tests indicate that for such payloads, we should describe the shape of the first object in the array, not the array itself. The current implementation describes the entire array, which leads to incorrect telemetry data for these events.
if (Array.isArray(params) && params.length > 0 && typeof params[0] === 'object' && params[0] !== null) {
// For array payloads of objects (like UpdateRuntimeFilters), describe the shape of the first element.
return describeValue(params[0]);
}
return describeValue(params);
SCAL-333657 · epic SCAL-325536 (TSE SWAT: Q1 2027)
Which host event is triggered, and which parameters it uses. Nothing else.
Why
trigger()uploadedvisual-sdk-trigger-<HostEvent>with no properties, so the only answerable question was whether a host event fired at all — across ~90 separate Mixpanel event names. Which parameters customers actually pass was invisible.What changes
One existing line, into ten:
No existing SDK behaviour changes. Same event name, same timing, same call site, no new Mixpanel events, no signature changes, nothing removed. The event simply carries properties where before it carried none, so existing reports keep working and gain fields.
Properties:
hostEvent,embedComponentType,contextType,sdkVersion,hasPayload,payloadType,paramCount,paramKeys,paramShape,shapeTruncated.Payload values never leave the browser
Host event payloads carry customer data — GUIDs, filter values, search strings, column names. A value is reported as its
typeof:name:string, nevername:"Quarterly revenue". Booleans included.SDK enum members are the one exception —
operator:EQis a fixed, low-cardinality token from our own contract, and knowing which operator customers pass is the point. A value is kept only when its key is a known enum parameter and it matches that enum exactly, so a free-form string under the same key still degrades tostring.Key names are reported only when they read as code identifiers, since a payload can be keyed by a customer column name —
"Total Sales"becomesredactedKey.Bounded: depth 3, 40 key paths. Cyclic payloads and throwing getters are handled — a hostile payload can never break the trigger it describes.
Opt-out is inherited, not new:
EmbedConfig.disableSDKTrackingalready stopsinitMixpanel.One known gap, left as a TODO in the code: the enum map is hand-maintained, so a host event that gains an enum parameter nobody adds will silently report
string. A generated map, or reading members off the contract types, would fix it properly.Public API
Nothing exported changes. The new module is internal — not re-exported from
index.ts. No newMIXPANEL_EVENTkeys, no changed signatures, no@versiontags needed.React needs no change:
useEmbedRef().current.trigger(...)calls straight through toTsEmbed.trigger().Testing
src/utils/hostEventTelemetry.spec.ts(19) — type-not-value reporting, enum members by both spellings, enum fallback for a non-member, key redaction, depth and path caps, empty containers and nulls, top-level array payloads, cyclic and throwing-getter payloads, and a direct "never reports a payload value" assertion.src/embed/host-event-telemetry.spec.ts(3) — end-to-end through a renderedLiveboardEmbed: the properties land, the event name is unchanged, and customer values are absent while enum members survive.Full suite 1714 passed (47 files),
tscclean, lint 0 errors,check-sizewithin the 34 kB budget.The rest of the split
Everything below was built and verified on this branch, then removed to keep PR 1 reviewable. It is preserved at
bb33dd47and will come back as its own PR:status(success / error / timed-out / render-not-called / no-iframe),durationMs, and the dispatchroute(custom handler vs UI passthrough vs legacy postMessage).on(ApiIntercept, responder)). Whether it was answered, how long it took, and the shape of the answer.processTriggerresolves rather than rejects with its timeout Error; the same timeout is lost entirely forPin/SaveAnswer/UpdateFilters/DrillDown; and the pre-init Mixpanel queue is unbounded whendisableSDKTrackingis set, so it grows for the lifetime of the page.Separately, and not part of any of these:
VISUAL_SDK_EMBED_CREATEuploads the entireviewConfigto Mixpanel —liveboardId,runtimeFilterswith values,searchOptionswith the search string. That is customer data going out today, on every embed construction. It needs its own ticket.🤖 Generated with Claude Code