diff --git a/src/embed/host-event-telemetry.spec.ts b/src/embed/host-event-telemetry.spec.ts new file mode 100644 index 000000000..0b3ec534d --- /dev/null +++ b/src/embed/host-event-telemetry.spec.ts @@ -0,0 +1,243 @@ +import { + init, AuthType, LiveboardEmbed, HostEvent, EmbedErrorCodes, RuntimeFilterOp, +} from '../index'; +import { getDocumentBody, getRootEl } from '../test/test-utils'; +import { ERROR_MESSAGE } from '../errors'; +import { UIPassthroughEvent } from './hostEventClient/contracts'; +import { logger } from '../utils/logger'; +import * as authInstance from '../auth'; +import * as mixpanelInstance from '../mixpanel-service'; +import { MIXPANEL_EVENT } from '../mixpanel-service'; +import * as processTriggerInstance from '../utils/processTrigger'; + +/** + * Returns the properties of the single `visual-sdk-host-event` upload. + * @param mock The spy on `uploadMixpanelEvent` + */ +const getHostEventProps = (mock: jest.SpyInstance) => { + const calls = mock.mock.calls.filter( + ([eventId]) => eventId === MIXPANEL_EVENT.VISUAL_SDK_HOST_EVENT, + ); + expect(calls).toHaveLength(1); + return calls[0][1] as Record; +}; + +/** + * Renders a Liveboard embed, so that `trigger` runs its normal path. + */ +const renderLiveboard = async () => { + init({ + thoughtSpotHost: 'https://tshost', + authType: AuthType.None, + }); + const embed = new LiveboardEmbed(getRootEl(), { + frameParams: { width: '100%', height: '100%' }, + liveboardId: '4c8a1b2e-0000-0000-0000-000000000001', + }); + await embed.render(); + return embed; +}; + +describe('Host event telemetry', () => { + let mockUploadMixpanelEvent: jest.SpyInstance; + let mockProcessTrigger: jest.SpyInstance; + + beforeEach(() => { + document.body.innerHTML = getDocumentBody(); + jest.spyOn(authInstance, 'postLoginService').mockImplementation( + () => Promise.resolve(true as any), + ); + mockUploadMixpanelEvent = jest.spyOn(mixpanelInstance, 'uploadMixpanelEvent'); + mockProcessTrigger = jest + .spyOn(processTriggerInstance, 'processTrigger') + .mockResolvedValue({ session: 'ok' }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('reports the host event, its parameters and a successful outcome', async () => { + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' }); + + // The per-event upload keeps its name so existing Mixpanel reports + // still work, and now carries the same properties. + expect(mockUploadMixpanelEvent).toHaveBeenCalledWith( + `${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${HostEvent.DownloadAsCsv}`, + expect.objectContaining({ + hostEvent: HostEvent.DownloadAsCsv, + paramKeys: ['vizId'], + }), + ); + + expect(getHostEventProps(mockUploadMixpanelEvent)).toEqual( + expect.objectContaining({ + hostEvent: HostEvent.DownloadAsCsv, + embedComponentType: 'LiveboardEmbed', + contextType: 'none', + hasPayload: true, + paramCount: 1, + paramKeys: ['vizId'], + paramShape: ['vizId:string'], + status: 'success', + route: 'legacy', + durationMs: expect.any(Number), + }), + ); + }); + + test('reports parameter names and enum members, never customer values', async () => { + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await embed.trigger(HostEvent.UpdateRuntimeFilters, [ + { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['west'] }, + ]); + + const serialized = JSON.stringify(mockUploadMixpanelEvent.mock.calls); + ['Region', 'west'].forEach((value) => expect(serialized).not.toContain(value)); + + const props = getHostEventProps(mockUploadMixpanelEvent); + expect(props.paramKeys).toEqual(['columnName', 'operator', 'values']); + expect(props.paramShape).toEqual( + expect.arrayContaining([ + 'payload[].columnName:string', + 'payload[].operator:EQ', + 'payload[].values:array(1)', + ]), + ); + }); + + test('reports a trigger that the embedded app never answered', async () => { + // processTrigger resolves, rather than rejects, when it times out. + mockProcessTrigger.mockResolvedValue(new Error(ERROR_MESSAGE.TRIGGER_TIMED_OUT)); + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' }); + + expect(getHostEventProps(mockUploadMixpanelEvent).status).toBe('timed-out'); + }); + + test('reports a failed trigger without its error message', async () => { + mockProcessTrigger.mockRejectedValue(new Error('Answer 4c8a1b2e not found')); + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await expect(embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' })).rejects.toThrow(); + + const props = getHostEventProps(mockUploadMixpanelEvent); + expect(props.status).toBe('error'); + expect(JSON.stringify(props)).not.toContain('4c8a1b2e'); + }); + + /** + * Makes the embedded app answer UI passthrough calls, advertising the given + * passthrough keys. Everything else resolves over the legacy channel. + * @param keys The passthrough keys the app claims to support + * @param passthroughResult What a passthrough call other than the key + * lookup resolves with + */ + const mockPassthroughApp = (keys: string[], passthroughResult: any = [{ value: { ok: true } }]) => { + mockProcessTrigger.mockImplementation( + (_iFrame: any, messageType: any, _host: any, data: any) => { + if (messageType !== HostEvent.UIPassthrough) { + return Promise.resolve({ session: 'ok' }); + } + if (data?.type === UIPassthroughEvent.GetAvailableUIPassthroughs) { + return Promise.resolve([{ value: { keys } }]); + } + return Promise.resolve(passthroughResult); + }, + ); + }; + + test('reports the ui-passthrough route for a getter the app supports', async () => { + mockPassthroughApp([UIPassthroughEvent.GetTabs]); + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await embed.trigger(HostEvent.GetTabs, {}); + + expect(getHostEventProps(mockUploadMixpanelEvent)).toEqual( + expect.objectContaining({ hostEvent: HostEvent.GetTabs, route: 'ui-passthrough' }), + ); + }); + + test('reports the legacy route when the app lacks the passthrough key', async () => { + mockPassthroughApp(['someUnrelatedPassthrough']); + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await embed.trigger(HostEvent.GetTabs, {}); + + expect(getHostEventProps(mockUploadMixpanelEvent)).toEqual( + expect.objectContaining({ route: 'legacy' }), + ); + }); + + test('reports the custom-handler route for a setter with custom logic', async () => { + mockPassthroughApp([UIPassthroughEvent.PinAnswerToLiveboard]); + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await embed.trigger(HostEvent.Pin, { + newVizName: 'Quarterly revenue', + liveboardId: '4c8a1b2e-0000-0000-0000-000000000002', + }); + + expect(getHostEventProps(mockUploadMixpanelEvent)).toEqual( + expect.objectContaining({ + route: 'custom-handler', + paramKeys: ['liveboardId', 'newVizName'], + }), + ); + }); + + test('reports a custom-handler trigger that the app never answered as timed out', async () => { + // A UI passthrough setter turns the resolved timeout + // Error into a thrown "no answer", which used to be + // reported as a plain error and hid the timeout for + // Pin, SaveAnswer, UpdateFilters and DrillDown. + mockPassthroughApp( + [UIPassthroughEvent.PinAnswerToLiveboard], + new Error(ERROR_MESSAGE.TRIGGER_TIMED_OUT), + ); + const embed = await renderLiveboard(); + mockUploadMixpanelEvent.mockClear(); + + await expect( + embed.trigger(HostEvent.Pin, { + newVizName: 'Quarterly revenue', + liveboardId: '4c8a1b2e-0000-0000-0000-000000000002', + }), + ).rejects.toBeDefined(); + + expect(getHostEventProps(mockUploadMixpanelEvent).status).toBe('timed-out'); + }); + + test('reports a trigger called before render', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => undefined); + init({ + thoughtSpotHost: 'https://tshost', + authType: AuthType.None, + }); + const embed = new LiveboardEmbed(getRootEl(), { + frameParams: { width: '100%', height: '100%' }, + liveboardId: '4c8a1b2e-0000-0000-0000-000000000001', + }); + mockUploadMixpanelEvent.mockClear(); + + await embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' }); + + expect(getHostEventProps(mockUploadMixpanelEvent)).toEqual( + expect.objectContaining({ + status: 'render-not-called', + errorCode: EmbedErrorCodes.RENDER_NOT_CALLED, + }), + ); + }); +}); diff --git a/src/embed/hostEventClient/host-event-client.ts b/src/embed/hostEventClient/host-event-client.ts index 46e0f8cb0..9898d9565 100644 --- a/src/embed/hostEventClient/host-event-client.ts +++ b/src/embed/hostEventClient/host-event-client.ts @@ -1,5 +1,9 @@ import { ContextType, HostEvent } from '../../types'; -import { processTrigger as processTriggerService } from '../../utils/processTrigger'; +import { HostEventRoute } from '../../utils/hostEventTelemetry'; +import { + isTriggerTimeout, + processTrigger as processTriggerService, +} from '../../utils/processTrigger'; import { getEmbedConfig } from '../embedConfig'; import { isValidUpdateFiltersPayload, @@ -92,12 +96,16 @@ export class HostEventClient { parameters: UIPassthroughRequest, context?: ContextType, ): Promise> { - const response = (await this.triggerUIPassthroughApi(apiName, parameters, context)) - ?.find?.((r) => r.error || r.value); + const raw = await this.triggerUIPassthroughApi(apiName, parameters, context); + const response = raw?.find?.((r) => r.error || r.value); if (!response) { const error = `No answer found${parameters.vizId ? ` for vizId: ${parameters.vizId}` : ''}.`; - throw { error }; + // A timeout arrives here as a missing response, because + // processTrigger resolves with an Error rather than rejecting. The + // thrown shape stays as it was; the flag lets telemetry tell an + // unanswered trigger from a genuine "no answer". + throw isTriggerTimeout(raw) ? { error, isTimeout: true } : { error }; } const errors = response.error @@ -278,6 +286,11 @@ export class HostEventClient { * @param hostEvent - The host event to trigger * @param payload - Optional payload for the event * @param context - Optional context (e.g. vizId) for scoped operations + * @param onRoute - Optional telemetry hook, called with the dispatch branch + * taken here. It reports which branch ran, not which channel ultimately + * carried the message: a custom handler can fall back to the legacy channel + * itself, and `ui-passthrough` falls back too when the app returns no usable + * response. */ public async triggerHostEvent< HostEventT extends HostEvent, @@ -287,6 +300,7 @@ export class HostEventClient { hostEvent: HostEventT, payload?: TriggerPayload, context?: ContextT, + onRoute?: (route: HostEventRoute) => void, ): Promise> { const customHandler = this.customHandlers[hostEvent]; const passthroughEvent = PASSTHROUGH_MAP[hostEvent]; @@ -294,15 +308,22 @@ export class HostEventClient { // If embedded app supports passthrough but not this event, use legacy channel const keys = passthroughEvent ? await this.getAvailableUIPassthroughKeys(context as ContextType) : []; if (passthroughEvent && keys.length > 0 && !keys.includes(passthroughEvent)) { + onRoute?.('legacy'); return this.hostEventFallback(hostEvent, payload, context) as any; } // Custom handler (setters) > getter passthrough > legacy fallback - return (customHandler - ? customHandler(payload, context as ContextType) - : passthroughEvent - ? this.getDataWithPassthroughFallback(passthroughEvent, hostEvent, payload, context as ContextType) - : this.hostEventFallback(hostEvent, payload, context) - ) as any; + if (customHandler) { + onRoute?.('custom-handler'); + return customHandler(payload, context as ContextType) as any; + } + if (passthroughEvent) { + onRoute?.('ui-passthrough'); + return this.getDataWithPassthroughFallback( + passthroughEvent, hostEvent, payload, context as ContextType, + ) as any; + } + onRoute?.('legacy'); + return this.hostEventFallback(hostEvent, payload, context) as any; } } diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 10ed5d535..22610ff88 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -71,6 +71,12 @@ import { BaseViewConfig, } from '../types'; import { uploadMixpanelEvent, MIXPANEL_EVENT } from '../mixpanel-service'; +import { + getHostEventTelemetryProps, + HostEventRoute, + HostEventStatus, +} from '../utils/hostEventTelemetry'; +import { isTriggerTimeout } from '../utils/processTrigger'; import { processEventData, processAuthFailure } from '../utils/processData'; import { version } from '../utils/sdk-version'; import { @@ -1679,9 +1685,31 @@ export class TsEmbed { data: TriggerPayload = {} as any, context?: ContextT, ): Promise> { - uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`); + const telemetryProps = getHostEventTelemetryProps({ + hostEvent: messageType, + payload: data, + context, + embedComponentType: this.viewConfig?.embedComponentType, + }); + const triggerStartedAt = Date.now(); + let route: HostEventRoute | undefined; + // Emitted once, when the trigger settles or bails out, so a single + // Mixpanel report can answer which host events are used, with which + // parameters, and how they resolve. + const reportHostEvent = (status: HostEventStatus, errorCode?: EmbedErrorCodes) => { + uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_HOST_EVENT, { + ...telemetryProps, + status, + durationMs: Date.now() - triggerStartedAt, + ...(route ? { route } : {}), + ...(errorCode ? { errorCode } : {}), + }); + }; + + uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`, telemetryProps); if (!this.isRendered) { + reportHostEvent('render-not-called', EmbedErrorCodes.RENDER_NOT_CALLED); this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.RENDER_BEFORE_EVENTS_REQUIRED, @@ -1692,6 +1720,7 @@ export class TsEmbed { } if (!messageType) { + reportHostEvent('host-event-undefined', EmbedErrorCodes.HOST_EVENT_TYPE_UNDEFINED); this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.HOST_EVENT_TYPE_UNDEFINED, @@ -1707,34 +1736,54 @@ export class TsEmbed { logger.debug( `Cannot trigger ${messageType} - iframe not available (likely due to auth failure)`, ); + reportHostEvent('no-iframe'); return null; } // send an empty object, this is needed for liveboard default handlers - return this.hostEventClient.triggerHostEvent(messageType, data, context).catch( - ( - err: Error & { - isValidationError?: boolean; - embedErrorDetails?: { - errorType: ErrorDetailsTypes; - message: string; - code: EmbedErrorCodes; - error: string; - }; + return this.hostEventClient + .triggerHostEvent(messageType, data, context, (dispatchRoute) => { + route = dispatchRoute; + }) + .then((response) => { + reportHostEvent(isTriggerTimeout(response) ? 'timed-out' : 'success'); + return response; + }) + .catch( + ( + err: Error & { + isValidationError?: boolean; + isTimeout?: boolean; + embedErrorDetails?: { + errorType: ErrorDetailsTypes; + message: string; + code: EmbedErrorCodes; + error: string; + }; + }, + ): Promise => { + if (err?.isValidationError) { + const errorDetails = err.embedErrorDetails ?? { + errorType: ErrorDetailsTypes.VALIDATION_ERROR, + message: err.message || ERROR_MESSAGE.UPDATEFILTERS_INVALID_PAYLOAD, + code: EmbedErrorCodes.UPDATEFILTERS_INVALID_PAYLOAD, + error: err.message, + }; + this.handleError(errorDetails); + reportHostEvent('error', errorDetails.code); + } else if (err?.isTimeout) { + // A UI passthrough setter turns an unanswered trigger + // into a thrown "no answer", so the timeout only + // reaches us as this flag. + reportHostEvent('timed-out'); + } else { + // The error message can hold customer data, so only the + // fact of the failure is reported. + reportHostEvent('error'); + } + throw err; }, - ): Promise => { - if (err?.isValidationError) { - const errorDetails = err.embedErrorDetails ?? { - errorType: ErrorDetailsTypes.VALIDATION_ERROR, - message: err.message || ERROR_MESSAGE.UPDATEFILTERS_INVALID_PAYLOAD, - code: EmbedErrorCodes.UPDATEFILTERS_INVALID_PAYLOAD, - error: err.message, - }; - this.handleError(errorDetails); - } - throw err; - }, - ); + ); } /** diff --git a/src/mixpanel-service.spec.ts b/src/mixpanel-service.spec.ts index 9fe7c229d..5f8c35ef0 100644 --- a/src/mixpanel-service.spec.ts +++ b/src/mixpanel-service.spec.ts @@ -3,6 +3,7 @@ import { initMixpanel, uploadMixpanelEvent, MIXPANEL_EVENT, + MAX_QUEUED_EVENTS, testResetMixpanel, } from './mixpanel-service'; import { AuthType } from './types'; @@ -83,6 +84,20 @@ describe('Unit test for mixpanel', () => { expect(mixpanel.track).toHaveBeenCalledTimes(2); }); + test('caps the pre-init queue, so tracking left uninitialized cannot grow it', () => { + testResetMixpanel(); + for (let i = 0; i < MAX_QUEUED_EVENTS + 50; i += 1) { + uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_HOST_EVENT, { index: i }); + } + const sessionInfo = { + mixpanelToken: 'abc123', + userGUID: '12345', + isPublicUser: false, + } as SessionInfo; + initMixpanel(sessionInfo); + expect(mixpanel.track).toHaveBeenCalledTimes(MAX_QUEUED_EVENTS); + }); + test('init mixpanel with no mixpanel token', () => { jest.spyOn(logger, 'error').mockImplementation(() => {}); initMixpanel({ test: 'dummy' } as any); diff --git a/src/mixpanel-service.ts b/src/mixpanel-service.ts index 09da666b5..61dd97b1d 100644 --- a/src/mixpanel-service.ts +++ b/src/mixpanel-service.ts @@ -22,6 +22,10 @@ export const MIXPANEL_EVENT = { VISUAL_SDK_RENDER_COMPLETE: 'visual-sdk-render-complete', VISUAL_SDK_RENDER_FAILED: 'visual-sdk-render-failed', VISUAL_SDK_TRIGGER: 'visual-sdk-trigger', + // Emitted once per host event trigger, when it settles. Carries the host + // event name as a property, so one report can rank host events and their + // parameters instead of needing one report per `visual-sdk-trigger-*` name. + VISUAL_SDK_HOST_EVENT: 'visual-sdk-host-event', VISUAL_SDK_ON: 'visual-sdk-on', VISUAL_SDK_IFRAME_LOAD_PERFORMANCE: 'visual-sdk-iframe-load-performance', VISUAL_SDK_EMBED_CREATE: 'visual-sdk-embed-create', @@ -35,6 +39,14 @@ export const MIXPANEL_EVENT = { let isMixpanelInitialized = false; let eventQueue: { eventId: string; eventProps: any }[] = []; +/** + * Upper bound on events held before mixpanel is initialized. A host + * application can turn tracking off entirely with `disableSDKTracking`, in + * which case `initMixpanel` is never called and this queue would otherwise + * grow for the lifetime of the page. + */ +export const MAX_QUEUED_EVENTS = 100; + /** * Pushes the event with its Property key-value map to mixpanel. * @param eventId @@ -42,7 +54,9 @@ let eventQueue: { eventId: string; eventProps: any }[] = []; */ export function uploadMixpanelEvent(eventId: string, eventProps = {}): void { if (!isMixpanelInitialized) { - eventQueue.push({ eventId, eventProps }); + if (eventQueue.length < MAX_QUEUED_EVENTS) { + eventQueue.push({ eventId, eventProps }); + } return; } mixpanelInstance.track(eventId, eventProps); diff --git a/src/utils/hostEventTelemetry.spec.ts b/src/utils/hostEventTelemetry.spec.ts new file mode 100644 index 000000000..82094cff3 --- /dev/null +++ b/src/utils/hostEventTelemetry.spec.ts @@ -0,0 +1,241 @@ +import { + describeHostEventPayload, + getHostEventTelemetryProps, + MAX_SHAPE_PATHS, + REDACTED_KEY, +} from './hostEventTelemetry'; +import { ContextType, HostEvent, RuntimeFilterOp } from '../types'; +import { ApplicabilityLevel } from '../embed/hostEventClient/contracts'; +import { version } from './sdk-version'; + +describe('describeHostEventPayload', () => { + test('reports no payload for undefined and null', () => { + [undefined, null].forEach((payload) => { + expect(describeHostEventPayload(payload)).toEqual({ + hasPayload: false, + payloadType: 'none', + paramCount: 0, + paramKeys: [], + paramShape: [], + shapeTruncated: false, + }); + }); + }); + + test('reports an empty object as a payload with no parameters', () => { + const shape = describeHostEventPayload({}); + expect(shape.hasPayload).toBe(false); + expect(shape.payloadType).toBe('object'); + expect(shape.paramCount).toBe(0); + expect(shape.paramKeys).toEqual([]); + }); + + test('reports which parameters of an object payload are used', () => { + const shape = describeHostEventPayload({ + newVizName: 'Quarterly revenue', + liveboardId: '4c8a1b2e-0000-0000-0000-000000000001', + vizId: 'd0a1', + }); + expect(shape.paramCount).toBe(3); + expect(shape.paramKeys).toEqual(['liveboardId', 'newVizName', 'vizId']); + expect(shape.paramShape).toEqual([ + 'liveboardId:string', + 'newVizName:string', + 'vizId:string', + ]); + }); + + test('reports a boolean by its type, not its value', () => { + const shape = describeHostEventPayload({ runRuntimeFilters: true, isPublic: false }); + expect(shape.paramShape).toEqual(['isPublic:boolean', 'runRuntimeFilters:boolean']); + }); + + test('reports array length and the shape of the first element', () => { + const shape = describeHostEventPayload({ + runtimeFilters: [ + { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['west', 'east'] }, + { columnName: 'Revenue', operator: RuntimeFilterOp.GT, values: [100] }, + ], + }); + expect(shape.paramKeys).toEqual(['runtimeFilters']); + expect(shape.paramShape).toEqual([ + 'runtimeFilters:array(2)', + 'runtimeFilters[]:object(3)', + 'runtimeFilters[].columnName:string', + // `operator` is an SDK enum, so the member is reported. + 'runtimeFilters[].operator:EQ', + 'runtimeFilters[].values:array(2)', + ]); + }); + + test('reports the member of an enum parameter, by either spelling', () => { + expect( + describeHostEventPayload({ + filters: [{ column: 'Region', oper: RuntimeFilterOp.IN, values: ['west'] }], + }).paramShape, + ).toContain('filters[].oper:IN'); + + expect( + describeHostEventPayload({ + filter: { + column: 'Region', + operator: RuntimeFilterOp.BW, + applicability: { level: ApplicabilityLevel.Tab, targetId: 'tab-1' }, + }, + }).paramShape, + ).toEqual( + expect.arrayContaining(['filter.operator:BW', 'filter.applicability.level:TAB']), + ); + }); + + test('falls back to the type when an enum parameter holds something else', () => { + const shape = describeHostEventPayload({ + filters: [{ column: 'Region', oper: 'Total Sales > 500', values: ['west'] }], + }); + expect(shape.paramShape).toContain('filters[].oper:string'); + expect(JSON.stringify(shape)).not.toContain('Total Sales'); + }); + + test('does not treat a customer value as an enum just because a sibling key does', () => { + // `values` is never an enum parameter, so an + // operator-shaped value in it stays a type. + const shape = describeHostEventPayload({ + oper: RuntimeFilterOp.EQ, + values: ['EQ'], + }); + expect(shape.paramShape).toEqual(['oper:EQ', 'values:array(1)', 'values[]:string']); + }); + + test('reports empty containers and nulls without walking into them', () => { + const shape = describeHostEventPayload({ + runtimeFilters: [], + parameters: {}, + vizId: null, + }); + expect(shape.paramShape).toEqual([ + 'parameters:object(0)', + 'runtimeFilters:array(0)', + 'vizId:null', + ]); + expect(shape.shapeTruncated).toBe(false); + }); + + test('treats a top-level array payload as the parameter list', () => { + const shape = describeHostEventPayload([ + { columnName: 'Region', values: ['west'] }, + ]); + expect(shape.payloadType).toBe('array'); + expect(shape.paramCount).toBe(1); + expect(shape.paramKeys).toEqual(['columnName', 'values']); + expect(shape.paramShape[0]).toBe('payload:array(1)'); + }); + + test('reports a primitive payload as its type only', () => { + expect(describeHostEventPayload('answer-guid')).toEqual( + expect.objectContaining({ + hasPayload: true, + payloadType: 'primitive', + paramKeys: [], + paramShape: ['payload:string'], + }), + ); + }); + + test('never reports a payload value', () => { + const secrets = ['Region', 'west', 'super-secret-token', 'Quarterly revenue']; + const shape = describeHostEventPayload({ + name: 'Quarterly revenue', + token: 'super-secret-token', + filters: [{ columnName: 'Region', values: ['west'] }], + }); + const serialized = JSON.stringify(shape); + secrets.forEach((secret) => { + expect(serialized).not.toContain(secret); + }); + }); + + test('redacts key names that could be customer data', () => { + const shape = describeHostEventPayload({ + 'Total Sales': 100, + région: 'west', + [`${'a'.repeat(41)}`]: 1, + vizId: 'd0a1', + }); + expect(shape.paramKeys.filter((key) => key !== 'vizId')).toEqual([ + REDACTED_KEY, + REDACTED_KEY, + REDACTED_KEY, + ]); + expect(shape.paramShape).toContain('vizId:string'); + expect(shape.paramShape).not.toContain('Total Sales:number'); + }); + + test('summarizes below the depth limit instead of walking the whole payload', () => { + const shape = describeHostEventPayload({ + a: { b: { c: { d: { e: 'deep' } } } }, + }); + expect(shape.shapeTruncated).toBe(true); + expect(shape.paramShape).toEqual([ + 'a:object(1)', + 'a.b:object(1)', + 'a.b.c:object(1)', + ]); + }); + + test('caps the number of reported key paths', () => { + const wide: Record = {}; + for (let i = 0; i < MAX_SHAPE_PATHS + 10; i += 1) { + wide[`param${i}`] = i; + } + const shape = describeHostEventPayload(wide); + expect(shape.paramCount).toBe(MAX_SHAPE_PATHS + 10); + expect(shape.paramShape).toHaveLength(MAX_SHAPE_PATHS); + expect(shape.shapeTruncated).toBe(true); + }); + + test('survives a cyclic payload', () => { + const cyclic: any = { vizId: 'd0a1' }; + cyclic.self = cyclic; + expect(() => describeHostEventPayload(cyclic)).not.toThrow(); + expect(describeHostEventPayload(cyclic).paramKeys).toEqual(['self', 'vizId']); + }); + + test('survives a payload with a throwing getter', () => { + const hostile = { + get vizId() { + throw new Error('nope'); + }, + }; + expect(describeHostEventPayload(hostile)).toEqual( + expect.objectContaining({ payloadType: 'unknown' }), + ); + }); +}); + +describe('getHostEventTelemetryProps', () => { + test('reports the host event, context, embed component and SDK version', () => { + expect( + getHostEventTelemetryProps({ + hostEvent: HostEvent.Pin, + payload: { vizId: 'd0a1' }, + context: ContextType.Liveboard, + embedComponentType: 'LiveboardEmbed', + }), + ).toEqual( + expect.objectContaining({ + hostEvent: HostEvent.Pin, + contextType: ContextType.Liveboard, + embedComponentType: 'LiveboardEmbed', + sdkVersion: version, + paramKeys: ['vizId'], + }), + ); + }); + + test('falls back when context and embed component are unknown', () => { + const props = getHostEventTelemetryProps({ hostEvent: HostEvent.Reload }); + expect(props.contextType).toBe('none'); + expect(props.embedComponentType).toBe('unknown'); + expect(props.hasPayload).toBe(false); + }); +}); diff --git a/src/utils/hostEventTelemetry.ts b/src/utils/hostEventTelemetry.ts new file mode 100644 index 000000000..d12b05a23 --- /dev/null +++ b/src/utils/hostEventTelemetry.ts @@ -0,0 +1,333 @@ +/* + * Telemetry helpers for host events. These build the property bag uploaded + * with the host event Mixpanel events, so we can answer which host events are + * triggered, which parameters of those events are actually used, and how those + * triggers resolve. + * + * Host event payloads carry customer data — GUIDs, filter values, search + * strings and column names. So a value is reported as its `typeof`, not as + * itself: `name:string`, never `name:"Quarterly revenue"`. + * + * The one exception is an SDK enum. `operator:EQ` is a fixed, low-cardinality + * token from our own contract, and knowing *which* operator customers pass is + * the point of the exercise, so enum members are reported by value. A value is + * treated as an enum member only when its key is a known enum-valued parameter + * *and* the value matches one of that enum's members exactly — anything else + * falls back to its type. + * + * Key names are reported too, but only when they read as SDK contract + * identifiers; a payload can be keyed by a customer column name, so anything + * else becomes REDACTED_KEY. + */ + +import { ContextType, HostEvent, RuntimeFilterOp } from '../types'; +import { ApplicabilityLevel } from '../embed/hostEventClient/contracts'; +import { version as sdkVersion } from './sdk-version'; + +/** How deep into a payload the shape walk goes before it summarizes. */ +export const MAX_SHAPE_DEPTH = 3; + +/** Upper bound on the number of key paths reported for one payload. */ +export const MAX_SHAPE_PATHS = 40; + +/** Key names longer than this are reported as {@link REDACTED_KEY}. */ +export const MAX_KEY_LENGTH = 40; + +/** Stands in for a key name that could carry customer data. */ +export const REDACTED_KEY = 'redactedKey'; + +/** + * Path label for a payload that is not a key-value record, so that an array + * payload reads as `payload[].columnName` rather than starting with a colon. + */ +const ROOT_PATH = 'payload'; + +/** + * A key is reported verbatim only when it reads as a plain code identifier, + * the way every key in the host event contracts does. A customer column name + * used as a key ("Total Sales", "région") fails this and gets redacted. + */ +const SAFE_KEY_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * Host event parameters that are typed as an SDK enum, and the members that + * enum allows. A value under one of these keys is reported as-is when it is + * one of the listed members — it is a token from our own contract, not + * customer data. Add a key here when a host event gains an enum parameter. + */ +const ENUM_VALUED_PARAMS: Record = { + // `RuntimeFilter.operator`, and the `oper` spelling that + // `HostEvent.UpdateFilters` also accepts. + operator: Object.values(RuntimeFilterOp), + oper: Object.values(RuntimeFilterOp), + // `Applicability.level` on a filter or parameter update. + level: Object.values(ApplicabilityLevel), +}; + +/** + * Whether a value is a member of the enum its key is typed as. + * @param key The key the value sits under + * @param value The string value at that key + */ +function isEnumMember(key: string, value: string): boolean { + return ENUM_VALUED_PARAMS[key]?.includes(value) ?? false; +} + +/** + * Which dispatch branch inside `HostEventClient.triggerHostEvent` served the + * host event. This is the branch that ran, not the channel that ultimately + * carried the message: `custom-handler` means "a setter with custom logic ran", + * and both it and `ui-passthrough` can fall back to the legacy channel + * internally — a custom handler when the payload lacks the fields it needs, and + * a passthrough getter when the app returns no usable response. + */ +export type HostEventRoute = 'custom-handler' | 'ui-passthrough' | 'legacy'; + +/** + * How a host event trigger ended. Everything other than `success` is a case + * the host application cannot currently see in aggregate. + */ +export type HostEventStatus = + | 'success' + | 'error' + | 'timed-out' + | 'render-not-called' + | 'host-event-undefined' + | 'no-iframe'; + +/** + * The shape of a host event payload, with no values in it. + */ +export interface HostEventPayloadShape { + /** Whether the caller passed a payload with anything in it. */ + hasPayload: boolean; + /** Top-level container kind of the payload. */ + payloadType: 'none' | 'object' | 'array' | 'primitive' | 'unknown'; + /** Top-level key count for an object payload, or length for an array. */ + paramCount: number; + /** + * Sorted top-level parameter names. For an array payload these are the + * keys of the first element, which is what identifies, say, which filter + * fields a customer sets on `HostEvent.UpdateFilters`. + */ + paramKeys: string[]; + /** + * 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. + */ + paramShape: string[]; + /** Whether the walk hit {@link MAX_SHAPE_PATHS} or {@link MAX_SHAPE_DEPTH}. */ + shapeTruncated: boolean; +} + +const EMPTY_SHAPE: HostEventPayloadShape = { + hasPayload: false, + payloadType: 'none', + paramCount: 0, + paramKeys: [], + paramShape: [], + shapeTruncated: false, +}; + +/** + * Returns the key if it reads as a code identifier, and a placeholder if it + * could be customer data. + * @param key A key from a host event payload + */ +function sanitizeKey(key: string): string { + return key.length <= MAX_KEY_LENGTH && SAFE_KEY_PATTERN.test(key) ? key : REDACTED_KEY; +} + +/** + * Describes a leaf value by its type, so the value itself never leaves the + * browser. An SDK enum member is the one exception — see the module comment. + * @param value A leaf value from a host event payload + * @param key The key the value sits under, used to spot enum parameters + */ +function describeLeaf(value: unknown, key?: string): string { + if (value === null) { + return 'null'; + } + if (typeof value === 'string' && key && isEnumMember(key, value)) { + return value; + } + return typeof value; +} + +/** + * Whether a value should be walked into as a key-value record. + * @param value A value from a host event payload + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +interface ShapeAccumulator { + paths: string[]; + truncated: boolean; +} + +/** + * Walks a payload branch, appending `path:type` entries to the accumulator. + * The depth and path caps also bound cyclic payloads. + * @param value The value at this path + * @param path The dotted path to this value + * @param acc Collected paths and the truncation flag + * @param depth Current walk depth + * @param key The raw key this value sits under, if it has one + */ +function walkShape( + value: unknown, + path: string, + acc: ShapeAccumulator, + depth: number, + key?: string, +): void { + if (acc.paths.length >= MAX_SHAPE_PATHS) { + acc.truncated = true; + return; + } + + if (Array.isArray(value)) { + acc.paths.push(`${path}:array(${value.length})`); + if (value.length === 0) { + return; + } + if (depth >= MAX_SHAPE_DEPTH) { + acc.truncated = true; + return; + } + walkShape(value[0], `${path}[]`, acc, depth + 1, key); + return; + } + + if (isRecord(value)) { + const keys = Object.keys(value); + acc.paths.push(`${path}:object(${keys.length})`); + if (keys.length === 0) { + return; + } + if (depth >= MAX_SHAPE_DEPTH) { + acc.truncated = true; + return; + } + keys.sort().forEach((childKey) => { + walkShape( + value[childKey], `${path}.${sanitizeKey(childKey)}`, acc, depth + 1, childKey, + ); + }); + return; + } + + acc.paths.push(`${path}:${describeLeaf(value, key)}`); +} + +/** + * Summarizes a host event payload as shape only, never values. + * @param payload The payload passed to `trigger` + * @example + * ```js + * describeHostEventPayload({ runtimeFilters: [{ columnName: 'Region' }] }); + * // paramKeys: ['runtimeFilters'] + * // paramShape: ['runtimeFilters:array(1)', 'runtimeFilters[]:object(1)', + * // 'runtimeFilters[].columnName:string'] + * ``` + */ +export function describeHostEventPayload(payload: unknown): HostEventPayloadShape { + if (payload === undefined || payload === null) { + return { ...EMPTY_SHAPE }; + } + + try { + const acc: ShapeAccumulator = { paths: [], truncated: false }; + + if (Array.isArray(payload)) { + const firstElement = payload[0]; + walkShape(payload, ROOT_PATH, acc, 0); + return { + hasPayload: payload.length > 0, + payloadType: 'array', + paramCount: payload.length, + paramKeys: isRecord(firstElement) + ? Object.keys(firstElement).map(sanitizeKey).sort() + : [], + paramShape: acc.paths, + shapeTruncated: acc.truncated, + }; + } + + if (isRecord(payload)) { + const keys = Object.keys(payload); + keys.sort().forEach((key) => { + walkShape(payload[key], sanitizeKey(key), acc, 1, key); + }); + return { + hasPayload: keys.length > 0, + payloadType: 'object', + paramCount: keys.length, + paramKeys: keys.map(sanitizeKey), + paramShape: acc.paths, + shapeTruncated: acc.truncated, + }; + } + + return { + ...EMPTY_SHAPE, + hasPayload: true, + payloadType: 'primitive', + paramShape: [`${ROOT_PATH}:${describeLeaf(payload)}`], + }; + } catch (e) { + // A payload with a throwing getter must never break the trigger it is + // describing. + return { ...EMPTY_SHAPE, payloadType: 'unknown' }; + } +} + +/** + * The properties uploaded with a host event Mixpanel event. + */ +export interface HostEventTelemetryProps extends HostEventPayloadShape { + /** The host event that was triggered. */ + hostEvent: string; + /** The context the trigger was scoped to, or `none`. */ + contextType: string; + /** Which embed component triggered it, or `unknown`. */ + embedComponentType: string; + /** Version of the SDK the host application is on. */ + sdkVersion: string; +} + +/** + * Builds the property bag for a host event trigger. + * + * The name of the host event is a *property* here, not only a suffix on the + * Mixpanel event name, so that a single report can rank host events by usage + * instead of one report per event name. + * @param params Trigger details + * @param params.hostEvent The host event being triggered + * @param params.payload The payload passed to `trigger` + * @param params.context The context the trigger is scoped to + * @param params.embedComponentType The embed component that is triggering + */ +export function getHostEventTelemetryProps({ + hostEvent, + payload, + context, + embedComponentType, +}: { + hostEvent: HostEvent; + payload?: unknown; + context?: ContextType; + embedComponentType?: string; +}): HostEventTelemetryProps { + return { + hostEvent: String(hostEvent), + contextType: context ? String(context) : 'none', + embedComponentType: embedComponentType || 'unknown', + sdkVersion, + ...describeHostEventPayload(payload), + }; +} diff --git a/src/utils/processTrigger.ts b/src/utils/processTrigger.ts index 761eb6463..3f72db2e6 100644 --- a/src/utils/processTrigger.ts +++ b/src/utils/processTrigger.ts @@ -37,6 +37,17 @@ function postIframeMessage( export const TRIGGER_TIMEOUT = 30000; +/** + * Whether a settled `processTrigger` result is the timeout sentinel. + * + * `processTrigger` resolves — it does not reject — with an Error when the + * embedded app never answers, so without this check a timed-out trigger is + * indistinguishable from a successful one. + * @param value A value a `processTrigger` promise settled with + */ +export const isTriggerTimeout = (value: unknown): boolean => value instanceof Error + && value.message === ERROR_MESSAGE.TRIGGER_TIMED_OUT; + /** * * @param iFrame