Skip to content

SCAL-333657: track which host event is triggered and with which parameters - #635

Open
sastaachar wants to merge 18 commits into
mainfrom
SCAL-333657
Open

SCAL-333657: track which host event is triggered and with which parameters#635
sastaachar wants to merge 18 commits into
mainfrom
SCAL-333657

Conversation

@sastaachar

@sastaachar sastaachar commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

SCAL-333657 · epic SCAL-325536 (TSE SWAT: Q1 2027)

Replaces #634, closed by a branch rename. PR 1 of a split — see the plan at the bottom.

Which host event is triggered, and which parameters it uses. Nothing else.

Why

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 pass was invisible.

What changes

One existing line, into ten:

-uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`);
+uploadMixpanelEvent(
+    `${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`,
+    getHostEventTelemetryProps({ hostEvent: messageType, payload: data, context, embedComponentType }),
+);

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, never name:"Quarterly revenue". Booleans included.

SDK enum members are the one exceptionoperator:EQ is 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 to string.

Key names are reported only when they read as code identifiers, since a payload can be keyed by a customer column name — "Total Sales" becomes redactedKey.

{ runtimeFilters: [{ columnName: 'Region', operator: EQ, values: ['west'] }] }

paramKeys:  ['runtimeFilters']
paramShape: ['runtimeFilters:array(1)',
             'runtimeFilters[]:object(3)',
             'runtimeFilters[].columnName:string',   <- name kept, value never
             'runtimeFilters[].operator:EQ',         <- SDK enum, member kept
             'runtimeFilters[].values:array(1)']

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.disableSDKTracking already stops initMixpanel.

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 new MIXPANEL_EVENT keys, no changed signatures, no @version tags needed.

React needs no change: useEmbedRef().current.trigger(...) calls straight through to TsEmbed.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 rendered LiveboardEmbed: the properties land, the event name is unchanged, and customer values are absent while enum members survive.

Full suite 1714 passed (47 files), tsc clean, lint 0 errors, check-size within 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 bb33dd47 and will come back as its own PR:

  • PR 2 — outcome and duration of a trigger. status (success / error / timed-out / render-not-called / no-iframe), durationMs, and the dispatch route (custom handler vs UI passthrough vs legacy postMessage).
  • PR 3 — the response story, both directions. A trigger resolves with the app's answer; an embed event hands the host a responder (on(ApiIntercept, responder)). Whether it was answered, how long it took, and the shape of the answer.
  • PR 4 — embed event tracking. Which embed events actually arrive, their payload shape, and how many handlers each dispatch ran.
  • PR 5 — bug fixes found along the way, each independently reviewable: a 30s trigger timeout is currently indistinguishable from success because processTrigger resolves rather than rejects with its timeout Error; the same timeout is lost entirely for Pin/SaveAnswer/UpdateFilters/DrillDown; and the pre-init Mixpanel queue is unbounded when disableSDKTracking is set, so it grows for the lifetime of the page.

Separately, and not part of any of these: VISUAL_SDK_EMBED_CREATE uploads the entire viewConfig to Mixpanel — liveboardId, runtimeFilters with values, searchOptions with the search string. That is customer data going out today, on every embed construction. It needs its own ticket.

🤖 Generated with Claude Code

sastaachar and others added 3 commits August 20, 2026 14:12
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}` : ''}.`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
const error = `No answer found${parameters.vizId ? ` for vizId: ${parameters.vizId}` : ''}.`;
const error = 'No answer found' + (parameters?.vizId ? ' for vizId: ' + parameters.vizId : '') + '.';

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Review summary

Reviewed the new host-event / embed-event telemetry pipeline (eventTelemetry.ts, mixpanel-service.ts, ts-embed.ts, host-event-client.ts) plus the accompanying tests. Overall this is well-scoped and defensively written — payload shape reporting is capped in depth and path count, keys are sanitized, values are never serialized (only typeof, with an explicit allowlist for enum members), the pre-init mixpanel queue is now bounded, and the timeout-vs-error signal is threaded consistently between processTrigger's resolve-with-Error convention and the thrown-object convention in handleHostEventWithParam.

One correctness finding, posted inline on src/embed/ts-embed.ts: in executeCallbacks, handlerCount can undercount when an earlier handler in the dispatch loop calls its responder synchronously (the documented pattern for ApiIntercept/OnBeforeGetVizDataIntercept) — the report fires and latches reported = true before later handlers in the same forEach run, so the final count doesn't include them. This directly undermines the stated goal of a prior fix in this stack ("count only the handlers a dispatch ran"), and isn't covered by the existing tests (which only exercise a single handler responding, or multiple handlers where none respond).

No breaking changes to the public API surface — HostEventClient and eventTelemetry.ts aren't re-exported from index.ts, and the new onRoute parameter on triggerHostEvent is optional and appended last. No version-tag or deprecated-terminology lines were touched in this diff.

Style guide

No documentation findings in this diff — no @version tags, deprecated terminology, or malformed doc comments were added or edited in the reviewed files.

@pkg-pr-new

pkg-pr-new Bot commented Aug 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@thoughtspot/visual-embed-sdk@635

commit: e4f27f3

…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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Reshaped per feedback, in c42ddb99. Taking the points in order.

2. Send the extra data on the existing event — done, and it was the better call. The separate aggregate event is gone. visual-sdk-trigger-<HostEvent> keeps its name but now fires once, when the trigger settles, carrying the parameters, outcome and duration. One upload per trigger instead of two.

3. Duration, and a no-response event. Every upload now carries durationMs. When the embedded app never answers, visual-sdk-host-event-no-response goes out alongside the per-event upload — measured at 30001 ms in the browser harness.

4. Embed events tracked. executeCallbacks is the one point every embed event passes through, so a single hook covers all of them. New visual-sdk-embed-event reports the event name, payload shape, the event's own status, and handlerCount — which also answers "is anyone actually listening to this event", useful on its own.

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 Data event carrying { columnNames: ['Region'], rows: [['west', 100]], answerName: 'Quarterly revenue rollup' } reports:

data:object(3)  data.answerName:string  data.columnNames:array(1)
data.columnNames[]:string  data.rows:array(1)  data.rows[]:array(2)

6. Non-blocking, minimal cost. Three things: nothing is walked or built when a host application sets disableSDKTracking — it returns before touching the payload; telemetry is reported after the host's own handlers have run; and the walk itself is deferred to requestIdleCallback (2s timeout, setTimeout fallback). So an embed event on a hot path costs the caller one closure allocation. Two tests pin it: a disabled host uploads nothing at all, and the host handler is provably ordered before the telemetry.

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 string, and nothing fails to tell you. It suggests generating the map or reading the members back off the contract types instead. Left as a TODO rather than pretending it is solved.

Verification

Full suite 1729 passed (47 files), tsc clean, lint 0 errors, check-size 31.71 kB against the 34 kB budget.

Browser harness 37/37, now including embed events driven over the real postMessage path rather than a direct call — so the whole chain (postMessageprocessDataexecuteCallbacks → telemetry) is covered. No customer value in any of the 38 host + embed event uploads in a full run.

One incidental finding: render() itself dispatches an init embed event, so that shows up in the telemetry too. Correct behaviour and useful signal, but worth knowing before reading a report — the first embed event of any session is the SDK's own.

Comment thread src/embed/ts-embed.ts Outdated
embedEvent: eventType,
payload: data,
embedComponentType: this.viewConfig?.embedComponentType,
handlerCount: callbacks.length,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/embed/ts-embed.ts
const responder = this.createEmbedEventResponder(eventPort, eventType);
callbackObj.callback(data, responder);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Registration noise cut, in d3e1ee15. You were right that it is mostly noise, and it turned out to be worse than that — the mechanism meant to suppress it was broken.

Measured, for a full-height Liveboard with two host handlers:

before:  12 uploads   appInit, ThoughtspotAuthExpired, IdleSessionTimeout,
                      EmbedListenerReady, authInit, RefreshAuthToken,
                      ROUTE_CHANGE, EMBED_HEIGHT, EmbedIframeCenter,
                      requestVisibleEmbedCoordinates,   <- 10 SDK internals
                      data, Error                       <- 2 host handlers
after:    2 uploads   data, Error

isRegisteredBySDK exists precisely to mark those internals, and it was not working:

  1. V1Embed.on dropped it. The override declared three parameters and called super.on(eventType, callback, options) — so on any Liveboard, Pinboard or App embed, all six base-class internal registrations arrived marked isRegisteredBySDK: false, indistinguishable from a host registration. That is a pre-existing bug, not something this PR introduced; it just happens to be the thing that defeats the filter.
  2. The full-height handlers never set it — four sites in liveboard.ts / app.ts called this.on(...) with two arguments.

Both fixed, and the on() upload now goes through reportEvent, so it respects disableSDKTracking and stays off the critical path like everything else.

What survives is the signal worth having: which embed events host applications actually subscribe to. Occurrence tracking is untouched — visual-sdk-embed-event still reports every embed event that arrives, which is the "how many events are we getting" number.

One thing to flag on the diff: three existing specs asserted the old two-argument on() call via toHaveBeenCalledWith(EmbedEvent.X, expect.anything()), which is arity-sensitive, so they failed on the extra arguments. Updated to the four-argument form in app.spec.ts, liveboard.spec.ts and pinboard.spec.ts. The behaviour they cover — which handlers a full-height embed registers — is unchanged; only the asserted call shape moved.

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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Description rewritten to cover the embed-event half properly — event table, the typeof-except-enums rule applied to both kinds, the cost model, all four bugs fixed on the way, and the public-API notes.

While doing that I found three review comments I had not read. Two were real; addressed in aea8ca14.

handlerCount was overstating. It used every handler registered for the event type, not the ones the 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 exactly one handler ran each time. That undermines the only thing the field is for. Now counted inside the dispatch loop. Regression test verified to fail without the fix — Expected: 1, Received: 2.

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.

parameters?.vizId applied — belt and braces, since every current caller passes a validated object, but the line is in this diff.

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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Two response-aware events, in d711b2eb. You were right that the response side was missing entirely — a trigger resolves with whatever the app sends back, and on(ApiIntercept, responder) means the interesting part of an interception is the answer, not the question. Neither was captured.

Event Once per Whole story
visual-sdk-host-event trigger host event, parameter shape, route, status, durationMs, responded, response shape
visual-sdk-embed-event embed event embed event, payload shape, handlerCount, canRespond, responded, responseTimeMs, response shape

visual-sdk-trigger-<HostEvent> is back to exactly what it was before this branch — fired at call time, no properties. The new event carries the data instead, so nothing existing changes meaning. That also retires visual-sdk-host-event-no-response: its only signal is now responded: false on the story.

Only events that can actually be answered are waited on. canRespond comes from the presence of the MessagePort — no port means triggerEventOnPort is a no-op, so a response is impossible. Those are reported as soon as the handlers have run. Events that can be answered get a five-second window, then record responded: false. So there is no timer on the vast majority of embed events, which keeps the hot path where it was.

Responses are shape-only tooresponseType, responseKeys, responseShape, same summariser, never a value. A host application answering an intercept with { allow: true, answerName: 'Quarterly revenue' } reports responseKeys: ['allow','answerName'] and the name never leaves the browser; there is a test asserting exactly that.

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 fetch, short enough not to pile up. If you would rather it matched the 30s trigger timeout, or were configurable, say which.

Comment thread src/embed/ts-embed.ts Outdated
Comment on lines +1471 to +1485
@@ -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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Good catch, and an uncomfortable one — this was a regression from the commit that made handlerCount accurate in the first place. Fixed in bb33dd47.

Reporting from inside the responder meant a handler answering synchronously — the documented ApiIntercept / OnBeforeGetVizDataIntercept pattern — uploaded the event mid-loop, freezing the count at that handler's position and turning the post-loop report into a no-op. Two intercept handlers where the first answers inline reported handlerCount: 1, not 2.

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: handlerCount: 1 where 2 is expected.

Full suite 1738 passed (47 files), lint 0 errors.

Worth noting for whoever reviews this: handlerCount has now been wrong twice, in two different ways, and both were caught here rather than by me. If it is not carrying its weight as a metric I would rather drop it than keep patching it — it is the least load-bearing property in the set.

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>
@sastaachar sastaachar changed the title SCAL-333657: report which host event and which params are triggered SCAL-333657: track which host event is triggered and with which parameters Aug 21, 2026
@sastaachar

Copy link
Copy Markdown
Contributor Author

Split, in 84fe0175. This PR is now PR 1: host event parameters only — title and description rewritten to match.

No existing SDK code behaviour changes. The whole diff against main is:

src/embed/ts-embed.ts                   11 +-     one call site, properties added
src/utils/hostEventTelemetry.ts        189 +      new, internal
src/utils/hostEventTelemetry.spec.ts   238 +      new
src/embed/host-event-telemetry.spec.ts  95 +      new

visual-sdk-trigger-<HostEvent> keeps its name, timing and call site — it just carries properties where it carried none.

isRegisteredBySDK is gone. The flag was broken to begin with, and threading it meant writing { start: false }, true at every internal call site just 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 — no flag, and it belongs with the embed event PR anyway. V1Embed.on, liveboard.ts, app.ts and the three unrelated specs are back to untouched.

Everything else is preserved at bb33dd47 and comes back as its own PR — outcome and duration, the response story for both directions, embed event tracking, and the bug fixes. The description lists them.

Two threads above are now moot because the code they point at is not in this PR: the handlerCount comments (embed event work, PR 4) and the parameters?.vizId suggestion (host event client, PR 5). Both will be carried into the right PR rather than dropped.

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>
sastaachar and others added 2 commits August 21, 2026 15:01
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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Key redaction removed (33dc0e89) — the rule is about values, not key names. Keys are the SDK's own parameter names, so they go as they are. Two lodash calls became one, and the identifier regex is gone.

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:

trigger(HostEvent.UpdateRuntimeFilters, [
  { columnName: 'Region', operator: EQ, values: ['west'] }
])
-> params: { columnName: 'string', operator: 'EQ', values: 'array(1)' }

Region and west never leave the browser; EQ does, because it is a token from our own contract. A test asserts exactly that, and a second one asserts a non-member under an enum key degrades to string.

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 trigger() reject with the telemetry error, so a host application loses a working host event to a telemetry side effect. Two tests cover it.

Full suite 1705 passed (47 files), lint 0 errors.

The diff against main is four files: one existing call site (+7/−1) and three new files.

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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Array element types instead of a count (0e16e805), and the circular question answered below.

{ columns: ['Region', 'Revenue'], points: [{ x: 1 }, null, 7], empty: [] }

-> columns: ['string', 'string']
   points:  ['object', 'null', 'number']
   empty:   []

Capped at ten elements — a filter can carry a thousand values, and a thousand copies of 'string' in a Mixpanel property is pure waste. A nested array reports as 'array' rather than expanding, so there is still no recursion anywhere in this code.

Circular payloads

Safe, 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 JSON.stringify inside mixpanel-browser.

const circular = { vizId: 'd0a1' };
circular.self = circular;
circular.loop = [circular];

-> { vizId: 'string', self: 'object', loop: ['object'] }

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 trigger() reject with the telemetry error.

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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Nested objects, in c89a1644. The type map mirrors the payload all the way down now:

{ points: [{ x: 1 }, null, 7] }
-> { points: [{ x: 'number' }, 'null', 'number'] }

{ filter: { column: 'Region', values: ['west'],
            applicability: { level: 'TAB', targetId: 'tab-1' } } }
-> { filter: { column: 'string', values: ['string'],
               applicability: { level: 'TAB', targetId: 'string' } } }

Recursing means cycles now have to be handled rather than being impossible, so a value already on the current path reports circular and is not followed. The visited set is per path rather than global, so a shared object referenced twice is still described twice — only an actual cycle is cut.

const circular = { vizId: 'd0a1' };
circular.self = circular;
circular.loop = [circular];
-> { vizId: 'string', self: 'circular', loop: ['circular'] }

Writing the nested test caught a gap: level had fallen out of the enum map during the earlier simplification, so applicability.level was reporting 'string' instead of 'TAB'. Applicability.level is a real enum in filter and parameter payloads — nesting is simply what made it reachable. Back in, and asserted.

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.

sastaachar and others added 2 commits August 21, 2026 19:32
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>
@sastaachar

Copy link
Copy Markdown
Contributor Author

Done in 497481a0 — the payload is serialised first, then the copy is walked. The ancestors set and the circular marker are gone, and functions and undefined values are dropped on the way through rather than needing a case each.

Behaviour verified against Node rather than assumed:

payload result
{ cb: () => {} } key dropped
[() => {}, 'x'] ['null', 'string']
{ missing: undefined } key dropped
{ when: new Date() } 'string'
{ m: new Map(...) } 'object' (empty)
circular / throwing getter / BigInt throws → no parameters, reason logged

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 { vizId: 'string', self: 'circular' } — the parts it could reach. Now it reports nothing at all. So an empty parameter map has two meanings: a host event with no parameters, or a payload that could not be serialised. A marker property would separate them if that ambiguity ever costs anything; say the word.

Neither is a blocker, and functions really are handled now, which the walk did not do — a function value would have reported 'function' instead of vanishing.

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>
@adityamittal3107

Copy link
Copy Markdown
Contributor

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/utils/hostEventTelemetry.ts Outdated
Comment on lines +29 to +43
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;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
};

Comment thread src/utils/hostEventTelemetry.ts Outdated
Comment on lines +45 to +60
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> : {};
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Guard against nullish/empty payloads and support BigInt serialization

  1. Avoid throwing exceptions for control flow: When payload is undefined or [], JSON.stringify(undefined) returns undefined, which causes JSON.parse to throw a SyntaxError. This clutters debug logs with Could not describe host event payload errors for perfectly normal empty payloads. Adding early guards for nullish/empty payloads avoids this overhead.
  2. Support BigInt serialization: Use a custom replacer function in JSON.stringify to serialize bigint values 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> : {};
};

@sastaachar

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +29 to +36
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);

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.

2 participants