SCAL-333657: report how a host event resolved and how long it took (PR 2) - #636
SCAL-333657: report how a host event resolved and how long it took (PR 2)#636sastaachar wants to merge 9 commits into
Conversation
…ng it took SCAL-333657 PR 2, on top of the parameter tracking in PR 1. Adds `status` and `durationMs` to the host event upload. Status covers every exit: `success`, `error`, `timed-out`, and the three that never reach the embedded app at all — `render-not-called`, `host-event-undefined`, `no-iframe`. `timed-out` needs saying out loud: processTrigger resolves, rather than rejects, with an Error when the app never answers, so without a check for that sentinel a 30-second timeout reports as a success. `isTriggerTimeout` in processTrigger is a new export and changes no behaviour. An error's message is never uploaded — only the status — because it can echo customer data back. One behaviour change worth calling out on review: the upload moves from call time to settle time, because the outcome and the duration do not exist until the trigger finishes. Same event name, same properties as PR 1, later by the round trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request enhances host event telemetry by tracking the status (such as success, error, timeout, and validation failures) and duration of host event triggers, supported by new unit tests. The review feedback suggests wrapping the telemetry upload in a try-catch block to prevent telemetry failures from disrupting the main promise chain, and notes a design improvement opportunity regarding promise rejection on timeouts for future versions.
| const reportHostEvent = (status: HostEventStatus) => { | ||
| uploadMixpanelEvent( | ||
| `${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`, | ||
| getHostEventTelemetryProps({ | ||
| hostEvent: messageType, | ||
| payload: data, | ||
| context, | ||
| embedComponentType: this.viewConfig?.embedComponentType, | ||
| status, | ||
| durationMs: Date.now() - triggerStartedAt, | ||
| }), | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Wrapping the telemetry upload in a try-catch block is highly recommended here. Since reportHostEvent is executed inside the .then handler of triggerHostEvent, any unexpected synchronous error thrown during telemetry generation (e.g., if describeParams or uploadMixpanelEvent fails) will cause the entire promise chain to reject. This would incorrectly report a successful host event trigger as a failure to the host application, disrupting core functionality due to a non-critical telemetry side-effect.
const reportHostEvent = (status: HostEventStatus) => {
try {
uploadMixpanelEvent(
MIXPANEL_EVENT.VISUAL_SDK_TRIGGER + '-' + messageType,
getHostEventTelemetryProps({
hostEvent: messageType,
payload: data,
context,
embedComponentType: this.viewConfig?.embedComponentType,
status,
durationMs: Date.now() - triggerStartedAt,
}),
);
} catch (e) {
logger.debug('Failed to upload host event telemetry', e);
}
};| export const isTriggerTimeout = (value: unknown): boolean => value instanceof Error | ||
| && value.message === ERROR_MESSAGE.TRIGGER_TIMED_OUT; |
There was a problem hiding this comment.
Resolving a promise with an Error object on timeout instead of rejecting it is generally considered an anti-pattern in Promise design. It forces callers to perform manual type checks (like instanceof Error or using isTriggerTimeout) and can easily lead to unhandled runtime exceptions (e.g., TypeError when destructuring properties like session or currentContext from the resolved value). While this behavior might need to be preserved for backward compatibility in processTrigger, consider rejecting the promise on timeout in the next major version of the SDK to align with standard asynchronous error-handling practices.
…outcome SCAL-333657 PR 2, on top of the parameter tracking in PR 1. A trigger now gets an id and its parameters go into a pending queue at call time. When the trigger settles, the outcome joins the entry and the whole thing is pushed as one upload carrying `hostEventId`, `status` and `durationMs`. If nothing ever settles, a 35-second timer pushes the entry as it stands with `status: 'no-outcome'`, so a trigger is recorded either way and the queue cannot grow. Status covers every exit: `success`, `error`, `timed-out`, and the three that never reach the embedded app — `render-not-called`, `host-event-undefined`, `no-iframe`. `timed-out` needs saying out loud: processTrigger resolves, rather than rejects, with an Error when the app never answers, so without a check for that sentinel a 30-second timeout reports as a success. `isTriggerTimeout` is a new export and changes no behaviour. The upload itself is deferred to a microtask, so a Mixpanel call never sits on the caller's synchronous path. Building the properties stays synchronous, on purpose: the payload belongs to the caller, and describing it later would report whatever it had been mutated into. An error's message is never uploaded — only the status — because it can echo customer data back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onto it SCAL-333657 The outcome was reported from inside the promise the caller gets back, so a throw in telemetry rejected that promise: a working host event surfaced to the host application as a failure, and was reported as an error at the same time. The promise is now observed on the side. The trigger's own chain is exactly what it is on main, telemetry attaches a second handler to it, and the original is returned. Nothing telemetry does can change what the host application receives. The observer chain needs its own terminal catch, which is not obvious until you try it: a throwing observer produces an unhandled rejection on the derived promise, and the test for this killed the Node process before that catch went in. It is a debug log now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Applied in const triggered = this.hostEventClient.triggerHostEvent(messageType, data, context)
.catch(/* unchanged from main */);
// Observed, not chained.
triggered.then(
(response) => reportHostEventOutcome(id, isTriggerTimeout(response) ? 'timed-out' : 'success'),
() => reportHostEventOutcome(id, 'error'),
).catch((e) => logger.debug('Could not report host event outcome', e));
return triggered;The trigger's own chain is now exactly what it is on One non-obvious part, which a test caught rather than review: the observer needs its own terminal So the pattern is right, but incomplete without the trailing catch. That test now passes and pins it. Full suite 1716 passed (47 files), lint 0 errors. |
SCAL-333657 · epic SCAL-325536
Did the host event work, and how long did it take.
PR 1 answers which host event, with which parameters. It says nothing about whether the trigger succeeded, so a host event that times out for 30 seconds looks exactly like one that works.
What this adds
Two properties on the same upload:
statussuccess·error·timed-out·render-not-called·host-event-undefined·no-iframedurationMsThe three non-dispatch statuses matter on their own:
render-not-calledandno-iframeare integration mistakes in the host application that are currently invisible, andno-iframein particular happens after an auth failure.timed-outneeds saying out loudprocessTriggerresolves — it does not reject — withError(TRIGGER_TIMED_OUT)after 30 seconds. So without a check for that sentinel, a trigger the embedded app never answered reports as a success. That is the single most misleading thing telemetry could say here, which is why the check is in this PR rather than a later one.isTriggerTimeoutis a new export inprocessTrigger; it changes no behaviour.An error's message is never uploaded — only the status — because it can echo customer data back. There is a test asserting a GUID from an error message does not appear in the properties.
One behaviour change, on purpose
The upload moves from call time to settle time, because neither the outcome nor the duration exists until the trigger finishes. Same event name, same properties as PR 1, just later by the round trip.
That is the trade this PR asks for: outcome data in exchange for a delayed event. If you would rather keep the call-time event and add a second one at settle, say so — it is a small change, but it doubles the upload count per trigger.
Testing
Four new cases in
src/embed/host-event-telemetry.spec.ts: a successful trigger carryingstatusanddurationMs, a trigger the app never answered reported astimed-out, a failed trigger with its error message asserted absent, and a trigger beforerender().Full suite 1707 passed (47 files),
tscclean, lint 0 errors.Still to come
on(ApiIntercept, responder).Pin/SaveAnswer/UpdateFilters/DrillDown, where a custom handler turns it into a generic error; and the pre-init Mixpanel queue is unbounded whendisableSDKTrackingis set.🤖 Generated with Claude Code