Skip to content

feat(telemetry): report which host event and which params are triggered (SCAL-333657) - #634

Closed
sastaachar wants to merge 3 commits into
mainfrom
feat/host-event-observability
Closed

feat(telemetry): report which host event and which params are triggered (SCAL-333657)#634
sastaachar wants to merge 3 commits into
mainfrom
feat/host-event-observability

Conversation

@sastaachar

@sastaachar sastaachar commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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

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. Invisible today: which parameters of a host event customers actually use, which embed component triggered it, whether the trigger succeeded / failed / timed out, how long it took, and whether UI passthrough or the legacy postMessage channel served it.

What it emits

Two uploads per trigger:

Event When Why
visual-sdk-trigger-<HostEvent> at call time unchanged name so existing dashboards keep working; now carries the property bag
visual-sdk-host-event (new) once, when the trigger settles or bails out hostEvent is a property, so one report ranks host events and their parameters instead of needing one report per event name

Properties: hostEvent, embedComponentType, contextType, sdkVersion, hasPayload, payloadType, paramCount, paramKeys, paramShape, shapeTruncated, status, durationMs, route, errorCode.

Payload values never leave the browser

Host event payloads carry customer data — GUIDs, filter values, search strings, column names. So 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 of the exercise. A value is treated as an enum member only when its key is a known enum-valued parameter and the value matches that enum exactly, so a free-form string under the same key still degrades to string. Mapped today: operator / operRuntimeFilterOp, levelApplicabilityLevel.

Two further guards: key names are reported only when they read as code identifiers (a payload can be keyed by a customer column name, so "Total Sales" becomes redactedKey), and error messages are never uploaded — only status plus our own EmbedErrorCodes.

Opt-out is inherited, not new: EmbedConfig.disableSDKTracking already stops initMixpanel.

{ runtimeFilters: [{ columnName: 'Region', operator: RuntimeFilterOp.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)']

Two fixes that fall out of this

  1. Timeouts were invisible. processTrigger resolves — it does not reject — with Error(TRIGGER_TIMED_OUT) after 30 s, so a host event the embedded app never answered looked identical to a success. Now reported as status: 'timed-out'.
  2. The pre-init Mixpanel queue was unbounded. uploadMixpanelEvent queues until initMixpanel runs, and with disableSDKTracking: true initMixpanel is never called — so the queue grew for the lifetime of the page. Capped at MAX_QUEUED_EVENTS = 100; flush semantics unchanged.

Public API

MIXPANEL_EVENT is re-exported from index.ts, so the new VISUAL_SDK_HOST_EVENT key is an additive public change. The enum is @hidden, so it is not in the published docs and needs no @version tag. HostEventClient is not exported publicly, so its new optional onRoute argument is internal only. No existing name, value or default changes.

React needs no change: useEmbedRef().current.trigger(...) calls straight through to TsEmbed.trigger(), so React consumers are covered by the same path.

Not in this PR — please read

VISUAL_SDK_EMBED_CREATE (src/embed/ts-embed.ts) does uploadMixpanelEvent(..., { ...viewConfig }) — it spreads the entire viewConfig into Mixpanel, including liveboardId, runtimeFilters with values and searchOptions with the search string. That is customer data reaching Mixpanel today, on every embed construction. Deliberately left alone here to keep this PR reviewable; it needs its own ticket, and the shape-not-values helper added here is what fixes it.

Testing

  • src/utils/hostEventTelemetry.spec.ts (19) — type-not-value reporting, enum members by both spellings, enum fallback when the value is not a member, key redaction, depth and path caps, cyclic and throwing-getter payloads, and a direct "never reports a payload value" assertion.
  • src/embed/host-event-telemetry.spec.ts (5) — end-to-end through a rendered LiveboardEmbed: success plus route, parameter names and enum members with customer values asserted absent, timed-out, error without its message, trigger-before-render.
  • src/mixpanel-service.spec.ts — queue cap.

Full suite 47 files / 1717 passed, tsc clean, lint 0 errors and no new warnings, check-size 31.71 kB against the 34 kB budget.

Open question for review

The property names become a Mixpanel contract the moment reports are saved against them — a rename afterwards invalidates those reports. Utsav — worth a look at the property list above before this merges.

🤖 Generated with Claude Code

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>
@sastaachar
sastaachar requested a review from a team as a code owner August 20, 2026 11:32

@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, allowing the SDK to report which host events are triggered, their parameter shapes (with customer data redacted), and how they resolve. Key changes include adding a new visual-sdk-host-event Mixpanel event, implementing payload shape-walking utilities with safety limits, capping the pre-initialization Mixpanel event queue to prevent memory leaks, and adding extensive unit tests. The review feedback highlights two areas for improvement: updating a comment in the test file to use the current "liveboards" terminology instead of "dashboards" to comply with the style guide, and correcting a JSDoc comment in hostEventTelemetry.ts that incorrectly states boolean values are reported as-is when they are actually reported by their type.

Comment thread src/embed/host-event-telemetry.spec.ts Outdated

await embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' });

// The per-event upload is kept for the existing dashboards, and now

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

❌ FOUND: "dashboards" in line 65 comment.

FLAG FOR UPDATE: Replace "dashboards" with "liveboards" to adhere to the current ThoughtSpot terminology.

Reference: Repository Style Guide line 146.

Suggested change
// The per-event upload is kept for the existing dashboards, and now
// The per-event upload is kept for the existing liveboards, and now
References
  1. Replace 'Dashboard' or 'Pinboard' with 'Liveboard' as per the deprecated terminology mapping. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not applied — the term here refers to Mixpanel dashboards, not ThoughtSpot Liveboards, so the substitution would have made the comment false.

Reworded instead, in 64dcca43, to avoid the ambiguous term without asserting something untrue:

// The per-event upload keeps its name so existing Mixpanel reports
// still work, and now carries the same properties.

Comment thread src/utils/hostEventTelemetry.ts Outdated
Comment on lines +114 to +116
* `runtimeFilters[].columnName:string`, `start:true`. Boolean values are
* reported as-is because the value is the usage signal and carries no
* customer data; every other value is reduced to its type.

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

There is a discrepancy between the JSDoc description and the actual implementation/test suite. The JSDoc states that boolean values are reported as-is (e.g., start:true), but both the implementation in describeLeaf and the test suite (reports a boolean by its type, not its value) reduce booleans to their type ('boolean').

Please update the JSDoc to align with the actual behavior.

Suggested change
* `runtimeFilters[].columnName:string`, `start:true`. Boolean values are
* reported as-is because the value is the usage signal and carries no
* customer data; every other value is reduced to its type.
* 'runtimeFilters[].columnName:string', 'start:boolean'. Boolean values are
* reported by their type; every other value is also reduced to its type.

@pkg-pr-new

pkg-pr-new Bot commented Aug 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 64dcca4

Comment thread src/embed/ts-embed.ts
Comment on lines +1743 to +1759
return this.hostEventClient
.triggerHostEvent(messageType, data, context, (dispatchRoute) => {
route = dispatchRoute;
})
.then((response) => {
// processTrigger resolves — it does not reject — with an Error
// when the embedded app never answers, so a timed-out trigger
// is otherwise invisible.
const settled = response as unknown;
reportHostEvent(
settled instanceof Error
&& settled.message === ERROR_MESSAGE.TRIGGER_TIMED_OUT
? 'timed-out'
: 'success',
);
return response;
})

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: for host events dispatched through the custom-handler branch — Pin, SaveAnswer, UpdateFilters, DrillDown — a real timeout is not reported as status: 'timed-out' here; it comes through as a generic status: 'error' with no errorCode.

Trace: processTrigger resolves (does not reject) with new Error(ERROR_MESSAGE.TRIGGER_TIMED_OUT) on timeout (src/utils/processTrigger.ts:77). For these four events, triggerHostEvent dispatches through customHandler, which for all of them eventually calls handleHostEventWithParam (host-event-client.ts:90-102):

const response = (await this.triggerUIPassthroughApi(apiName, parameters, context))?.find?.(...)
if (!response) { throw { error: `No answer found...` }; }

An Error instance has no .find, so response becomes undefined and the function throws a plain { error } object instead of letting the resolved Error propagate. That plain object reaches this file's .catch() with isValidationError undefined, so it falls into the else branch at line 1784 and reports status: 'error' — the settled instanceof Error check in the .then() above never runs because the promise rejected instead of resolving.

Net effect: a real 30s timeout on Pin/SaveAnswer/UpdateFilters/DrillDown is indistinguishable in the new telemetry from any other error, which undercounts exactly the signal ('timed-out') this PR was built to surface for those four events.

Comment thread src/embed/ts-embed.ts Outdated
embedComponentType: this.viewConfig?.embedComponentType,
});
const triggerStartedAt = Date.now();
let route: HostEventRoute;

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.

Minor type-accuracy nit: route is typed as HostEventRoute (never undefined), but it stays unset whenever reportHostEvent fires before hostEventClient.triggerHostEvent is ever called — the render-not-called (line 1711), host-event-undefined (line 1722), and no-iframe (line 1738) branches. It works today only because ...(route ? { route } : {}) treats the uninitialized value as falsy; the declared type doesn't reflect that possibility. Consider let route: HostEventRoute | undefined; so the type matches actual runtime states.

}),
);
});
});

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.

Missing test coverage: every test here drives HostEvent.DownloadAsCsv, which always resolves to route: 'legacy'. Nothing in this file (or hostEventTelemetry.spec.ts) exercises the 'custom-handler' or 'ui-passthrough' values of HostEventRoute, even though onRoute in host-event-client.ts is new code introduced by this PR with three distinct branches. Worth a case that triggers e.g. HostEvent.Pin or a getter event and asserts route on the reported props.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review verdict: no blocking issues found

Reviewed the host-event telemetry addition (hostEventTelemetry.ts, the HostEventClient.triggerHostEvent route callback, TsEmbed.trigger's reportHostEvent, and the mixpanel-service queue cap).

Traced the main risk areas closely and didn't find a reproducible bug:

  • The isTriggerTimeout sentinel flows correctly through both the legacy path (processTrigger resolves with an Error) and the UI-passthrough path (thrown { error, isTimeout: true }), and ts-embed.ts's .then/.catch each report exactly once, never both.
  • onRoute?.(...) is invoked synchronously inside triggerHostEvent before its returned promise settles, so the outer route variable is always set before reportHostEvent reads it — no race.
  • describeHostEventPayload's depth/path caps correctly bound cyclic payloads (verified by hand-tracing the cyclic.self = cyclic case), and the throwing-getter path is caught and degrades to payloadType: 'unknown'.
  • The new MIXPANEL_EVENT.VISUAL_SDK_HOST_EVENT value and MAX_QUEUED_EVENTS are additive; nothing existing changes shape. triggerHostEvent's new onRoute param is optional, so the one other caller (in the spec file) is unaffected.
  • No enum string values, deprecated terminology, or @version tags were touched.

One thing worth a maintainer's own judgment call, not raised as a finding since it doesn't reproduce today: describeHostEventPayload's EMPTY_SHAPE constant is spread ({ ...EMPTY_SHAPE }) rather than deep-cloned, so every "no payload" result shares the same paramKeys/paramShape array references. Nothing in the current codebase mutates those arrays after the fact, but it's a latent footgun if a future caller ever does.

Test coverage is thorough — hostEventTelemetry.spec.ts and host-event-telemetry.spec.ts exercise the redaction, truncation, enum-passthrough, and timeout/error/route-classification behavior in good detail.

Style guide

No documentation findings — no @version/@deprecated tag issues, no deprecated terminology, no brand-casing or grammar issues, no example/quote/indentation problems in the new or edited doc comments.

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

Copy link
Copy Markdown
Contributor Author

Verified in a real browser, not just jest — 32/32, twice

Built a credential-free harness for this: the SDK is bundled from source, mixpanel-browser is aliased to a recording stub, and a local server plays both the cluster (the four endpoints init/render touch) and the embedded app — a genuine postMessage/MessagePort responder with five behaviour modes. No cluster, no credentials, so it is reproducible by anyone.

What it confirms live, beyond what the unit tests pin:

Properties land correctly embedComponentType: LiveboardEmbed, contextType, sdkVersion: 1.51.0, durationMs, paramCount, hasPayload
typeof, not the value vizId:string for a GUID; runRuntimeFilters:boolean for a true
Enum members survive payload[].operator:EQ
…and only real members operator: 'Total Sales'payload[].operator:string
Customer-shaped keys redacted { 'Total Sales': 1 }paramKeys: ["redactedKey","vizId"]
All four routes legacy, ui-passthrough, custom-handler, and the legacy fallback when the app does not advertise the passthrough key
The 30s timeout is now visible status: 'timed-out', durationMs: 30005 — previously indistinguishable from success
Error path status: 'error', and the app's message ("Answer 4c8a1b2e-0000 not found for Region west") is absent from the upload

And the assertion that matters most: no customer value appears in any of the 26 host event uploads across a full run, scanned for Region, west, Quarterly revenue rollup, Total Sales, 4c8a1b2e-0000, lb-guid-1, secret-token-abc.

It also confirmed the pre-existing leak empirically

The same run observes visual-sdk-embed-create uploading liveboardId, frameParams, embedComponentType — so the Liveboard GUID 4c8a1b2e-0000 does reach Mixpanel today, on every embed construction. Untouched by this PR, reported by the harness as a NOTE line rather than folded into the pass/fail count so a regression in it cannot hide behind a green run. Still needs its own ticket.

One doc fix pushed as a result (73a1139b)

The harness 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 behaviour is correct — route is the dispatch branch, by design — but the comments documented that caveat for custom handlers only. Now both.

Comment on lines +114 to +119
/**
* Key paths annotated with value type — `runtimeFilters:array(3)`,
* `runtimeFilters[].columnName:string`, `start:true`. Boolean values are
* reported as-is because the value is the usage signal and carries no
* customer data; every other value is reduced to its type.
*/

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.

The doc comment contradicts the implementation and the test suite. describeLeaf reduces every non-null, non-enum leaf value (including booleans) to typeof value, so a boolean is reported as isPublic:boolean, never as start:true. This is directly asserted by the test 'reports a boolean by its type, not its value' in src/utils/hostEventTelemetry.spec.ts, which expects ['isPublic:boolean', 'runRuntimeFilters:boolean']. As written, this comment tells a future reader raw boolean values are exposed, which is the opposite of what happens and of the privacy invariant the rest of the file is built around.

Suggested change
/**
* Key paths annotated with value type `runtimeFilters:array(3)`,
* `runtimeFilters[].columnName:string`, `start:true`. Boolean values are
* reported as-is because the value is the usage signal and carries no
* customer data; every other value is reduced to its type.
*/
/**
* Key paths annotated with value type `runtimeFilters:array(3)`,
* `runtimeFilters[].columnName:string`, `isPublic:boolean`. Every value
* is reduced to its type, except an SDK enum member (see the module
* comment), so no customer value ever appears here.
*/

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

Copy link
Copy Markdown
Contributor Author

Review addressed in 64dcca43. The correctness finding was real and worth the catch — thanks.

1. Timed-out custom-handler setters reported as generic errors — fixed. The trace was exactly right: processTrigger resolves 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, and never reaching the settled instanceof Error check because the promise had rejected rather than resolved. So Pin, SaveAnswer, UpdateFilters and DrillDown undercounted the one signal this PR exists to surface.

Fixed without moving anything a host application catches today: the thrown shape is unchanged, an isTimeout flag rides alongside it, and trigger() reports 'timed-out'. The timeout predicate now lives once in processTrigger as isTriggerTimeout, shared by both call sites instead of being open-coded in ts-embed.

Pinned by a regression test, and I confirmed it fails without the fix — Expected: "timed-out", Received: "error".

2. Missing route coverage — added. Four tests: 'ui-passthrough' for a getter the app supports, 'legacy' when the app does not advertise the key, 'custom-handler' for a setter, and the timeout regression above. (The browser harness already covered all four routes, but that harness is not in CI, so the gap in jest was a fair call.)

3. route type — fixed to HostEventRoute | undefined, matching the three branches that report before dispatch happens.

4. paramShape doc comment — fixed. Correctly flagged by both reviewers: it was stale from before the typeof-not-value rule landed and claimed booleans are reported as-is, which is the opposite of what the code and its test do. Exactly the kind of comment that misleads a future reader about the privacy invariant.

5. On the "dashboards" → "liveboards" suggestion — not applied, deliberately. That comment is about Mixpanel dashboards, not ThoughtSpot Liveboards, so the substitution would have made it false. Reworded to "existing Mixpanel reports" instead, which removes the ambiguous term without asserting something untrue.

Re-verified after the changes: full suite 1721 passed (47 files), and the browser harness still 32/32 including the real 30s timeout.

@sastaachar

Copy link
Copy Markdown
Contributor Author

Closed by a branch rename to match the SCAL-xxxx convention, not by abandonment — continued in #635 with the same three commits (ecb5f181, 73a1139b, 64dcca43).

The review round here is already addressed in 64dcca43; the threads above are still the best record of it, in particular the timeout-reporting bug on the custom-handler path.

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.

1 participant