Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ecb5f18
feat(telemetry): report which host event and which params are triggered
sastaachar Aug 20, 2026
73a1139
docs(telemetry): note that ui-passthrough can fall back internally too
sastaachar Aug 20, 2026
64dcca4
fix(telemetry): report a timed-out UI passthrough setter as timed out
sastaachar Aug 20, 2026
c42ddb9
refactor(telemetry): one upload per trigger, add embed events, drop c…
sastaachar Aug 21, 2026
d3e1ee1
fix(telemetry): stop uploading the SDK's own event registrations
sastaachar Aug 21, 2026
aea8ca1
fix(telemetry): count only the handlers a dispatch ran, describe the …
sastaachar Aug 21, 2026
d711b2e
feat(telemetry): two response-aware events, one per direction
sastaachar Aug 21, 2026
bb33dd4
fix(telemetry): keep handlerCount accurate when a handler responds in…
sastaachar Aug 21, 2026
84fe017
refactor(telemetry): reduce to PR 1 — host event parameters only
sastaachar Aug 21, 2026
a94ebf6
refactor(telemetry): dump the payload as a type map, drop the walker
sastaachar Aug 21, 2026
d084b57
fix(telemetry): never let telemetry break the trigger it describes
sastaachar Aug 21, 2026
33dc0e8
refactor(telemetry): drop the key redaction, guard values only
sastaachar Aug 21, 2026
0e16e80
feat(telemetry): report array element types, not just a count
sastaachar Aug 21, 2026
c89a164
feat(telemetry): describe nested objects, keep the shape of the payload
sastaachar Aug 21, 2026
66ea595
refactor(telemetry): name the cycle guard for what it holds
sastaachar Aug 21, 2026
497481a
refactor(telemetry): serialise the payload first, drop the cycle guard
sastaachar Aug 21, 2026
e4f27f3
refactor(telemetry): one traversal entry point, and say why it is cyc…
sastaachar Aug 21, 2026
daf3907
SCAL-333657 : Clean up
sastaachar Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/embed/host-event-telemetry.spec.ts
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'],
});
});
});
8 changes: 7 additions & 1 deletion src/embed/ts-embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
BaseViewConfig,
} from '../types';
import { uploadMixpanelEvent, MIXPANEL_EVENT } from '../mixpanel-service';
import { reportHostEvent } from '../utils/hostEventTelemetry';
import { processEventData, processAuthFailure } from '../utils/processData';
import { version } from '../utils/sdk-version';
import {
Expand Down Expand Up @@ -1679,7 +1680,12 @@ export class TsEmbed {
data: TriggerPayload<PayloadT, HostEventT> = {} as any,
context?: ContextT,
): Promise<TriggerResponse<PayloadT, HostEventT, ContextT>> {
uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`);
reportHostEvent({
hostEvent: messageType,
payload: data,
context,
embedComponentType: this.viewConfig?.embedComponentType,
});

if (!this.isRendered) {
this.handleError({
Expand Down
143 changes: 143 additions & 0 deletions src/utils/hostEventTelemetry.spec.ts
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: [],
}),
);
});
});
93 changes: 93 additions & 0 deletions src/utils/hostEventTelemetry.ts
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;
Comment on lines +29 to +36

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;

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

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

};

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