Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
88372d3
fix(telemetry): restore parameter description and the enum membership…
sastaachar Sep 2, 2026
4416463
feat(filters): accept column and operator on runtime and Liveboard fi…
sastaachar Sep 2, 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'],
});
});
});
72 changes: 68 additions & 4 deletions src/embed/hostEventClient/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,78 @@ export interface Applicability {
targetId?: string;
}

export interface FilterUpdate {
column: string;
oper: string;
values: string[];
/**
* The attributes of a Liveboard filter update other than the column and
* operator it applies with.
*/
export interface FilterUpdateBase {
/**
* The list of operands. Accepts the same types as the `values` of a
* {@link RuntimeFilter}, widened from `string[]`.
* @version SDK: 1.53.0 | ThoughtSpot Cloud: 26.10.0.cl
*/
values: (number | boolean | string | bigint)[];
type?: string;
applicability?: Applicability;
}

/**
* A filter passed to {@link HostEvent.UpdateFilters}.
*
* The column is named with `column` and the operator with `operator`, matching
* {@link RuntimeFilter}, so a filter read back from
* {@link HostEvent.GetFilters} can be passed to either event without renaming.
*
* The older `columnName` and `oper` still work; `column` and `operator` win
* when both are given.
* @version SDK: 1.53.0 | ThoughtSpot Cloud: 26.10.0.cl
*/
export type FilterUpdate = FilterUpdateBase
& (
| {
/**
* The name of the column to filter on (case-sensitive)
*/
column: string;
/**
* @deprecated Use `column` instead.
*/
columnName?: string;
}
| {
/**
* @deprecated Use `column` instead.
*/
columnName: string;
/**
* The name of the column to filter on (case-sensitive)
*/
column?: string;
}
)
& (
| {
/**
* The operator to apply
*/
operator: string;
/**
* @deprecated Use `operator` instead.
*/
oper?: string;
}
| {
/**
* @deprecated Use `operator` instead.
*/
oper: string;
/**
* The operator to apply
*/
operator?: string;
}
);

export interface LiveboardFilter {
applicability?: Applicability;
[key: string]: any;
Expand Down
176 changes: 176 additions & 0 deletions src/embed/hostEventClient/host-event-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,182 @@ describe('HostEventClient', () => {
);
});

it('should accept UpdateFilters written with columnName and send column', async () => {
const { client, mockIframe } = createHostEventClient();
const payload = {
filters: [
{ columnName: 'item type', oper: 'IN', values: ['shoes', 'boots'] },
{ column: 'Region', oper: 'IN', values: ['West'] },
],
} as any;
mockProcessTrigger
.mockResolvedValueOnce(mockGetAvailablePassthroughs())
.mockResolvedValueOnce([{ value: { success: true } }]);

await client.triggerHostEvent(HostEvent.UpdateFilters, payload);

expect(mockProcessTrigger).toHaveBeenNthCalledWith(
2,
mockIframe,
HostEvent.UIPassthrough,
mockThoughtSpotHost,
{
type: UIPassthroughEvent.UpdateFilters,
parameters: {
filters: [
{ column: 'item type', oper: 'IN', values: ['shoes', 'boots'] },
{ column: 'Region', oper: 'IN', values: ['West'] },
],
},
},
undefined,
);
});

it('should accept UpdateFilters written with operator and send oper', async () => {
const { client, mockIframe } = createHostEventClient();
const payload = {
filters: [
{ column: 'city', operator: 'IN', values: ['atlanta'] },
{ column: 'Region', oper: 'ignored', operator: 'EQ', values: ['West'] },
],
} as any;
mockProcessTrigger
.mockResolvedValueOnce(mockGetAvailablePassthroughs())
.mockResolvedValueOnce([{ value: { success: true } }]);

await client.triggerHostEvent(HostEvent.UpdateFilters, payload);

expect(mockProcessTrigger.mock.calls[1][3].parameters).toEqual({
filters: [
{ column: 'city', oper: 'IN', values: ['atlanta'] },
{ column: 'Region', oper: 'EQ', values: ['West'] },
],
});
});

it('should accept a single UpdateFilters filter written with columnName', async () => {
const { client, mockIframe } = createHostEventClient();
const payload = {
filter: {
columnName: 'item type',
oper: 'IN',
values: [1, true, 'boots'],
applicability: { level: 'TAB', targetId: 'tab-1' },
},
} as any;
mockProcessTrigger
.mockResolvedValueOnce(mockGetAvailablePassthroughs())
.mockResolvedValueOnce([{ value: {} }]);

await client.triggerHostEvent(HostEvent.UpdateFilters, payload);

expect(mockProcessTrigger).toHaveBeenNthCalledWith(
2,
mockIframe,
HostEvent.UIPassthrough,
mockThoughtSpotHost,
{
type: UIPassthroughEvent.UpdateFilters,
parameters: {
filter: {
column: 'item type',
oper: 'IN',
values: [1, true, 'boots'],
applicability: { level: 'TAB', targetId: 'tab-1' },
},
},
},
undefined,
);
});

it('should accept UpdateRuntimeFilters written with column and send columnName', async () => {
const { client, mockIframe } = createHostEventClient();
const payload = [
{ column: 'state', operator: 'EQ', values: ['michigan'] },
{ columnName: 'item type', operator: 'IN', values: ['Jackets'] },
] as any;
mockProcessTrigger.mockResolvedValueOnce({ success: true });

await client.triggerHostEvent(HostEvent.UpdateRuntimeFilters, payload);

expect(mockProcessTrigger).toHaveBeenCalledWith(
mockIframe,
HostEvent.UpdateRuntimeFilters,
mockThoughtSpotHost,
[
{ columnName: 'state', operator: 'EQ', values: ['michigan'] },
{ columnName: 'item type', operator: 'IN', values: ['Jackets'] },
],
undefined,
);
});

it('should prefer column over the deprecated columnName', async () => {
const { client, mockIframe } = createHostEventClient();
const payload = [
{ columnName: 'ignored', column: 'state', operator: 'EQ', values: [BigInt(10)] },
] as any;
mockProcessTrigger.mockResolvedValueOnce({ success: true });

await client.triggerHostEvent(HostEvent.UpdateRuntimeFilters, payload);

expect(mockProcessTrigger).toHaveBeenCalledWith(
mockIframe,
HostEvent.UpdateRuntimeFilters,
mockThoughtSpotHost,
[{ columnName: 'state', operator: 'EQ', values: [BigInt(10)] }],
undefined,
);
});

it('should keep a vizId stamped on the UpdateRuntimeFilters array itself', async () => {
const { client, mockIframe } = createHostEventClient();
const payload: any = [{ column: 'state', operator: 'EQ', values: ['michigan'] }];
payload.vizId = 'viz-1';
mockProcessTrigger.mockResolvedValueOnce({ success: true });

await client.triggerHostEvent(HostEvent.UpdateRuntimeFilters, payload);

const sent = mockProcessTrigger.mock.calls[0][3];
expect(sent.vizId).toBe('viz-1');
expect(sent.length).toBe(1);
expect(sent[0]).toEqual({ columnName: 'state', operator: 'EQ', values: ['michigan'] });
});

it('should return GetFilters filters carrying both column spellings', async () => {
const { client } = createHostEventClient();
mockProcessTrigger
.mockResolvedValueOnce(mockGetAvailablePassthroughs())
.mockResolvedValueOnce([{
value: {
liveboardFilters: [{ column: 'Region', oper: 'IN', values: ['West'] }],
runtimeFilters: [{ columnName: 'state', operator: 'EQ', values: ['michigan'] }],
},
}]);

const result = await client.triggerHostEvent(HostEvent.GetFilters, {}) as any;

expect(result.liveboardFilters[0]).toEqual({
column: 'Region', columnName: 'Region', oper: 'IN', operator: 'IN', values: ['West'],
});
expect(result.runtimeFilters[0]).toEqual({
column: 'state', columnName: 'state', oper: 'EQ', operator: 'EQ', values: ['michigan'],
});
});

it('should leave a GetFilters entry alone when it names no column', async () => {
const { client } = createHostEventClient();
mockProcessTrigger
.mockResolvedValueOnce(mockGetAvailablePassthroughs())
.mockResolvedValueOnce([{ value: { liveboardFilters: [{ id: 'f1' }], runtimeFilters: [] as any[] } }]);

const result = await client.triggerHostEvent(HostEvent.GetFilters, {}) as any;

expect(result).toEqual({ liveboardFilters: [{ id: 'f1' }], runtimeFilters: [] });
});

it('should dispatch UpdateParameters over the legacy channel', async () => {
const { client, mockIframe } = createHostEventClient();
const payload = [
Expand Down
Loading
Loading