-
Notifications
You must be signed in to change notification settings - Fork 13
SCAL-333657: track which host event is triggered and with which parameters #635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ecb5f18
73a1139
64dcca4
c42ddb9
d3e1ee1
aea8ca1
d711b2e
bb33dd4
84fe017
a94ebf6
d084b57
33dc0e8
0e16e80
c89a164
66ea595
497481a
e4f27f3
daf3907
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import { | ||
| init, AuthType, LiveboardEmbed, HostEvent, RuntimeFilterOp, | ||
| } from '../index'; | ||
| import { getDocumentBody, getRootEl } from '../test/test-utils'; | ||
| import * as authInstance from '../auth'; | ||
| import * as mixpanelInstance from '../mixpanel-service'; | ||
| import { MIXPANEL_EVENT } from '../mixpanel-service'; | ||
| import * as processTriggerInstance from '../utils/processTrigger'; | ||
|
|
||
| describe('Host event parameter telemetry', () => { | ||
| let mockUploadMixpanelEvent: jest.SpyInstance; | ||
|
|
||
| beforeEach(() => { | ||
| document.body.innerHTML = getDocumentBody(); | ||
| jest.spyOn(authInstance, 'postLoginService').mockImplementation( | ||
| () => Promise.resolve(true as any), | ||
| ); | ||
| jest.spyOn(processTriggerInstance, 'processTrigger').mockResolvedValue({ session: 'ok' }); | ||
| mockUploadMixpanelEvent = jest.spyOn(mixpanelInstance, 'uploadMixpanelEvent'); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| 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; | ||
| }; | ||
|
|
||
| const triggerProps = (hostEvent: HostEvent) => { | ||
| const uploads = mockUploadMixpanelEvent.mock.calls.filter( | ||
| ([id]) => id === `${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${hostEvent}`, | ||
| ); | ||
| expect(uploads).toHaveLength(1); | ||
| return uploads[0][1] as Record<string, any>; | ||
| }; | ||
|
|
||
| test('reports which host event was triggered and which parameters it used', async () => { | ||
| const embed = await renderLiveboard(); | ||
| mockUploadMixpanelEvent.mockClear(); | ||
|
|
||
| await embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' }); | ||
|
|
||
| expect(triggerProps(HostEvent.DownloadAsCsv)).toEqual( | ||
| expect.objectContaining({ | ||
| hostEvent: HostEvent.DownloadAsCsv, | ||
| embedComponentType: 'LiveboardEmbed', | ||
| contextType: 'none', | ||
| params: { vizId: 'string' }, | ||
| paramKeys: ['vizId'], | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| test('keeps the existing event name, so existing reports still work', async () => { | ||
| const embed = await renderLiveboard(); | ||
| mockUploadMixpanelEvent.mockClear(); | ||
|
|
||
| await embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' }); | ||
|
|
||
| expect(mockUploadMixpanelEvent.mock.calls.map(([id]) => id)).toEqual([ | ||
| `${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${HostEvent.DownloadAsCsv}`, | ||
| ]); | ||
| }); | ||
|
|
||
| test('a payload it cannot describe never breaks the trigger', async () => { | ||
| const embed = await renderLiveboard(); | ||
| mockUploadMixpanelEvent.mockClear(); | ||
| const hostile = { | ||
| get vizId() { | ||
| throw new Error('no telemetry for you'); | ||
| }, | ||
| }; | ||
|
|
||
| await expect(embed.trigger(HostEvent.DownloadAsCsv, hostile)).resolves.toEqual( | ||
| { session: 'ok' }, | ||
| ); | ||
| }); | ||
|
|
||
| test('a failing upload never breaks the trigger', async () => { | ||
| const embed = await renderLiveboard(); | ||
| mockUploadMixpanelEvent.mockClear(); | ||
| mockUploadMixpanelEvent.mockImplementation(() => { | ||
| throw new Error('mixpanel is down'); | ||
| }); | ||
|
|
||
| await expect( | ||
| embed.trigger(HostEvent.DownloadAsCsv, { vizId: 'd0a1' }), | ||
| ).resolves.toEqual({ session: 'ok' }); | ||
| }); | ||
|
|
||
| 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)); | ||
|
|
||
| expect(triggerProps(HostEvent.UpdateRuntimeFilters).params).toEqual({ | ||
| columnName: 'string', | ||
| operator: 'EQ', | ||
| values: ['string'], | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| import { | ||
| describeParams, | ||
| getHostEventTelemetryProps, | ||
| MAX_ARRAY_TYPES, | ||
| } from './hostEventTelemetry'; | ||
| import { ContextType, HostEvent, RuntimeFilterOp } from '../types'; | ||
| import { ApplicabilityLevel } from '../embed/hostEventClient/contracts'; | ||
| import { version } from './sdk-version'; | ||
|
|
||
| describe('describeParams', () => { | ||
| test('dumps each parameter as its type, never its value', () => { | ||
| expect(describeParams({ | ||
| newVizName: 'Quarterly revenue', | ||
| rowCount: 10, | ||
| runRuntimeFilters: true, | ||
| tabId: null, | ||
| columns: ['Region', 'Revenue'], | ||
| points: [{ x: 1 }, null, 7], | ||
| empty: [], | ||
| })).toEqual({ | ||
| newVizName: 'string', | ||
| rowCount: 'number', | ||
| runRuntimeFilters: 'boolean', | ||
| tabId: 'null', | ||
| columns: ['string', 'string'], | ||
| points: [{ x: 'number' }, 'null', 'number'], | ||
| empty: [], | ||
| }); | ||
| }); | ||
|
|
||
| test('describes nested objects all the way down', () => { | ||
| expect(describeParams({ | ||
| filter: { | ||
| column: 'Region', | ||
| applicability: { level: ApplicabilityLevel.Tab, targetId: 'tab-1' }, | ||
| }, | ||
| })).toEqual({ | ||
| filter: { | ||
| column: 'string', | ||
| applicability: { level: 'TAB', targetId: 'string' }, | ||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| test('caps how many element types it reports for a long array', () => { | ||
| const values = Array.from({ length: MAX_ARRAY_TYPES + 5 }, (_v, i) => `value-${i}`); | ||
| expect(describeParams({ values }).values).toHaveLength(MAX_ARRAY_TYPES); | ||
| }); | ||
|
|
||
| test('keeps the member of an enum parameter, by either spelling', () => { | ||
| expect(describeParams({ operator: RuntimeFilterOp.EQ })).toEqual({ operator: 'EQ' }); | ||
| expect(describeParams({ oper: RuntimeFilterOp.IN })).toEqual({ oper: 'IN' }); | ||
| }); | ||
|
|
||
| test('falls back to the type when an enum parameter holds something else', () => { | ||
| expect(describeParams({ operator: 'Total Sales > 500' })).toEqual({ operator: 'string' }); | ||
| }); | ||
|
|
||
| test('reads the parameters of an array payload from its first element', () => { | ||
| expect(describeParams([ | ||
| { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['west'] }, | ||
| ])).toEqual({ | ||
| columnName: 'string', | ||
| operator: 'EQ', | ||
| values: ['string'], | ||
| }); | ||
| }); | ||
|
|
||
| test('reports nothing for a payload it cannot serialise', () => { | ||
| const circular: any = { vizId: 'd0a1' }; | ||
| circular.self = circular; | ||
| expect(describeParams(circular)).toEqual({}); | ||
|
|
||
| const loop: any[] = ['west']; | ||
| loop.push(loop); | ||
| expect(describeParams({ values: loop })).toEqual({}); | ||
|
|
||
| const throwing = { | ||
| get vizId(): string { | ||
| throw new Error('nope'); | ||
| }, | ||
| }; | ||
| expect(describeParams(throwing)).toEqual({}); | ||
| }); | ||
|
|
||
| test('drops a function or undefined parameter instead of choking on it', () => { | ||
| expect(describeParams({ | ||
| vizId: 'd0a1', | ||
| callback: (): void => undefined, | ||
| missing: undefined, | ||
| handlers: [(): void => undefined, 'x'], | ||
| })).toEqual({ | ||
| vizId: 'string', | ||
| handlers: ['null', 'string'], | ||
| }); | ||
| }); | ||
|
|
||
| test('reports nothing for a payload with no parameters', () => { | ||
| [undefined, null, {}, [], 'answer-guid', 42].forEach((payload) => { | ||
| expect(describeParams(payload)).toEqual({}); | ||
| }); | ||
| }); | ||
|
|
||
| test('never reports a payload value', () => { | ||
| const serialized = JSON.stringify(describeParams({ | ||
| name: 'Quarterly revenue', | ||
| token: 'secret-token-abc', | ||
| columns: ['Region'], | ||
| })); | ||
| ['Quarterly revenue', 'secret-token-abc', 'Region'].forEach((secret) => { | ||
| expect(serialized).not.toContain(secret); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| 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({ | ||
| hostEvent: HostEvent.Pin, | ||
| contextType: ContextType.Liveboard, | ||
| embedComponentType: 'LiveboardEmbed', | ||
| sdkVersion: version, | ||
| params: { vizId: 'string' }, | ||
| paramKeys: ['vizId'], | ||
| }); | ||
| }); | ||
|
|
||
| test('falls back when context and embed component are unknown', () => { | ||
| expect(getHostEventTelemetryProps({ hostEvent: HostEvent.Reload })).toEqual( | ||
| expect.objectContaining({ | ||
| contextType: 'none', | ||
| embedComponentType: 'unknown', | ||
| params: {}, | ||
| paramKeys: [], | ||
| }), | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { ContextType, HostEvent, RuntimeFilterOp } from '../types'; | ||
| import { MIXPANEL_EVENT, uploadMixpanelEvent } from '../mixpanel-service'; | ||
| import { logger } from './logger'; | ||
| import { version as sdkVersion } from './sdk-version'; | ||
|
|
||
|
|
||
| // we preserver these field's values | ||
| const PRESERVED_FIELDS: Array<string> = ['operator', 'oper', 'level']; | ||
|
|
||
| export const MAX_ARRAY_TYPES = 10; | ||
|
|
||
| export type ParamTypes = string | ParamTypes[] | { [key: string]: ParamTypes }; | ||
|
|
||
| /* | ||
| * Assumes an already-serialised value, which is what makes the absence of a | ||
| * cycle guard safe: describeParams clones first, and a clone cannot hold a | ||
| * cycle. Do not export this or call it with a raw payload. | ||
| */ | ||
| const describeValue = (value: unknown): ParamTypes => { | ||
| try { | ||
| if (value === null) { | ||
| return 'null'; | ||
| } | ||
|
|
||
| if (Array.isArray(value)) { | ||
| return value.slice(0, MAX_ARRAY_TYPES).map((item) => describeValue(item)); | ||
| } | ||
|
|
||
| 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; | ||
| } catch (e) { | ||
| logger.debug('Error parsing type', value); | ||
| return 'ErrorParsing' | ||
| } | ||
| }; | ||
|
|
||
| export const describeParams = (payload: unknown): unknown => { | ||
| 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(payload)); | ||
| } catch (e) { | ||
| logger.debug('Could not describe host event payload', e); | ||
| return {}; | ||
| } | ||
| return describeValue(params); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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); |
||
| }; | ||
|
|
||
| export interface HostEventTelemetryParams { | ||
| hostEvent: HostEvent; | ||
| payload?: unknown; | ||
| context?: ContextType; | ||
| embedComponentType?: string; | ||
| } | ||
|
|
||
| export const getHostEventTelemetryProps = ({ | ||
| hostEvent, | ||
| payload, | ||
| context, | ||
| embedComponentType, | ||
| }: HostEventTelemetryParams) => { | ||
| const params = describeParams(payload); | ||
| return { | ||
| hostEvent: String(hostEvent), | ||
| contextType: context ? String(context) : 'none', | ||
| embedComponentType: embedComponentType || 'unknown', | ||
| sdkVersion, | ||
| params, | ||
| paramKeys: Object.keys(params), | ||
| }; | ||
| }; | ||
|
|
||
| export const reportHostEvent = (params: HostEventTelemetryParams): void => { | ||
| try { | ||
| uploadMixpanelEvent( | ||
| `${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${params.hostEvent}`, | ||
| getHostEventTelemetryProps(params), | ||
| ); | ||
| } catch (e) { | ||
| logger.debug('Could not report host event telemetry', e); | ||
| } | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
describeValuefunction 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.