Advanced Analytics
Overview
@shared/advanced-analytics embeds ThoughtSpot analytics into rhapsody using Trusted Authentication. The package provides a single provider (AdvancedAnalyticsProvider), a high-level AnalyticsLiveboard component for pages, a StandaloneLiveboard for Liveboards shown inside the analytics modal, a custom actions API with a shared analytics modal, and the useThoughtSpotAuth hook. Pages only need a Liveboard's ThoughtSpot obj_id (liveboardObjId) to get a fully managed embed with loading skeleton, error state and Sentry reporting. Every AnalyticsLiveboard comes with saved views, stored under the viewKey the page gives it. The filter options (defaultFilters, syncFiltersWithHostUrl, onFilterStateChange) belong to StandaloneLiveboard: AnalyticsLiveboard uses them internally to drive its views.
@app/advanced-analytics is the first consumer: three flag-gated routes that exercise the embed end to end. See Host app. To add the package to a new app, follow Building a new app.
Quick start
1. Wrap routes with the provider
import { AdvancedAnalyticsProvider } from '@shared/advanced-analytics'
<AdvancedAnalyticsProvider>
<Switch>
<Route path="/my-analytics" component={MyPage} />
</Switch>
</AdvancedAnalyticsProvider>
It is the only provider the package needs: ThoughtSpot auth plus the shared analytics modal.
2. Drop in the Liveboard
import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
} from '@shared/advanced-analytics'
export function MyPage() {
return (
<AnalyticsLiveboard
viewKey="<melody-view-key>"
liveboardObjId="<liveboard-obj-id>"
visibleActions={GENERAL_VISIBLE_ACTIONS}
/>
)
}
That's it. viewKey is the Melody view the page's saved views are stored under. obj_id resolution, auth lifecycle, loading skeleton, error state, locale resolution, Sentry reporting and the "Drill Down" action are all handled internally.
Two components
| Component | Use it for | Saved views | Filter options |
|---|---|---|---|
AnalyticsLiveboard | The Liveboard of a page. viewKey is required | Always: picker, Save / Reset, sharing, default view | Not accepted: defaultFilters, syncFiltersWithHostUrl, filtersUrlParam, onFilterStateChange and embedSize are owned by the views layer |
StandaloneLiveboard | A Liveboard shown in the analytics modal (what createOpenLiveboardAction renders) | None | All of them: defaultFilters, syncFiltersWithHostUrl, filtersUrlParam, onFilterStateChange, embedSize |
AnalyticsLiveboard is StandaloneLiveboard plus the views layer, so everything below that is about the embed itself (props, obj_id resolution, errors, custom actions, filter sync) applies to both. The props table lists the union; the rows that AnalyticsLiveboard does not accept are marked.
Building a new app
A step-by-step guide for a new consumer of the package. The examples use a hypothetical @app/my-analytics app with a pipeline page and an accounts page.
What you need
| Need | Provided by |
|---|---|
| ThoughtSpot auth, the shared analytics modal | AdvancedAnalyticsProvider (this package), added once |
React Router (useHistory, used by ctx.navigate) and a TanStack QueryClient | The rhapsody shell — every app route already renders inside them |
window.ENV.THOUGHTSPOT_URL | The server env (see Environment variable) |
Add the package to the app's package.json:
"dependencies": {
"@shared/advanced-analytics": "workspace:*"
}
Suggested structure
apps/my-analytics/src/
├── liveboards.ts ← obj_ids (and default filters) of the app's Liveboards
├── customActions.tsx ← CUSTOM_ACTIONS: the actions the app's pages pick from
└── pages/
├── MyAnalytics.tsx ← AdvancedAnalyticsProvider + routes
├── PipelinePage.tsx
├── AccountsPage.tsx
└── messages.ts
1. Add the provider once
AdvancedAnalyticsProvider is the only provider an app needs. It initializes ThoughtSpot auth and renders one shared analytics modal for everything below it. Place it around the routes, not inside each page:
// pages/MyAnalytics.tsx
import { Route, Switch } from 'react-router-dom'
import { AdvancedAnalyticsProvider } from '@shared/advanced-analytics'
import { PipelinePage } from './PipelinePage.tsx'
import { AccountsPage } from './AccountsPage.tsx'
export const MyAnalytics = () => (
<AdvancedAnalyticsProvider>
<Switch>
<Route exact path="/my-analytics/pipeline" component={PipelinePage} />
<Route exact path="/my-analytics/accounts" component={AccountsPage} />
</Switch>
</AdvancedAnalyticsProvider>
)
2. Declare the Liveboards
Reference Liveboards by obj_id only (see Referencing Liveboards by obj_id). Default filters that more than one place needs — the page and the actions that navigate to it — live next to them:
// liveboards.ts
import type { LiveboardFilter } from '@shared/advanced-analytics'
export const LIVEBOARDS = {
pipeline: { liveboardObjId: 'PipelineOverview-1234abcd' },
accounts: { liveboardObjId: 'AccountDetail-5678efgh' },
} as const
export const ACCOUNTS_DEFAULT_FILTERS: LiveboardFilter[] = [
{ column: 'Industry', oper: 'IN', values: ['CPG'] },
]
3. Declare the app's custom actions
Keep one dictionary per app. Each entry is a LiveboardCustomAction; satisfies checks every entry while keeping the keys typed. Use a .tsx file when an action renders components. The three ways to build one, from least to most control:
// customActions.tsx
import {
RecordsTable,
toRuntimeFilters,
toLiveboardFilters,
defineCustomAction,
resolveRecordsQuery,
createNavigateAction,
mergeLiveboardFilters,
recordsDrilldownAction,
createOpenLiveboardAction,
type DrilldownClick,
type LiveboardTarget,
type LiveboardCustomAction,
} from '@shared/advanced-analytics'
import { LIVEBOARDS, ACCOUNTS_DEFAULT_FILTERS } from './liveboards.ts'
import { messages } from './pages/messages.ts'
import { AccountSummary } from './components/AccountSummary.tsx'
/** Clicked column → column of the accounts Liveboard. Only these are forwarded. */
const ACCOUNT_COLUMNS = { 'Account Name': 'Account' }
/**
* What to open for each pipeline visualization. Targets come from this list,
* never from clicked values, so a click can only open what is declared here.
*/
const ACCOUNT_TARGETS: Record<string, (click: DrilldownClick) => LiveboardTarget> = {
'viz-pipeline-by-account': (click) => ({
title: messages.accountDetail,
liveboardObjId: LIVEBOARDS.accounts.liveboardObjId,
runtimeFilters: toRuntimeFilters(click, { columns: ACCOUNT_COLUMNS }),
}),
'viz-deals-by-stage': (click) => ({
title: messages.stageDeals,
liveboardObjId: LIVEBOARDS.accounts.liveboardObjId,
vizId: 'viz-deals-table',
defaultFilters: toLiveboardFilters(click, { columns: { Stage: 'Deal Stage' } }),
}),
}
export const CUSTOM_ACTIONS = {
/** 1. A built-in action, as is. */
records: recordsDrilldownAction,
/** 2a. Factory: open a Liveboard or viz in the modal, picked per click. */
openAccount: createOpenLiveboardAction({
id: 'open-account',
label: () => messages.openAccountLabel,
scope: { metadataIds: { vizIds: Object.keys(ACCOUNT_TARGETS) } },
resolveTarget: (click) => ACCOUNT_TARGETS[click.vizId ?? '']?.(click) ?? null,
}),
/** 2b. Factory: go to another page, which opens its Liveboard filtered. */
goToAccounts: createNavigateAction({
id: 'go-to-accounts',
label: () => messages.goToAccountsLabel,
resolveRoute: (click) => ({
pathname: '/my-analytics/accounts',
liveboardFilters: mergeLiveboardFilters(
ACCOUNTS_DEFAULT_FILTERS,
toLiveboardFilters(click, { columns: ACCOUNT_COLUMNS })
),
}),
}),
/** 3a. Custom action: render one of the app's components in the modal. */
accountSummary: defineCustomAction({
id: 'account-summary',
label: () => messages.accountSummaryLabel,
run: (click, { modal, refuse }) => {
const account = click.attributes.find(({ name }) => name === 'Account Name')
if (typeof account?.value !== 'string') {
refuse('NO_ACCOUNT', click.vizId)
return
}
modal.open({
title: account.value,
content: <AccountSummary accountName={account.value} />,
})
},
}),
/** 3b. Custom action reusing the records pipeline with its own title. */
dealRecords: defineCustomAction({
id: 'deal-records',
label: () => messages.dealRecordsLabel,
run: async (click, context) => {
const query = await resolveRecordsQuery(click, context)
if (!query) return
context.modal.open({
title: messages.dealRecordsTitle,
content: <RecordsTable query={query} />,
})
},
}),
} as const satisfies Record<string, LiveboardCustomAction>
Labels are getters on the app's messages, like any other copy:
// pages/messages.ts
export const messages = {
get openAccountLabel() {
return intl.formatMessage({
defaultMessage: 'Open account',
description: 'Chart context menu entry that opens the account Liveboard',
})
},
// …
}
4. Pick the actions per page
Each AnalyticsLiveboard offers exactly the actions it receives. Declare the list outside the component, so it keeps its identity across renders:
// pages/PipelinePage.tsx
import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
} from '@shared/advanced-analytics'
import { LIVEBOARDS } from '../liveboards.ts'
import { CUSTOM_ACTIONS } from '../customActions.tsx'
const PIPELINE_ACTIONS = [
CUSTOM_ACTIONS.openAccount,
CUSTOM_ACTIONS.goToAccounts,
CUSTOM_ACTIONS.accountSummary,
]
export function PipelinePage() {
return (
<AnalyticsLiveboard
viewKey={LIVEBOARDS.pipeline.viewKey}
liveboardObjId={LIVEBOARDS.pipeline.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={PIPELINE_ACTIONS}
/>
)
}
// pages/AccountsPage.tsx
const ACCOUNTS_ACTIONS = [CUSTOM_ACTIONS.records, CUSTOM_ACTIONS.dealRecords]
export function AccountsPage() {
return (
<AnalyticsLiveboard
viewKey={LIVEBOARDS.accounts.viewKey}
standardViews={ACCOUNTS_STANDARD_VIEWS}
liveboardObjId={LIVEBOARDS.accounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={ACCOUNTS_ACTIONS}
/>
)
}
AccountsPage opens with the filters of its first view from Salesloft (ACCOUNTS_STANDARD_VIEWS, a list of { name, filters: ACCOUNTS_DEFAULT_FILTERS }). Filters that goToAccounts puts in the URL are not applied by a page with saved views: see Known limitations. Omit customActions to offer only the two "Drill Down" actions, recordsDrilldownAction and kpiDrillAction (DEFAULT_CUSTOM_ACTIONS), or pass [] to offer none.
5. Open the modal from your own UI
useAnalyticsModal() works anywhere below AdvancedAnalyticsProvider, not only inside custom actions:
import {
StandaloneLiveboard,
useAnalyticsModal,
} from '@shared/advanced-analytics'
export function ForecastButton() {
const modal = useAnalyticsModal()
return (
<Button
onClick={() =>
modal.open({
title: messages.forecast,
content: (
<StandaloneLiveboard
embedSize="modal"
liveboardObjId={LIVEBOARDS.forecast.liveboardObjId}
/>
),
})
}
>
{messages.openForecast}
</Button>
)
}
Checklist for custom actions
- Unique
idper Liveboard. A repeated id is reported, and only the first action with it runs. labelis a function (() => messages.x), so it follows the user's locale.- Refuse instead of failing silently. Call
refuse(reason, detail)when a click can't be handled. Errors thrown byrunare reported for you and never reach the Liveboard. - Targets and routes come from code. Pick
liveboardObjIds and routes from lists you declare (LIVEBOARDS, a map byvizId), never from clicked values.ctx.navigaterefuses anything that is not an in-app path. - Forward only columns the target has. Pass
columnstotoRuntimeFilters/toLiveboardFilterswhen the target names its columns differently or shares only some of them. - Keep the destination's defaults when navigating. Filters in the URL replace a board's
defaultFilters, so merge them back withmergeLiveboardFilters(defaults, clicked). - Use
scope(metadataIds.vizIds,dataModelIds…) so ThoughtSpot only shows the action where it applies.
Architecture
Browser (rhapsody)
│
│ GET /v2/thoughtspot/embed_token
│ (Bearer: Salesloft user token)
▼
analytics-api
│ Resolves user + tenant from server-side context only
│ POST /api/rest/2.0/auth/token/custom → ThoughtSpot
▼
ThoughtSpot
│ Short-lived token (expires_in seconds)
▼
analytics-api → rhapsody
│
│ Visual Embed SDK initializes iframe
▼
ThoughtSpot (embedded iframe)
│ Authorized query → Cube (tenant-isolated via ts_var RLS)
▼
Embedded result in rhapsody
Key security properties:
- The ThoughtSpot Trusted Auth secret never leaves
analytics-api - User identity and tenant are resolved server-side only — no identity data is trusted from the browser
- Row-level security (ABAC via
ts_var()) enforces tenant isolation at the data layer - Tokens are short-lived (
expires_inseconds); the SDK refreshes them automatically viagetAuthToken
Package structure
shared/advanced-analytics/
├── package.json
├── docs/
│ ├── load-performance.md ← load analysis: strategies tried, evidence, what was adopted and why
│ └── saved-views-backend-changes.md ← changes requested to Melody's saved_list_views
└── src/
├── index.ts ← public API
├── types.ts
├── constants.ts
├── services/ ← one folder per service: [name].ts, [name].spec.ts, types.ts, constants.ts
│ ├── thoughtspotAuthService/ ← fetchThoughtSpotToken: Trusted Auth token from analytics-api
│ └── liveboardService/ ← fetchLiveboardId (GUID alone) and fetchLiveboard (with details): obj_id via ThoughtSpot metadata/search
├── context/
│ ├── AdvancedAnalyticsProvider/ ← the one provider apps add: auth + analytics modal
│ └── ThoughtSpotAuthContext/
│ ├── ThoughtSpotAuthContext.tsx
│ ├── constants.ts ← THOUGHTSPOT_AUTH_STATUS
│ └── types.ts
├── hooks/ ← one folder per hook: [name].ts, [name].spec.ts(x)
│ ├── useLiveboardViewBridge/ ← saved views store ⇄ Liveboard: baseline, isModified, Reset
│ ├── useHostParamsUrlSync/ ← date range and user / group / account selections: saved views store ⇄ URL
│ ├── useDateRangeFilter/ ← the open view's range as a DateFilterConfig, waiting for the fiscal calendar
│ ├── useDateRuntimeFilters/ ← the range as a runtime filter: on open by prop, later by UpdateRuntimeFilters
│ ├── useThoughtSpotAuth/
│ ├── useLiveboardFilterUrlSync/ ← filter ⇄ URL sync (see below)
│ ├── useLiveboardQuery/ ← GUID and details queries, waitForDetails, loadDetails, localStorage first answer
│ ├── usePrefetchLiveboard/ ← starts the Liveboard above the saved views gate
│ ├── useLiveboardStatus/ ← embed lifecycle (loading → ready | error), reset per Liveboard
│ ├── useLiveboardCustomActions/ ← custom actions → SDK definitions + click dispatch
│ ├── useAnalyticsModal/ ← open/back/close the shared analytics modal
│ └── useEventCallback/ ← stable callback identity helper
├── customActions/ ← custom actions API and the built-in actions
│ ├── types.ts ← LiveboardCustomAction, CustomActionContext, AppRoute
│ ├── defineCustomAction.ts
│ ├── defaultCustomActions.ts ← DEFAULT_CUSTOM_ACTIONS
│ ├── needsLiveboardDetails.ts ← whether the embed config depends on the Liveboard's details
│ ├── recordsDrilldownAction.tsx ← "Drill Down" + resolveRecordsQuery
│ ├── kpiDrillAction.tsx ← "Drill Down" + resolveKpiDrillQuery
│ ├── createOpenLiveboardAction.tsx
│ └── createNavigateAction.ts
├── utils/
│ ├── drilldown/
│ │ ├── drilldown.ts ← payload → DrilldownClick; click → Cube members → records query
│ │ ├── constants.ts ← model → view map, failure reasons
│ │ └── types.ts ← ThoughtSpot custom action payload, DrilldownClick, Cube meta types
│ ├── liveboardCustomizations/ ← the look every page Liveboard shares (Ask button, CSS rules, theme variables) and mergeCustomizations
│ ├── savedViews/ ← buildStandardViews, keepUserViewsInTheirSection, setFiltersParam, typed @shared/saved-views
│ ├── tableUtils/ ← Cube annotation → RecordsTable columns: link, id and drill cells, NewTabLink
│ ├── clickFilters/ ← click → runtime filters / Liveboard filters
│ ├── dateFilter/ ← DEFAULT_DATE_RANGE, resolveDateRange (epoch seconds), toDateRuntimeFilters
│ ├── hostParams/ ← host params ⇄ URL: read, write, signature, apply to a view
│ ├── liveboardCache/ ← the resolved Liveboard in localStorage, per cluster and user
│ ├── navigation/ ← in-app route validation for ctx.navigate
│ ├── embedError/
│ │ └── embedError.ts ← toEmbedErrorReport: allowlisted error view
│ └── filterSync/
│ ├── filterSync.ts ← URL (de)serialization, validation, SDK payload mappers
│ ├── constants.ts ← operators, limits, applicability levels
│ └── types.ts ← hook options/result, UpdateFilters payload
└── components/
├── AnalyticsModal/ ← AnalyticsModalProvider: one modal, stacked frames, error boundary
├── LiveboardToolbar/ ← the row above every page Liveboard that holds the host's actions (Box); only the views picker for now
├── RecordsTable/ ← paginated Cube records, modal content of "Drill Down"
├── ClickedRecords/ ← the clicked point's attributes card above its records, when the measure declares no summary
├── SummaryDrilldown/ ← meta.drilldownSummary cards above the records of "Drill Down"
├── SavedViewsControls/ ← the views picker + Save / Reset, placed in the toolbar
├── DateRangeFilter/ ← the shared DateFilter, backed by the saved views store
├── AnalyticsLiveboard/ ← the page Liveboard: saved views provider, toolbar, view bridge, around StandaloneLiveboard
│ ├── AnalyticsLiveboard.tsx
│ ├── AnalyticsLiveboard.spec.tsx
│ ├── constants.ts ← SAVED_VIEWS_FRAME_PARAMS
│ ├── messages.tsx
│ └── types.ts
└── StandaloneLiveboard/ ← the embed without views (modals); what AnalyticsLiveboard wraps
├── StandaloneLiveboard.tsx
├── StandaloneLiveboard.spec.tsx
├── components.tsx ← DefaultLoadingSkeleton, DefaultErrorState (`reason`: error | maintenance)
├── components.spec.tsx
├── constants.ts ← SENTRY_TAGS, ERROR_STATE_REASON
├── styles.tsx
├── messages.tsx ← loading, error and maintenance copy (translatable)
└── types.ts
The app that consumes it:
apps/advanced-analytics/
├── entry.ts ← routes, nav items, shortcuts, feature flag
└── src/
├── bridge-loaders/routesBridge.tsx
├── liveboards.ts ← LIVEBOARDS: per Liveboard, everything its tab needs (view key, date column, Display By); PAGES: the tabs of each page
├── types.ts ← LiveboardConfig, PageConfig
├── utils/displayByTables/ ← the Display By tables a Liveboard can offer, by id
├── components/
│ ├── TabbedLiveboardPage/ ← route, tab in the path, header and Spotter panel of a page
│ │ └── components/PageHeader/
│ ├── LiveboardTab/ ← the body of a tab: Display By tables or the Liveboard whole
│ │ └── components/ ← DisplayByTab, DisplayByLiveboard, LiveboardEmbed
│ └── DisplayByPicker/
├── hooks/ ← useDisplayBy, useSpotterPanel
└── pages/
├── AdvancedAnalytics/ ← flag guard + auth provider + <Switch>
├── ProspectingAccounts/ ← ProspectingResults: a title and PAGES.prospectingResults
└── TeamProductivity/ ← a title and PAGES.teamProductivity
Public API
Everything exported from @shared/advanced-analytics:
| Export | Kind | Description |
|---|---|---|
AnalyticsLiveboard | Component | The Liveboard of a page: embed with auth lifecycle, skeleton, error UI, Sentry and saved views (viewKey) |
StandaloneLiveboard | Component | The same embed without saved views, for Liveboards shown in the analytics modal |
AdvancedAnalyticsProvider | Component | The one provider apps add: ThoughtSpot auth (SDK init, token rotation) and the shared analytics modal |
useThoughtSpotAuth | Hook | Returns { status, isReady, error, locale } for custom auth handling |
GENERAL_VISIBLE_ACTIONS | Constant | Curated read-only action preset for visibleActions |
THOUGHTSPOT_HOST | Constant | ThoughtSpot host URL from window.ENV.THOUGHTSPOT_URL |
DEFAULT_HOST_URL_FILTERS_PARAM | Constant | Default query param (tsFilters) used by syncFiltersWithHostUrl |
Action | Re-export | ThoughtSpot SDK Action enum |
defineCustomAction | Function | Types a LiveboardCustomAction declaration |
recordsDrilldownAction | Action | "Drill Down": records behind the clicked point in the modal |
kpiDrillAction | Action | "Drill Down": records behind a KPI visualization in the modal |
DEFAULT_CUSTOM_ACTIONS | Constant | Actions of a Liveboard without customActions ([recordsDrilldownAction, kpiDrillAction]) |
createOpenLiveboardAction | Function | Action that opens a Liveboard or viz, picked per click, in the modal |
createNavigateAction | Function | Action that navigates to an in-app route, optionally with Liveboard filters |
resolveRecordsQuery | Function | Click → Cube records query (or refusal), for custom records actions |
resolveKpiDrillQuery | Function | KPI click → Cube query from meta.drilldown (or refusal) |
toRuntimeFilters | Function | Click → RuntimeFilter[] for another Liveboard or viz |
toLiveboardFilters | Function | Click → Liveboard filters for ctx.navigate or a target's defaultFilters |
mergeLiveboardFilters | Function | Base filters with overrides added, replacing filters on the same column |
useAnalyticsModal | Hook | { open, back, close } of the shared analytics modal |
RecordsTable | Component | Paginated table of a Cube query's rows |
AnalyticsLiveboardProps | Type | Props accepted by AnalyticsLiveboard |
StandaloneLiveboardProps | Type | Props accepted by StandaloneLiveboard |
StandardViewDefinition | Type | One view from Salesloft: { name, filters } |
LiveboardFilter | Type | One Liveboard filter, as accepted by defaultFilters and stored in the URL |
EmbedSize | Type | 'page' \| 'modal' |
FilterStateChange, FilterStateSource, PersistedFilterState | Types | What onFilterStateChange reports: { state, source }, the source (initial, navigation, user), and the { liveboardFilters } state |
LiveboardCustomAction, CustomActionContext, CustomActionScope, AppRoute, DrilldownClick, ClickedColumn, AnalyticsModalApi, AnalyticsModalFrame, LiveboardTarget, OpenLiveboardActionOptions, NavigateActionOptions, ClickFilterOptions | Types | Custom actions API |
ThoughtSpotToken | Type | Token response shape from analytics-api |
Host app: @app/advanced-analytics
The package is consumed today by a single app whose only purpose is to exercise the embed in a real rhapsody shell. AdvancedAnalytics.tsx wraps its routes in AdvancedAnalyticsProvider, and each route is a page that renders AnalyticsLiveboard against its own Liveboards.
Routes
Registered in apps/advanced-analytics/entry.ts via registerRoutes, all resolved through one bridge loader (src/bridge-loaders/routesBridge.tsx → AdvancedAnalytics).
| Route | Page | Notes |
|---|---|---|
/advanced-analytics/prospecting-results/:tab | ProspectingResults | One tab per Liveboard: accounts, team-cadences, calls, emails, meetings |
/advanced-analytics/team-productivity/:tab | TeamProductivity | One tab per Liveboard: team-performance, process, call-sentiments, call-dispositions |
Everything a tab needs is declared in LIVEBOARDS (src/liveboards.ts), next to its obj_id and viewKey: its tabPath, the tabLabelKey of its tab text (also the name Spotter is told for the Liveboard) and, optionally, its displayBy (the query parameter and the table ids from DISPLAY_BY_TABLES). PAGES lists the tabs of each page. A page file only gives TabbedLiveboardPage its title and its PAGES entry.
The selected tab is part of the path, as on Analytics Hub (/analytics-hub/prospecting/calls), so a copied URL opens the same tab with its filters (tsFilters) and Display By parameter. Each tab path is registered in entry.ts, because registerRoutes takes exact paths; the page's <Route> uses :tab?.
| URL | What happens |
|---|---|
/advanced-analytics/prospecting-results | Redirects to /accounts, keeping the query string |
/advanced-analytics/team-productivity | Redirects to /team-performance, keeping the query string |
| An unknown tab | Same redirect as no tab |
| Clicking a tab | history.push to the tab's path without the query string: filters and Display By belong to the Liveboard being left, as with Analytics Hub's tab links |
All pages pass GENERAL_VISIBLE_ACTIONS and the viewKey of their Liveboard (LIVEBOARDS.<name>.viewKey in src/liveboards.ts), so they all have saved views, and each one builds its own custom actions (DEFAULT_CUSTOM_ACTIONS plus its own). See Custom actions.
Feature flag
Everything is gated behind advanced_analytics:
entry.ts— eachregisterGlobalNavMenuItemdeclaresshouldShow: ({ hasFeatureFlag }) => hasFeatureFlag('advanced_analytics'), so the nav entries and their shortcuts stay hidden without it.AdvancedAnalytics.tsx— re-checks withhasFeatureFlag('advanced_analytics')from@rhapsody/feature-flagsand returns<Redirect to="/" />when absent, so direct URL access is blocked too.
Page shape
Every page is a few lines — the package handles auth, loading, and errors:
import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
DEFAULT_CUSTOM_ACTIONS,
} from '@shared/advanced-analytics'
import { LIVEBOARDS } from '../../liveboards.ts'
export function MyPage() {
return (
<AnalyticsLiveboard
viewKey={LIVEBOARDS.prospectingAccounts.viewKey}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={DEFAULT_CUSTOM_ACTIONS}
/>
)
}
AnalyticsLiveboard
The single component for embedding Liveboards. Wraps the SDK's LiveboardEmbed directly and manages the full lifecycle.
Auth lifecycle
| Auth status | Renders |
|---|---|
idle / loading | Pulsing dashboard skeleton (DefaultLoadingSkeleton) |
error | DefaultErrorState (Starlight EmptyState + DisconnectedImage) + captureException to Sentry |
ready | LiveboardEmbed with skeleton overlaid until onLiveboardRendered fires |
Embed lifecycle (after auth is ready)
Internally tracked by useLiveboardStatus, reset whenever the resolved Liveboard changes:
| Status | Meaning | Renders |
|---|---|---|
loading | Mounted, no render confirmed yet | LiveboardEmbed + skeleton overlay |
ready | onLiveboardRendered fired | LiveboardEmbed, no overlay |
error | An error arrived before any render | DefaultErrorState |
Post-render errors are safe: an error that arrives after a successful render is reported to Sentry (with hasRendered: true) but does not replace the board with an error state. This is deliberate — the SDK reports single-visualization failures through the same onError channel.
Locale resolution
The locale prop takes priority when provided. Otherwise the locale resolved by AdvancedAnalyticsProvider (via @shared/user-locale) is used automatically.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
liveboardObjId | string | ✅ | ThoughtSpot obj_id of the Liveboard, stable across environments. Resolved to the GUID first. See Referencing Liveboards by obj_id |
locale | string | BCP 47 locale. Falls back to auth context locale | |
activeTabId | string | GUID of the tab the Liveboard opens on | |
runtimeFilters | RuntimeFilter[] | UI context filters at query time (not authorization — ts_var() RLS is the isolation layer). Owned by the host app: passed to the embed as-is and never written to the URL | |
syncFiltersWithHostUrl | boolean | StandaloneLiveboard only. Keeps the Liveboard filter state in sync with the host URL. See Filter sync with the host URL. Defaults to false | |
filtersUrlParam | string | StandaloneLiveboard only. Query param that holds the filter state. Give each synced board its own when several share a page. Defaults to DEFAULT_HOST_URL_FILTERS_PARAM | |
defaultFilters | LiveboardFilter[] | StandaloneLiveboard only. Liveboard filters (editable chips) the board opens with. See Default filters | |
visibleActions | Action[] | Allowlist of actions. Mutually exclusive with hiddenActions | |
loadingFallback | ReactNode | Replaces the default skeleton while auth, the obj_id resolution or the embed is loading | |
errorFallback | ReactNode | Replaces DefaultErrorState on auth failure, or a pre-render embed error | |
onLoad | () => void | Called when the embed iframe loads | |
onLiveboardRendered | () => void | Called when the Liveboard content renders. URL or default filters may still be pending at that point; they are applied once the embed subscribes to UpdateFilters | |
onError | (error: unknown) => void | Called on every embed error. Sentry reporting always fires independently | |
onFilterChanged | (payload: MessagePayload) => void | Called on every filter change with the raw SDK payload. Runs alongside the URL sync, never instead of it | |
onFilterStateChange | (change: FilterStateChange) => void | StandaloneLiveboard only. Called with each filter state read from the board and where it came from (initial, navigation or user). Only runs with syncFiltersWithHostUrl. See Reported filter state | |
dateColumn | string | AnalyticsLiveboard only: name of the Liveboard's date column. Shows the Analytics Hub date picker in the toolbar and keeps the Liveboard to the range with a runtime filter on it. See Date range filter | |
viewKey | string | ✅ (AnalyticsLiveboard) | The Melody view the page's saved views are stored under. Not accepted by StandaloneLiveboard |
standardViews | { name, filters }[] | AnalyticsLiveboard only: the "Views from Salesloft"; the first opens by default. Defaults to one "Default" view with no filters | |
reportName | string | AnalyticsLiveboard only: subject of the sentence under "set as default"; defaults to "This page" | |
customActions | LiveboardCustomAction[] | Custom actions of this Liveboard; their ids are added to visibleActions. Defaults to DEFAULT_CUSTOM_ACTIONS, [] for none. See Custom actions | |
embedSize | 'page' \| 'modal' | StandaloneLiveboard only (AnalyticsLiveboard is always page). page (default) fills the page and grows with the Liveboard; modal keeps a fixed height for a modal frame |
All other LiveboardViewConfig props from the SDK are forwarded via ...rest, on top of LIVEBOARD_EMBED_PROPS[embedSize] (for page: full height, Liveboard title visible, iframe sized to the viewport minus the navbar).
Look and feel (customizations)
A page Liveboard comes with the Salesloft look: ThoughtSpot theme variables (--ts-var-*, resolved from the design tokens, as the iframe cannot see them), CSS rule overrides (tooltips, table cell alignment, the header's chips) and the Ask button of the Spotter action, styled through ASK_SPOTTER_ACTION_ID. They live in src/utils/liveboardCustomizations/. A modal Liveboard gets none.
The customizations prop complements them: it is merged key by key with the defaults (mergeCustomizations), so passing one theme variable or one CSS rule keeps all the others, and a value you pass wins over the default. Arrays replace the default whole; an undefined value is ignored.
<AnalyticsLiveboard
customizations={{
style: {
customCSS: {
variables: { '--ts-var-chip-background': 'transparent' },
rules_UNSTABLE: { '.my-class': { 'margin-top': '8px' } },
},
},
}}
/>
The defaults are built once per mount, so a theme change while the page is open shows after the next load. --ts-var-button-padding (3px and Space 200) was part of the package's previous default but the app's own customizations replaced that default, so it never applied there and is not part of the new one.
GUIDs are not accepted: liveboardId and its legacy alias pinboardId are left out of the props on purpose, so every page references Liveboards by obj_id and no GUID ties the code to one environment.
Referencing Liveboards by obj_id
A Liveboard GUID changes between environments when content is promoted with TML, so hardcoding it ties the code to one environment. Instead, pass the Liveboard's ThoughtSpot obj_id, which travels with the TML:
<AnalyticsLiveboard viewKey="advanced_analytics_conversations_insights" liveboardObjId="ConversationsInsightsRLS-d5f75047" />
The Visual Embed SDK only embeds by GUID, so AnalyticsLiveboard resolves the obj_id first:
It waits for ThoughtSpot auth (
AdvancedAnalyticsProvider) to beready, so the embed's own login happens first.useLiveboardQueryruns two TanStack Query queries at once:fetchLiveboardId(key['advancedAnalytics.liveboardId', objId], the GUID alone) andfetchLiveboard(key['advancedAnalytics.liveboard', objId], the GUID plus the details). The embed waits for the first unless its custom actions need the details (see Load performance). Both:get a Trusted Auth token with
fetchThoughtSpotToken;calls ThoughtSpot through
thoughtSpotRestApi(@shared/requests,{THOUGHTSPOT_URL}/api/rest/2.0) as that user, so only Liveboards the user can access are found:POST {THOUGHTSPOT_URL}/api/rest/2.0/metadata/search
Authorization: Bearer <token>
{ "metadata": [{ "identifier": "<obj_id>" }] }return the
metadata_id(the GUID) of the entry whosemetadata_obj_idis the requestedobj_id. ThoughtSpot also matchesidentifieragainst GUIDs and names, so other entries are ignored.fetchLiveboardalso sends"include_details": true, which makesmetadata_detailnon-null and is where the model views, column names and KPI viz ids come from.
While auth or the query is pending, the loading state shows (
loadingFallbackor the default skeleton).On success, the embed renders with the resolved GUID. Everything downstream (status, filter sync, Sentry) works with the GUID as before, and Sentry reports also include the
obj_id.On failure (auth, request, or no matching Liveboard), the error state shows (
errorFallbackorDefaultErrorState) and the error is reported to Sentry once, tagged withcomponentand withliveboardObjIdinextra.
The answers are cached by the shared QueryClient for the session, and kept in localStorage for the next one (see Load performance): the embed starts from what a previous visit stored and asks ThoughtSpot again in the background once it is older than 5 minutes. The obj_id is not validated upfront: an unknown one fails the query and shows the error state.
The ThoughtSpot cluster must allow this app's origins (every environment, including
localhost.qasalesloft.com) in its CORS settings, because the browser calls the REST API directly.
obj_ids are unique per ThoughtSpot Org and are kept when a Liveboard is promoted, while its GUID changes. They can be changed with POST /api/rest/2.0/metadata/update-obj-id, which would break the reference in code.
Load performance
A page with an AnalyticsLiveboard used to show its Liveboard after a serial chain: saved views, /v2/users, a ThoughtSpot token, a metadata/search that asks for the Liveboard's details (up to two seconds), and only then the iframe. The full analysis, the strategies that were tried and why each one was adopted or not, and every measurement are in docs/load-performance.md. What the code does today:
- The Liveboard is resolved above the saved views gate.
AnalyticsLiveboardcallsusePrefetchLiveboardbeforeSavedViewsProvider, which renders nothing until its views are loaded. The Liveboard starts once ThoughtSpot auth is ready, andStandaloneLiveboardfinds the answers in the query cache or joins the requests in flight. - The embed waits for the GUID, not for the details.
fetchLiveboardIdasksmetadata/searchwithoutinclude_details(about 2 KB and 0.2 s, against 142 to 910 KB and 0.4 to 2.0 s).fetchLiveboardruns at the same time, and the embed only waits for it when an action derives its scope fromkpiVizIds(needsLiveboardDetails) or when a saved view selects accounts (AnalyticsLiveboardpasseswaitForDetails, since the account filter only applies when the Liveboard'scolumnsincludeACCOUNT_FILTER_COLUMN), because that scope and that filter are part of the embed config and a config that changes after the load reloads the iframe.modelViewsandcolumnNamesare read when an action is clicked, throughloadDetails(), which resolves at once with what is known. - A resolved Liveboard is kept in
localStorage(liveboardCache), per cluster and user, and used as the first answer. It is asked again in the background once it is older than 5 minutes (LIVEBOARD_STALE_TIME_MS) and ignored, and deleted when read, after 24 hours or when its shape is not the expected one. Sign-out does not clear it: the key holds the user id, so another user never reads it. BumpLIVEBOARD_CACHE_VERSIONwhen the stored shape changes. A stored GUID that is no longer valid fails the embed until the background request answers, and the status resets when the GUID changes.
Measured in one dev bundle against the QA 17 cluster, with the code before and after swapped between runs (3 runs each, median of the seconds until LiveboardRendered), the first visit of a page inside the app went from 3.4 to 2.4 s on simple, 3.7 to 2.4 s on tabs and 4.6 to 3.2 s on filters, and the prospecting page, which waits for the details, from 5.4 to 2.0 s when a stored copy exists. A full page load did not improve measurably. The numbers, their spread and their limits are in the document.
Filter sync with the host URL
With syncFiltersWithHostUrl, AnalyticsLiveboard uses useLiveboardFilterUrlSync to keep the Liveboard filters and the URL in both directions. Users can share, bookmark, and use back/forward on a filtered view.
Only Liveboard filters are synced: the filter bar users edit. Runtime filters are not. They belong to the host app, which passes them through the runtimeFilters prop, so the prop stays their single source of truth. If a page needs its runtime filters in a shareable link, it keeps them in its own URL state and passes them through the prop.
Runtime filters also stay out of the ThoughtSpot iframe URL (the SDK default, excludeRuntimeFiltersfromURL: true). They travel in the embed payload instead, which avoids the iframe URL limits (up to 50 URL filters, and an error past 2000 characters) and allows up to 1000 filters. See Runtime filters.
URL format
The state is stored as JSON in the tsFilters query param (DEFAULT_HOST_URL_FILTERS_PARAM). The param is removed when there are no filters, except on boards with default filters, which keep an explicit empty state. Other query params are preserved.
?tsFilters={"liveboardFilters":[...]}
| Key | Shape | Applied via |
|---|---|---|
liveboardFilters | { column, oper, values, type?, datePeriod?, includeCurrentPeriod?, applicability? }[] | HostEvent.UpdateFilters (visible filter chips, dates, ranges) |
Any other key is ignored, including runtimeFilters in links created before runtime filters stopped being persisted.
The URL comes from shared links and GetFilters from a cross-origin iframe, so both are treated as untrusted and validated on every read:
- A param longer than
MAX_URL_STATE_LENGTH(8,000 characters, decoded), or one that is not valid JSON, is ignored. - At most
MAX_LIVEBOARD_FILTERS(50) filters are kept. ThoughtSpot documents no limit forUpdateFilters, so this one is ours. - Unknown keys are stripped. Fields with the wrong type are dropped (strings must be non-empty strings,
includeCurrentPerioda boolean). valuesmust contain only strings, finite numbers, or booleans.applicability.levelmust beLIVEBOARD,TAB, orGROUP.
Date filters are persisted only when their value can be sent back to UpdateFilters:
| Type | Persisted values |
|---|---|
YESTERDAY, TODAY, TOMORROW, THIS_PERIOD, PERIOD_TO_DATE | [""] (the documented format; the period travels in datePeriod and the operator, e.g. GE for "on or after", in oper) |
EXACT_DATE | [epoch], taken from dateFilter.epoch |
EXACT_DATE_RANGE | [lowEpoch, highEpoch] |
LAST_N_PERIOD, NEXT_N_PERIOD | [count] |
Epochs are kept exactly as ThoughtSpot returns them (seconds, as strings), so no timezone conversion happens. Any other type, or a filter missing its value, is not persisted and never sent: the chip keeps the value saved in the Liveboard instead of receiving an empty date filter. For the same reason, an EXACT_DATE with values: [""] from older links is skipped when applying the URL.
The length limit also applies on write. If the embed's state would serialize to more than MAX_URL_STATE_LENGTH, the URL keeps the last state that fit and a warning is logged, instead of writing a state that would be dropped on reload.
With syncFiltersWithHostUrl off, the hook never reads or writes the URL. Use filtersUrlParam to give each board its own param when several synced boards share a page.
Default filters
defaultFilters declares the Liveboard filters a board opens with. They are regular Liveboard filters: they show as chips the user can change or remove, and they filter the data.
<StandaloneLiveboard
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
defaultFilters={[{ column: 'Region', oper: 'IN', values: ['West'] }]}
syncFiltersWithHostUrl
/>
They use the liveboardFilters shape of the URL (LiveboardFilter) and go through the same validation. Filters UpdateFilters can't apply (no column, operator or values, or a date type without its value) are dropped with a console warning.
What the board applies after render:
syncFiltersWithHostUrl | URL | Applied | URL written |
|---|---|---|---|
| off | (not read) | defaultFilters | Never |
| on | No param, or an invalid one | defaultFilters | Yes, the resulting state (history.replace) |
| on | A filter state | The URL state (defaults are ignored) | As usual |
| on | An explicit empty state ("liveboardFilters":[]) | Nothing (the Liveboard's saved filters) | As usual |
- The sync stays explicit.
defaultFiltersnever turnssyncFiltersWithHostUrlon: writing the URL is a visible side effect, and boards sharing a page need their ownfiltersUrlParam. - Explicit empty state. On a board with defaults, a missing param means "apply the defaults". So when the user clears every filter, the URL keeps
{"liveboardFilters":[]}instead of dropping the param, and the defaults don't come back on reload or back/forward. Boards without defaults still drop the param. - Navigation. With the sync on, a URL without the param resolves to the defaults, following the same rules as any other URL change (in-place update, or a reload when a column is dropped).
- Read on mount. Like
defaultValuein React, the defaults are read when the embed mounts or reloads (including when the SDK rebuilds it because a config prop such asruntimeFiltersoractiveTabIdchanged), and when the Liveboard changes. Changing the array afterwards does not re-apply it, so an inline array is safe. - Same path as URL filters. They are applied with
UpdateFiltersonce the embed is ready (see URL → Liveboard), with the same costs.
defaultFilters are not runtime filters: they never go to runtimeFilters, so the data always matches the chips.
URL → Liveboard
- Initial load: the URL filters (or the defaults) are applied with
HostEvent.UpdateFilters, on top of the filters saved in the Liveboard, as soon as the embed subscribes to that event. The hook listens withembed.on(embed.subscribedEvent(HostEvent.UpdateFilters), …), which ThoughtSpot documents as the way to trigger a host event at load time: it fires once the embedded app has registered the event's handler, so the call is sent once and is never dropped. It needs SDK 1.48.0+ and ThoughtSpot 26.4.0.cl+.- Order of signals. On our cluster
LiveboardRenderedarrives first and the subscription follows within about a second.LiveboardRenderedonly keeps the skeleton up while filters are pending; the subscription sends them. The skeleton is released as soon asUpdateFilterssettles, before the URL is written. - "No answer found" means applied. The SDK sends
UpdateFiltersthrough its UI passthrough channel and rejects withNo answer foundwhen ThoughtSpot replies without a value, which it also does when the filters were applied. That rejection is treated as success; any other error is logged. - Safety net. If the embed has not subscribed within
MAX_URL_FILTERS_HOLD_MS(10 s), the skeleton is released, the board shows its saved filters, and a console warning is logged. The filters still apply if the subscription arrives later. Nothing is retried on a timer. - Double query. Liveboard filters cannot be applied at load in SDK 1.52 / ThoughtSpot 26.9, so ThoughtSpot queries twice and the skeleton hides the unfiltered first load.
liveboardOverride(SDK 1.54.0 / ThoughtSpot 26.11.0.cl) applies filters at load and will remove this.
- Order of signals. On our cluster
- Later URL changes (back/forward, links) while the board is mounted:
- If they add or change filters,
UpdateFiltersapplies them in place, with no reload. It is sent once, like the initial load. When the host passesonFilterStateChange, the loading skeleton (isApplyingInitialFilters) stays up until the board reports the applied state, or for at mostMAX_URL_FILTERS_HOLD_MS, so the previous filters are not shown as the new ones. Without a listener the update is applied behind the visible board, as before. - If they drop a column (including a URL with no filters), the embed reloads (the hook bumps
embedKey, used as theLiveboardEmbedkey) and the URL is applied to the fresh load.UpdateFiltersonly updates the columns it receives, and ThoughtSpot supports no way to remove a filter:values: []leaves an empty chip and does not work for date filters. The result matches opening that URL from scratch.
- If they add or change filters,
- Liveboard changes (a different
liveboardObjId, so a different resolved GUID), and new SDK instances (the SDK React wrapper rebuilds the embed when a config prop such asruntimeFiltersoractiveTabIdchanges), start over as if freshly mounted: pending work for the previous board is dropped, and the URL state is applied to the new board once it renders.
Only the columns present in the URL are ever sent to UpdateFilters, and never with empty values. Filters the user did not touch, including date filters, stay exactly as the Liveboard has them.
Liveboard → URL
On onFilterChanged (debounced 250 ms) the hook calls HostEvent.GetFilters, keeps only its Liveboard filters, and writes the URL only when the state actually changed:
- User changes use
history.push, so each change (after the debounce) gets its own history entry and back/forward walks through them. Going back applies the previous state like any other URL change. - The hook's own writes (the initial filters, or the state read back after applying a navigation) use
history.replace, so opening a board never adds an extra entry and going back never breaks forward.
Changes the hook applies itself are ignored so they don't loop back into the URL. A consumer onFilterChanged is called right after, on every change, without the debounce.
Filters in the URL are UI state only. They are not authorization. Tenant isolation is still enforced by
ts_var()RLS.
Reported filter state
onFilterStateChange gives the host the filters the board actually holds, so it can tell when a user has moved away from a saved view. Each call is { state, source }, where state is { liveboardFilters } as GetFilters reports it:
source | When |
|---|---|
initial | The board with its initial state (URL state or defaultFilters) applied and readable, or, for a board with nothing to apply, read once without touching the URL. This is the baseline |
navigation | After a later URL change (back/forward, a link) was applied to the board and became readable. When the change drops a filter column the board cannot clear it, so the hook reloads the iframe; the state the board comes back with is reported as navigation, never as initial, so it does not replace the baseline |
user | After a filter change event that is not the settling of a state the hook applied |
Settling. ThoughtSpot answers UpdateFilters before every filter is readable: with two default filters, the first GetFilters right after applying returned only one of them, and a single FilterChanged event carrying the complete state arrived about four seconds later. So while a host listens, a read right after applying is held back, neither reported nor written to the URL, until it holds every filter that was applied, with the applied values (comparing only the columns would pass a read that still has the old values of a column, which is what happens when a view with the same columns and other values is opened). A read still in flight when a new state is applied is discarded, since it describes the board before that state. The read that follows the next FilterChanged event closes the wait whatever it holds, because the board has announced itself. That read is reported with the source of the apply and written with history.replace, so opening a board adds no history entry. There are no timers or retries: only the events the hook already listens to.
Two limits of that rule: a filter that never becomes readable leaves the wait open until the next change event, and a user edit made before ThoughtSpot announces the applied state is taken as part of the baseline. Without onFilterStateChange none of this applies and the hook behaves as before, including writing the first read to the URL.
Example: a board that opens with two default filters, Created Date and Industry. The times are milliseconds since the page loaded, taken from a real run made before the wait described above existed; the last line is what a host receives now:
9060 onLiveboardRendered the shell loaded, nothing applied yet
10895 UpdateFilters answered; GetFilters has 1 of the 2 filters → held back, nothing reported
14581 FilterChanged event, complete state → the read that follows it
14833 onFilterStateChange({ source: 'initial', state: { liveboardFilters: [Created Date, Industry] } })
Before the wait, the read at 10895 was reported as the baseline and the one at 14833 as a user change, so Save and Reset appeared without anyone touching a filter.
A host that compares with the baseline:
const baseline = useRef<string>()
const handleFilterStateChange = ({ state, source }: FilterStateChange) => {
const current = JSON.stringify(state)
if (source === 'initial' || baseline.current === undefined) {
baseline.current = current
setIsModified(false)
return
}
setIsModified(current !== baseline.current)
}
<StandaloneLiveboard
liveboardObjId={liveboardObjId}
syncFiltersWithHostUrl
onFilterStateChange={handleFilterStateChange}
/>
useLiveboardViewBridge is this, plus the store updates for Save and Reset.
Compare each state with the baseline instead of with the saved view: ThoughtSpot normalizes the filters it reports, so what it reports can differ in form from what was saved. A filter with no value, such as an empty Rep Name chip, is not reported at all.
Known limitations
- Console noise from custom charts. The post-load
UpdateFiltersreloads the visualizations while ThoughtSpot's custom charts are still initializing, so the ThoughtSpot iframe logs errors such asTarget window is not available(TSChartApp,ts-to-chart) once per load. They come from a cross-origin iframe, don't reachonErroror Sentry, and the charts end up rendered with the filters applied. Applying filters at load withliveboardOverridewill remove them. - Date chips can't be cleared with
UpdateFilters, so a navigation that drops a column reloads the embed.
Date range filter
A page Liveboard can show the date picker of the Analytics Hub reports (DateFilter from @shared/analytics) and keep the Liveboard to the chosen range. ThoughtSpot's own date filter works out "this quarter" by itself, which can disagree with the rest of Rhapsody: the Analytics Hub resolves ranges with the tenant's fiscal calendar (fiscal_year_periods.json), so a tenant whose fiscal year closes in February sees its own quarters there. Using the same picker and the same resolution keeps both pages on the same dates.
It is opt-in per Liveboard: pass the name of its date column.
<AnalyticsLiveboard
viewKey={LIVEBOARDS.teamPerformance.viewKey}
liveboardObjId={LIVEBOARDS.teamPerformance.liveboardObjId}
dateColumn={LIVEBOARDS.teamPerformance.dateColumn}
/>
LIVEBOARDS.teamPerformance.dateColumn is 'Date'. Without dateColumn nothing changes: no picker, no filter.
Decisions
| Decision | Why |
|---|---|
The picker is the shared DateFilter, with the options of the Analytics Hub reports, fiscal ones included | Same tokens (-7d, 0fq, today), same resolution (getQueryDates), so a range reads and means the same on both pages |
The range is part of the open saved view (from, to) | It is saved with the view and Reset restores it. DateFilter is given the saved views store as its useParams, as the Analytics Hub reports do |
The URL mirrors the host params through useHostParamsUrlSync; rewriteUrlOnSave stays false | rewriteUrlOnSave rewrites the whole search from buildParametersToPersistToURL, which defaults to () => ({}), so turning it on would erase tsFilters and the pages' own params |
There is always a range; the default is the last 7 days (DEFAULT_DATE_RANGE) | With the Liveboard's own date filter removed and no range applied, every tile of Team performance failed with Query execution failed (observed 2026-11-03). The default is the one DateFilter opens on in the Analytics Hub reports |
The range the embed opens with goes in runtimeFilters; later ranges are sent with HostEvent.UpdateRuntimeFilters | Changing runtimeFilters makes the SDK build a new iframe. The event repaints in place: onLoad did not fire again and the tiles repainted within 10 s (observed 2026-11-03) |
| Each event carries every runtime filter, not only the date | The ThoughtSpot docs do not say whether the event replaces the filters the embed loaded with or adds to them |
| If the event is rejected the embed is rebuilt with the current range | A board showing the previous range under a picker that shows the new one would be wrong numbers presented as right |
A fiscal range waits for the fiscal calendar, and is unavailable if it cannot be loaded | Without periods the date service falls back to the calendar quarter or year without saying so. That would be another period's numbers under a fiscal label |
| No weekend toggle and no comparison period | The Analytics Hub applies "weekends off" as a Cube segment; a runtime filter on a date column is a range and cannot express it. With weekends off the Analytics Hub showed 99 emails against 105 for the same year (2026-11-03), so the two pages differ if a user turns it off there |
Host params in the URL
The host owns these params; the Liveboard owns tsFilters. useHostParamsUrlSync keeps them in step between the saved views store and the URL, leaving every other param alone:
| Param | Value | Written as |
|---|---|---|
from, to | A relative token (-7d, 0q, 0fq, today) or an ISO date | from=-7d |
account_id, group_id | The user / group selection | group_id[]=all |
crm_account_id | The CRM account selection | crm_account_id[]=42 |
/advanced-analytics/team-productivity?from=0fq&to=today&group_id%5B%5D=all&tsFilters=%7B%22liveboardFilters%22%3A%5B%5D%7D
- Store → URL: a change in the store is written with
history.push, so back and forward walk through the user's changes. The first write after the page opens usesreplace, so opening adds no history entry. - URL → store: when the URL changes to a host state the store does not hold (back, forward, a link), the store takes it with
replaceParams. - The saved views store reads the URL when the page opens, so a shared link restores the range and the selections. The user, group and account selections were not in the URL before.
- Only the params the host owns are touched.
useLiveboardFilterUrlSyncnow compares only its owntsFilterswhen the URL changes, so a change tofromortodoes not re-apply the Liveboard's filters or put the skeleton up, and it reads the current search when it writes, so it no longer overwrites a host param written while aGetFilterswas in flight.
How a range reaches the board
useDateRangeFilter reads from and to from the open view (default -7d to today) and resolves them with getQueryDates to the first and last second of the days in the browser's time zone, as epoch seconds, the unit ThoughtSpot takes for date runtime filters. StandaloneLiveboard receives it as dateFilter and useDateRuntimeFilters applies it.
The range -7d to today on 2026-11-03, in a browser at UTC-6, as it was sent in a real run:
[
{
"columnName": "Date",
"operator": "BW_INC",
"values": [1793167200, 1793771999]
}
]
1793167200 is 2026-10-28T00:00:00-06:00 and 1793771999 is 2026-11-03T23:59:59-06:00. The first range goes in the embed's runtimeFilters; picking another one afterwards triggers HostEvent.UpdateRuntimeFilters with the full list (the date filter plus the user, group and account filters), without rebuilding the iframe.
range known ──► embed mounts with runtimeFilters = user + account + date
user picks a range ──► UpdateRuntimeFilters(user + account + date)
├─ ok ──► the board repaints in place
└─ rejected ──► reported to Sentry, embed rebuilt with the current range
Status of the date filter (DateFilterConfig.status):
| Status | When | What the page shows |
|---|---|---|
loading | A fiscal range and the fiscal calendar is still being fetched | The skeleton; the embed is not mounted |
ready | The range is resolved | The Liveboard, filtered |
unavailable | A fiscal range and the fiscal calendar could not be loaded, or the range cannot be read or ends before it starts | The error state ("This date range is unavailable") |
Drill-down follows the range
The "Drill Down" modals filter their records by the range the picker shows now, not the one the embed opened with. ThoughtSpot's click payload carries its own native date filters but not the runtime filters, so withRuntimeFilters adds a BW_INC runtime filter as a time dimension on the view's matching dimension (a range the click already carries on that member wins). useDateRuntimeFilters returns currentRuntimeFilters for this; the embed's runtimeFilters stays the opening range on purpose, because changing it rebuilds the iframe. Checked in QA on Team performance and Call sentiments: with Last 7 Days the Emails Sent KPI and its modal both show 3, with This Year both show 108, and a change made in the picker without reloading is reflected in the next drill-down.
Saved views
from and to are in SAVED_VIEW_PARAMS, so a view that was saved with a range keeps it:
"view_params": {
"tsFilters": "{\"liveboardFilters\":[]}",
"from": "0fq",
"to": "today",
"view_created_by": 13505
}
A view saved before this change has no from or to and opens on the default range. A range different from the open view's marks it as modified, so Save and Reset appear. Translating the date filters that older views hold inside tsFilters is not done.
What the Liveboard needs
- Its own date filter on that column must be removed in ThoughtSpot. The Liveboard's Cube view takes one time filter: with the native "Date This Year" filter and a runtime filter on
Date, every tile failed withView 'team_performance' supports only one time dimension. - The column name must be exact. On Team performance a runtime filter on
Created Date, which is not a column there, changed nothing and raised no error. - The Liveboard must be built from a Model: runtime filters do not apply to Tables or Views.
What was observed
On Team performance (Date, All Users, 2026-11-03), through UpdateRuntimeFilters:
| Range | Emails sent | Calls logged | Analytics Hub, same range |
|---|---|---|---|
| This year | 105 | 26 | 105 and 26 |
| This quarter | 0 | 2 | 0 and 2 |
| This week | 0 | 0 | 0 and 0 |
The counts match. The rates do not: for this year the Analytics Hub showed % Personalized 9.9%, % Opened 9.9%, % Replied 4.4% and % Positive replies 0.0%, against 8.57%, 7.62%, 3.81% and 2.86% in the Liveboard. They are different metric definitions, not a date issue. This tenant's fiscal calendar equals the calendar: 0fq resolved to 1 October. With a fiscal year starting on 1 February simulated in the browser, the board showed 97 emails against 105 from 1 January.
Limits of the date range filter
UpdateRuntimeFilterstakes about 30 s to resolve ({}), although the board repaints within 10 s. Nothing waits for it.Whether the event adds to or replaces the filters the embed opened with was not verified. The user filter had no visible effect on Team performance, so the test could not tell. Every event carries the full list for that reason.
Time zone. Days start and end in the browser's zone. Whether ThoughtSpot or the Cube view cut days in the organization's zone was not checked.
Every Liveboard of the app has a
dateColumn, and its own date filter must stay removed in ThoughtSpot. With both, the Cube view fails withsupports only one time dimension; with neither, withTime dimension required for view. Observed 2026-11-05 on the dev instance, with the range set to This Year and All Users:Page Tab dateColumnObserved Team Productivity Team performance Datetiles load Team Productivity Process Datetiles load Team Productivity Call sentiments Event Attiles load Team Productivity Call disposition Event Attiles load Prospecting results Accounts activity_attiles load. Date range, the label of the removed filter, is not a column: every tile failed withTime dimension requiredProspecting results Team cadences Activity Attiles load Prospecting results Calls Event Attiles load Prospecting results Emails Sent Attiles load Prospecting results Meetings Booked Attiles load Checked again on 2026-11-06, once the native date filters were removed in ThoughtSpot again: all nine tabs load with no native date chip, and their figures change between This Year and Last 7 Days (Team performance 108 emails and 31 calls against 3 and 5; the range also updates in place from the picker, with no reload). The four tabs that have an Analytics Hub page were compared with it, This Year, all users, weekends included in the Hub (
weekends=true). Team performance: 108 emails and 31 calls in both, and This Fiscal Quarter 3 and 7 in both. Process: Tasks Completed 44, Tasks Deleted 0, % On-time tasks 54.5%, Enrolled 31 and Hot Leads 4 match, but People Contacted is 38 in the Hub and 2 here (its tile here is subtitledWeek of Oct 23, which suggests it shows the last period, not verified). Call sentiments: Calls Logged 31, Positive Calls 25, Voicemails 0 and Connected Calls 5 match, but the positive rate is 500.0% in the Hub (positive over connected calls) and 80.0% here (positive over logged calls). Call dispositions: the same four counts match, and Connected 74% here is the Hub's 23 connected calls of 31. The two differences are in how the Liveboard's tiles are defined, not in the date filter. With the Hub's default,weekends=false, its Team performance emails were 102 against 108 here, because this page does not exclude weekends (see Limits of the date range filter). The five Prospecting tabs were not compared.The Spotter panel does not get the range. It restarts its chat when its filters change, which would happen on every range change.
The fiscal calendar is cached in
localStorageunder one key (all_fiscal_year_periods) shared by every user of the browser, andDateFilterfetches it too, so it is requested twice on a page with a date filter. Both are the shared date service's behaviour.A range such as "today" is resolved when the page renders, not at midnight.
Weekends and comparison periods are not offered. See the decisions table.
Saved views of a Liveboard
Users can save the filters they applied to a Liveboard, reopen them later and share them with their team. The picker, the Save / Reset buttons, sharing and the default view are the ones Analytics Hub pages already use (@shared/saved-views), and views are stored through Melody's existing saved_list_views endpoints. No new endpoint was created. The package adds the one thing the engine cannot know, what the Liveboard actually holds, through onFilterStateChange, and ties both together. Every page Liveboard has them: AnalyticsLiveboard requires a viewKey. The pages of the example app (ProspectingResults, TeamProductivity) all pass the same one.
┌────────────────── AnalyticsLiveboard ──────────────────────────────────────────┐
│ LiveboardToolbar useLiveboardViewBridge AnalyticsLiveboard │
│ picker + Save / Reset ◄── isModified ◄── onFilterStateChange │
│ │ │ ▲ ▲ │
└──────────┼──────────────────────────┼──┼──────────────────────────┼─────────────────┘
▼ ▼ │ URL param `tsFilters` │ GetFilters / UpdateFilters
@shared/saved-views ─────► Melody saved_list_views ThoughtSpot Liveboard
(store, picker) (one `view` per Liveboard)
Decisions
| Decision | Why |
|---|---|
Reuse saved_list_views; create no endpoint | The direction given to the team: reuse the existing endpoints and, when one needs a change, send the backend team a detailed list. Creating a view under a view Melody does not list returns 422; Melody supports every advanced_analytics_* key of LIVEBOARDS (see The view key) |
Every AnalyticsLiveboard has saved views, so viewKey is required | Every page works the same way, so there is no switch to maintain: no optional views, no lazy loading, no prop combinations to forbid. The embed without views still exists as StandaloneLiveboard because Liveboards opened in the analytics modal have none |
One Melody view key, passed as the viewKey prop | The key is a property of the page, not of the component. Every Liveboard in LIVEBOARDS declares its own (viewKey), named after the Analytics Hub page it replaces (see The view key) |
The filter state is one JSON string in view_params.tsFilters | Melody never sees nested objects or camelCase keys, so there is no schema or casing concern on its side. It is the same string the Liveboard keeps in the URL |
| Defaults live inside the "Views from Salesloft" | As in Analytics Hub: the default filters are the content of a view, not a separate prop. A user's own default view replaces them without merging |
| No mapping from Analytics Hub report views | from/to/group_id/columns/sort have no reliable ThoughtSpot equivalent and the mapping was too costly to maintain. Liveboard views use their own view key, so the two never mix |
| A view is "modified" when the board differs from its baseline, not from the saved string | ThoughtSpot normalizes the filters it reports, so they can differ in form from what was saved. The baseline is what the board reports once the view is applied |
| The URL belongs to the Liveboard | The saved views store is told not to write it (rewriteUrlOnSave={false}); the bridge only sets the tsFilters param when a view opens |
| The URL wins over the open view when the page opens | A shared link or a reload carries the filters the user wants to see, so the bridge does not overwrite a valid tsFilters that differs from the view's own. The default view stays selected and is flagged as modified, so Save and Reset appear. Picking a view or pressing Reset afterwards sets the URL from the view again |
| Tabs are not part of a view | The Liveboard does not use them, and SDK 1.52.1 has no tab-changed event (GetTabs returns only id and name) |
What a view stores
The request the engine sends when a user saves a view (shortened):
{
"view": "advanced_analytics_prospecting_accounts",
"name": "CPG and Consulting",
"is_default": false,
"shared": false,
"view_params": {
"tsFilters": "{\"liveboardFilters\":[{\"column\":\"Industry\",\"oper\":\"IN\",\"values\":[\"CPG\",\"Consulting\"]}]}",
"view_created_by": 13505
}
}
tsFilters is JSON.stringify of the PersistedFilterState the Liveboard reports: the same shape as defaultFilters, up to 50 filters. view_created_by is stamped by the engine and is how the picker shows who shared a view. These keys, the date range (from, to, see Date range filter) and the user, group and account selections are saved (SAVED_VIEW_PARAMS); anything else in the store is dropped by toSavedViewParams.
The view key
Saved views are stored in Melody under a view string, and AnalyticsLiveboard takes it as the viewKey prop. The app declares one per Liveboard, in the same object as its obj_id, so a Liveboard cannot be added without it:
// apps/advanced-analytics/src/liveboards.ts
export const LIVEBOARDS = {
prospectingAccounts: {
liveboardObjId: 'NewLiveboard-23f9fb7f',
viewKey: 'advanced_analytics_prospecting_accounts',
tabPath: 'accounts',
tabLabelKey: 'prospectingTabAccounts',
displayBy: {
param: 'display_by',
tables: ['dayOfWeek', 'hourOfDay', 'user', 'name'],
},
},
/* … */
} as const satisfies Record<string, LiveboardConfig>
<AnalyticsLiveboard
viewKey={LIVEBOARDS.prospectingAccounts.viewKey}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
/* … */
/>
Naming. The key is the one the Analytics Hub page being migrated saves its views under, with advanced_ in front: analytics_prospecting_accounts becomes advanced_analytics_prospecting_accounts. The Analytics Hub names were read from the view parameter of the saved_list_views requests each hub page makes (on 2026-11-05, in QA) and match the ones in apps/analytics-hub:
Liveboard (LIVEBOARDS) | Analytics Hub page | Analytics Hub view | viewKey here |
|---|---|---|---|
prospectingAccounts | prospecting/accounts | analytics_prospecting_accounts | advanced_analytics_prospecting_accounts |
prospectingTeamCadences | prospecting/team-cadences | analytics_prospecting_team_cadences | advanced_analytics_prospecting_team_cadences |
prospectingCalls | prospecting/calls | analytics_prospecting_calls | advanced_analytics_prospecting_calls |
prospectingEmails | prospecting/emails | analytics_prospecting_emails | advanced_analytics_prospecting_emails |
prospectingMeetings | prospecting/meetings | analytics_prospecting_meetings | advanced_analytics_prospecting_meetings |
teamPerformance | productivity/team-performance | analytics_team_productivity_team_performance | advanced_analytics_team_productivity_team_performance |
process | productivity/process | analytics_team_productivity_process | advanced_analytics_team_productivity_process |
callSentiments | productivity/call-sentiments | analytics_team_productivity_call_sentiments | advanced_analytics_team_productivity_call_sentiments |
callDisposition | productivity/call-dispositions | analytics_team_productivity_call_dispositions | advanced_analytics_team_productivity_call_dispositions |
A name that does not start with analytics would get advanced_analytics_ in front instead, so the prefix is never repeated. liveboards.spec.ts checks that every Liveboard has a key, that no two share one, and that none is cadence_people.
The advanced_ prefix keeps these records apart from the hub page's: a view saved on one never shows on the other, which is what the two need while their filters are not compatible. Once the hub page is gone, the key stays as it is, so no saved view is orphaned.
Melody must accept the keys. Melody only accepts the view types in its own list when a view is created, and a view outside it makes create return 422. Listing, updating and deleting do not validate view. Melody accepts all nine advanced_analytics_* keys of LIVEBOARDS. Checked on 2026-11-06 from the local app against QA (localhost.qasalesloft.com): on each of the nine tabs, saving a private view returned "View Saved" and the view was then deleted. Melody's code is not in this repository, so its list was not read. A key added to LIVEBOARDS later has to be added to Melody's list first, or saving under it fails.
The sections of the picker
| Section | Where the views come from | Editable by the user |
|---|---|---|
| Views from Salesloft | Declared in the client with buildStandardViews; never stored | No: saving one creates a copy under "Your saved views" |
| Your saved views | The user's own views from Melody | Yes |
| Views shared with you | Other users' views saved with shared: true, same team | No |
The engine puts a view under "Views from Salesloft" when its isDefault is true and, if that flag is missing, when Melody's is_default is true. is_default only means "the default of whoever saved it", so a view saved as default would jump sections. keepUserViewsInTheirSection sets isDefault: false on every view that has no flag, and SavedViewsControls passes its result to the picker and the buttons. The engine is shared with other apps and is left untouched.
Views from Salesloft
buildStandardViews turns definitions into the views of the first section. As in Analytics Hub they are declared in the client and all are flagged isDefault; the first definition is the one that opens until the user picks another. Their ids are their positions (0, 1, 2), so appending a definition never changes the id of an existing one: the engine stores a user's default view by id in the browser, and ids that depended on the length would make a returning user's default point to another view. The engine sorts views by id and opens the first one flagged default, so the first definition (id 0) opens, and it is also the view the engine selects after the open view is deleted (it needs an id 0). Only append definitions: removing or reordering one changes what a stored id means. A saved view cannot collide with these ids as long as the ids Melody gives the account's views for the key are larger than the number of definitions; the code does not check it. With no definitions (standardViews={[]}) the page uses the single "Default" view, because the engine cannot start without one.
import { buildStandardViews } from '../../savedViews/standardViews.ts'
const standardViews = buildStandardViews([
{ name: 'Default', filters: [{ column: 'Region', oper: 'IN', values: ['West'] }] },
{ name: 'Consumer goods', filters: [{ column: 'Industry', oper: 'IN', values: ['CPG'] }] },
])
// [
// { id: 0, name: 'Default', isDefault: true,
// view_params: { tsFilters: '{"liveboardFilters":[{"column":"Created Date", ... }]}' } },
// { id: 1, name: 'Consumer goods', isDefault: true,
// view_params: { tsFilters: '{"liveboardFilters":[{"column":"Industry","oper":"IN","values":["CPG"]}]}' } },
// ]
Every view carries its own filters, so one from Salesloft and one a user saved open through the same path, and the Liveboard needs no defaultFilters.
A user can pick any view as their default from the picker. The engine keeps that choice in the browser (localStorage, per user and view key) and opens it on the next load instead of the first view from Salesloft.
What happens when
| Event | What happens |
|---|---|
The page opens with no tsFilters in the URL (or an invalid one) | The store opens the user's default view, or the first from Salesloft. The bridge sets the tsFilters param to that view's own filters and the Liveboard applies them |
The page opens with a valid tsFilters that differs from the open view's own | The URL is left untouched and the Liveboard applies it. The store still opens the default view. When the board reports its first state (initial) the bridge compares it with the view's own filters (areStatesEqual, both normalized); if they differ, or the view has no filters, the view is flagged as modified and the state is copied into the store, so Save stores what is on screen. A URL equal to the view's own string opens the view as usual |
| The Liveboard finishes applying them | onFilterStateChange reports initial: the baseline. Save and Reset stay hidden |
| The user changes a chip | A user state arrives, differs from the baseline, and the bridge flags the view as modified and copies the state into the store. Save and Reset appear |
| The user changes it back | The state equals the baseline again and the buttons hide |
| Save, view the user owns | The saved string becomes the view's own. The bridge takes the current state as the new baseline and the buttons hide, with no reload |
| Save as a new view | The store selects the new view. The URL already holds that state, so nothing is applied and the last state read becomes its baseline: no reload |
| Reset | The store puts the view's own params back; the bridge sees it moving away from what it wrote, forgets the baseline and sets the URL param to the view's filters. The Liveboard applies them in place (or reloads when they drop a column) and the state it reports next, a navigation, is the new baseline |
| The user opens another view | The bridge forgets the baseline and sets the URL param to that view's filters. The Liveboard applies them in place and reloads only if they drop a filter column. The skeleton stays up until the board reports them, and that first report, a navigation, is the new baseline; the buttons stay hidden meanwhile |
| The page reloads | Unsaved changes are kept: the URL holds them, so the page opens the default view flagged as modified, with those filters |
The baseline is reported only once every filter the host applied is readable, which can take a few seconds. See Reported filter state.
Using it
Give AnalyticsLiveboard a viewKey, and optionally the views from Salesloft. Any other prop is passed through (visibleActions, customActions, frameParams…).
<AnalyticsLiveboard
viewKey={LIVEBOARDS.prospectingAccounts.viewKey}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={DEFAULT_CUSTOM_ACTIONS}
/>
With no standardViews the page has one view from Salesloft, "Default", with no filters: the Liveboard opens as it was saved in ThoughtSpot. A page whose Liveboard should open with filters declares them as a view:
const standardViews = useMemo(
() => [
{
name: messages.standardViewName,
filters: [{ column: 'Industry', oper: 'IN', values: ['CPG'] }],
},
],
[]
)
<AnalyticsLiveboard
viewKey={LIVEBOARDS.myPage.viewKey}
reportName={messages.reportName}
standardViews={standardViews}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={DEFAULT_CUSTOM_ACTIONS}
/>
What it renders: the picker and the Save / Reset buttons (Save and Reset appear only when the user changes something) above the Liveboard.
| Prop | Description |
|---|---|
viewKey | Required. The Melody view the views are stored under. See The view key |
standardViews | Optional. { name, filters }[] for the "Views from Salesloft"; the first one opens by default. Defaults to one "Default" view with no filters. Keep the array stable (useMemo when a name comes from messages) |
reportName | Optional. Subject of the sentence under "set as default" in the save dialog: "{reportName} will show these filters by default when you first land on the page". Defaults to "This page" |
liveboardObjId and the rest | Passed to the embed |
The types keep the props the views layer owns out of reach: defaultFilters (the filters come from the open view), syncFiltersWithHostUrl, filtersUrlParam and onFilterStateChange, and embedSize (a page Liveboard is always page-sized). A Liveboard that needs them, such as one in a modal, is a StandaloneLiveboard.
With more than one view from Salesloft:
const standardViews = useMemo(
() => [
{
name: messages.standardViewName,
filters: [{ column: 'Region', oper: 'IN', values: ['West'] }],
},
{
name: messages.consumerGoodsViewName,
filters: [{ column: 'Industry', oper: 'IN', values: ['CPG'] }],
},
],
[]
)
How it is built
AnalyticsLiveboard is StandaloneLiveboard with the views layer around it, in one component.
// components/AnalyticsLiveboard/AnalyticsLiveboard.tsx (shortened)
const history = useHistory()
useLayoutEffect(() => {
setFiltersParam(history, undefined)
}, [history])
<SavedViewsProvider
viewName={viewKey}
defaultView={buildStandardViews(standardViews ?? [defaultView])}
parametersToSaveViewMapper={toSavedViewParams}
rewriteUrlOnSave={false}
hasPublicSavedViews
>
<LiveboardToolbar>
<SavedViewsControls isModified={isModified} reportName={reportName} />
</LiveboardToolbar>
<StandaloneLiveboard
{...liveboardProps}
frameParams={frameParams}
onFilterStateChange={handleFilterStateChange}
syncFiltersWithHostUrl
/>
</SavedViewsProvider>
rewriteUrlOnSave={false}: the store would otherwise rewrite the URL with only the params it knows and drop the Liveboard'stsFilters.setFiltersParam(history, undefined)on mount: with public views the store merges every URL param into its state when it loads, so a leftovertsFilterswould make the open view look modified from the start.hasPublicSavedViews: turns on the "shared with you" section and the per-browser default.- No
keyon the embed: a view change, a Reset and a new saved view do not remount it. The bridge sets the URL param and the Liveboard follows it like a navigation: filters that are added or changed are applied in place, and it reloads itself only when the new view drops a filter column (see Filter sync with the host URL). pristine={!isModified}goes to the picker and to the buttons. The store's ownpristinecompares with the saved string and would be wrong.frameParamsdefaults tocalc(100vh - 120px)(SAVED_VIEWS_FRAME_PARAMS), which leaves room for the toolbar. Pass your own to change it.
LiveboardToolbar is the row above the Liveboard. Every Liveboard page has the same one and it knows nothing about views: it only lays out its children. Today the only child is SavedViewsControls (the picker and Save / Reset). More actions (for example a user or group filter) will be added to this row by changing the code, so none of it is a prop of AnalyticsLiveboard. Both use Box (see UI conventions):
// components/SavedViewsControls/SavedViewsControls.tsx
const views = useMemo(
() => keepUserViewsInTheirSection(savedViews),
[savedViews]
)
return (
<>
<SavedViewSelector savedViews={views} pristine={!isModified} /* … */ />
<PublicSavedViewActionButtons savedViews={views} pristine={!isModified} /* … */ />
</>
)
// components/LiveboardToolbar/LiveboardToolbar.tsx
<Box flex items-center gap-100 p-b-100 p-i-200>
{children}
</Box>
A control that must change what the Liveboard shows and be saved in the view needs its own wiring to the Liveboard and to view_params; adding it to the row is not enough, and that design does not exist yet.
Adding a Liveboard page
- Declare its entry in
LIVEBOARDS(src/liveboards.ts):obj_id, its ownviewKeynamed as in Theviewkey,tabPath,tabLabelKey(add the message topages/messages.ts) and, if it has a Display By picker,displayBy. A new table goes inDISPLAY_BY_TABLES. - Add it to the
tabsof its page inPAGES. A new page is a file that rendersTabbedLiveboardPagewith a title and itsPAGESentry, plus a<Route path=".../:tab?">inAdvancedAnalytics.tsx. Outside this app, renderAnalyticsLiveboardwith theviewKey(andstandardViewswhen it should open with filters). - Register the route (and one path per tab) and nav item in
entry.ts, and regenerate the route types (node ./infrastructure/scripts/generateGeneratedRoutes.mjs, then format the generated files with Biome).
Only one AnalyticsLiveboard can be on a page, because the filter state lives in a single URL param (tsFilters). Liveboards in the analytics modal are StandaloneLiveboards: they have no views and do not touch the URL.
Where the code lives
| Piece | Lives in |
|---|---|
AnalyticsLiveboard, SavedViewsControls, LiveboardToolbar, StandaloneLiveboard | src/components/ |
useLiveboardViewBridge: the baseline, isModified and Reset | src/hooks/useLiveboardViewBridge/ |
buildStandardViews, keepUserViewsInTheirSection, setFiltersParam, toSavedViewParams and the typed access to @shared/saved-views | src/utils/savedViews/ |
onFilterStateChange and the wait for the applied filters | src/hooks/useLiveboardFilterUrlSync/ |
The Liveboard, the viewKey, the views from Salesloft and the custom actions | The app: they are configuration of one page |
The package depends on @shared/saved-views; if a type check says it cannot find that module, run pnpm install.
Backend
Saved views use Melody's public API as it is (${API_GATEWAY_URL}/v2/saved_list_views, called by @shared/saved-views). Creating a view validates view against SavedListView::Views::VALID_VIEW_TYPES; listing, updating and deleting do not. A view outside that list makes create return 422, which is what the first attempt with a key of our own got. Melody accepted a create under every advanced_analytics_* key of LIVEBOARDS (tried on each of the nine tabs, 2026-11-06, QA). A key added later needs a Melody change before a user can save a view (see The view key).
Known limitations
- Opening another view and Reset change the filters in place. The loading skeleton stays up until the board reports the applied state, so the previous filters are never shown as the new ones; it can take a few seconds (about 3.7 s was observed in the QA cluster when applying filters on load). If the board never reports, the skeleton is released after
MAX_URL_FILTERS_HOLD_MS(10 s). The Liveboard reloads, with its own loading state, only when the new view drops a filter column, becauseUpdateFilterscannot remove a filter. - The new baseline is the first state reported after the switch. While the skeleton is up the user cannot edit the board, so nothing can slip into the baseline, except after that 10 s release.
- Saving a new view applies nothing (the board already shows that state), so it has no loading state.
- Unsaved changes are lost on reload, and a link does not carry them. Share a view instead.
- If the modified state equals another saved view's state exactly, the store selects that view (a behavior of the engine).
- The user's default view is stored in the browser, so it does not follow them to another browser or device. Melody also stores
is_defaultwhen a view is saved as default, but it is ignored. - A filter edit made before ThoughtSpot announces the applied state becomes part of the baseline, and a filter that never becomes readable keeps the baseline waiting until the next change event.
- Tabs are not saved in a view.
- Filters that an action puts in the URL (
createNavigateActionwithliveboardFilters) are not applied:AnalyticsLiveboardclearstsFilterswhen it mounts so a leftover value does not make the open view look modified. Navigating to a page with the clicked filters therefore opens the destination's own view instead.
Troubleshooting
| Symptom | Cause | What to do |
|---|---|---|
| Save and Reset are visible right after the page loads | The baseline was taken before the board settled, or the host passes the store's pristine | Pass onFilterStateChange to AnalyticsLiveboard and pristine={!isModified} to the picker and the buttons |
| Saving returns 422 | The viewKey is not one of the view types Melody accepts | Use a valid key (see The view key). The response should name the field view |
| A view saved as default appears under "Views from Salesloft" | The engine reads Melody's is_default as "view from Salesloft" | Pass the views through keepUserViewsInTheirSection |
Cannot find module '@shared/saved-views' | The package declares it in package.json but it is not installed | Run pnpm install |
| The filters of a view do not open, or only some of them do | A state holds up to 50 filters and 8,000 characters; beyond that part or all of it is ignored | Save fewer filters |
UI conventions for host pages
Pages and toolbars follow the design system rules: no inline style objects, layout through Box (@starlight/layout), text through Text (@starlight/typography), custom CSS that Box props cannot express in a styled-components file, and one component per file.
// Not this
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px' }}>
// This
<Box flex items-center gap-100 p-b-100 p-i-200>
Box takes boolean props (flex, flex-col, items-center, justify-between, overflow-auto…) and scaled props (gap-100, p-i-200, m-b-s-100, size-i…). The scale comes from the tokens: 100 is 8px and 200 is 16px. Text takes as (default p), color and variant, for example variant="Body/md/regular". The frame size passed to the embed (frameParams) is embed configuration, not a React style.
Error handling
Auth errors
When ThoughtSpot auth (AdvancedAnalyticsProvider) reports status === 'error', the component renders errorFallback (or DefaultErrorState). It also calls captureException with the auth error (if present), tagged with component: 'AnalyticsLiveboard', the liveboardObjId, and the resolved liveboardId when auth fails after resolution. The report runs from an effect, once per error and board, not on every render.
Any auth failure before the first successful login switches the provider to error. After that, EXPIRY and IDLE_SESSION_TIMEOUT failures are ignored because the SDK emits them on every normal token refresh and recovers on its own. Other failure types (SDK, NO_COOKIE_ACCESS, OTHER, …) still switch to error. If resolving the user locale fails, the provider falls back to en-US and authenticates anyway.
Embed errors
Embed errors are reported to Sentry once per distinct error:
Errorinstances are sent directly- Non-Error values (SDK payloads) are wrapped in
new Error('ThoughtSpot embed error: <code | errorType | UNKNOWN>'), so each failure kind gets its own readable Sentry issue - Duplicates are dropped. An error with the same
code,errorTypeand first message as one already reported for the current embed is not sent again. The filter resets when theliveboardIdchanges or the embed remounts. - Every report carries the
component: 'AnalyticsLiveboard'tag (filterable in Sentry). TheextraincludesliveboardId(the resolved GUID),liveboardObjId,hasRendered, anderror.erroris an allowlisted report fromtoEmbedErrorReport, never the raw SDK payload:{ code?, errorType?, messages }, with at most 5 messages of up to 200 characters each.
The raw payload can echo query data (column names, filter values), so it never leaves the browser as-is. For the same reason, the filter sync hook logs only this report to the console, because console output ends up in Sentry breadcrumbs. Messages are truncated but not scrubbed, so a short message can still include a value.
The onError callback (if provided) receives every occurrence, duplicates included, with the untouched original error.
Token flow and rotation
Token refresh is driven by the SDK, not a manual timer. There is no client-side
token cache — analytics-api caches short-lived tokens server-side per (tenant, user).
AdvancedAnalyticsProvider mounts (ThoughtSpot auth)
│
└─ init() called once with a getAuthToken callback.
│
├─ embed mounts and its iframe boots ─┐ in parallel
│ │
└─ SDK calls getAuthToken() on init and on every │
token expiry │
│ ─┘
└─ fetchThoughtSpotToken() → GET /v2/thoughtspot/embed_token
Always issues a fresh network request — tokens are
single-use.
callPrefetch: trueis set oninit()so ThoughtSpot's static assets warm in parallel with the handshake- Token rotation: ThoughtSpot Trusted Auth tokens are single-use.
getAuthTokenalways callsfetchThoughtSpotToken()directly, uncached
Custom actions
A custom action is an entry ThoughtSpot shows (by default in the chart context menu) plus what happens when it is clicked. Actions are plain objects, declared anywhere — in this package, in an app, or inline in a page — and each AnalyticsLiveboard picks the ones it offers. Building a new app walks through a full example; this section is the reference.
<AnalyticsLiveboard viewKey={LIVEBOARDS.myPage.viewKey} liveboardObjId={…} customActions={[recordsDrilldownAction, openAccount]} />
AnalyticsLiveboard adds their ids to visibleActions (ThoughtSpot hides custom actions it doesn't name), hands the SDK only their plain fields, and runs the clicked one. Without customActions a Liveboard offers DEFAULT_CUSTOM_ACTIONS; [] offers none.
Declaring an action
import { defineCustomAction, toRuntimeFilters, StandaloneLiveboard } from '@shared/advanced-analytics'
export const openAccount = defineCustomAction({
id: 'open-account',
label: () => messages.openAccount,
scope: { metadataIds: { vizIds: [PIPELINE_VIZ_ID] } },
run: (click, { modal, refuse }) => {
const target = TARGETS_BY_VIZ[click.vizId ?? '']
if (!target) return refuse('NO_TARGET', click.vizId)
modal.open({
title: messages.account,
content: (
<StandaloneLiveboard
embedSize="modal"
liveboardObjId={target.liveboardObjId}
runtimeFilters={toRuntimeFilters(click, { columns: target.columns })}
/>
),
})
},
})
click(DrilldownClick) is the normalized payload:actionId,vizId,measuresandattributes({ name, value, column }), the clickedmodel, andraw— the SDK payload as received, whose shape ThoughtSpot may change.scopeis resolved by ThoughtSpot itself (metadataIds,dataModelIds,orgIds,groupIds), so an action only shows where it applies. It can also be a function of the Liveboard's metadata ({ kpiVizIds }); returningnullleaves the action out of that Liveboard.- The context gives every action:
modal—open(frame),back(),close()on the shared analytics modal. Opening while it is open stacks the frame, with a back button.navigate(route)— closes the modal and pushes an in-app route; absolute URLs,//hostand paths with a query or hash are refused.liveboardFiltersare written to the destination's filters param (tsFiltersby default), so a page withsyncFiltersWithHostUrlopens filtered. A URL filter state replaces the destination'sdefaultFilters, so merge them back withmergeLiveboardFilters; with noliveboardFilters, the destination opens with its defaults.refuse(reason, detail)— gives up on the click and reports it.modelViews,columnNamesandloadCubeMeta()— what the records drilldown needs to build Cube queries.columnNamesmaps each model column to the table column (Cube member) it reads from, so a column's display name can differ from its member.
Refusals, errors thrown by run and duplicate ids are reported to Sentry as Custom action "<id>" refused: <reason>, tagged component: 'useLiveboardCustomActions', customAction and refusalReason, once per action, reason and detail. With duplicate ids only the first action runs.
Built-in actions and factories
| Export | What it does |
|---|---|
recordsDrilldownAction | "Drill Down" (id salesloft-drilldown-action): the records behind the clicked point, in a RecordsTable |
kpiDrillAction | "Drill Down" (id kpi-drill): the records behind a KPI visualization, in a RecordsTable. Scoped to the Liveboard's kpiVizIds (chart type KPI or MUZE_STUDIO, TS_KPI_CHART_TYPES), and left out when it has none |
createOpenLiveboardAction({ resolveTarget }) | Opens the Liveboard or viz resolveTarget(click) returns ({ title, liveboardObjId, vizId?, runtimeFilters?, defaultFilters?, customActions? }) in the modal; null refuses the click. runtimeFilters are fixed and hidden; defaultFilters show in the filter bar and can be edited |
createNavigateAction({ resolveRoute }) | Navigates to the AppRoute resolveRoute(click) returns; null refuses the click |
Pick targets and routes from lists declared in code (like LIVEBOARDS) rather than building them from clicked values. toRuntimeFilters / toLiveboardFilters turn the clicked attributes into filters; pass columns to forward only the columns the target has, renamed as needed. mergeLiveboardFilters(base, overrides) adds the overrides to base, replacing its filters on the same column.
The analytics modal
AdvancedAnalyticsProvider renders a single modal for its subtree, so every Liveboard of a page shares it. An AnalyticsLiveboard rendered without the provider above it creates a modal of its own as a fallback. A Liveboard opened in the modal stacks its own frames on the same modal, with a back button; going back remounts the previous frame. Each frame mounts inside an error boundary: a failing frame shows DefaultErrorState and is reported (component: 'AnalyticsModal') while the Liveboard behind it stays up. useAnalyticsModal() opens it from any component below the provider.
"Drill Down" (recordsDrilldownAction)
On click, recordsDrilldownAction (through resolveRecordsQuery):
- Reads the clicked measures and attributes, plus the answer's filters (
embedAnswerData.filterGroups), from the payload. - Maps the ThoughtSpot model to a Cube view:
modelViews(read from the Liveboard's metadata) first,TS_MODEL_SL_VIEWas a fallback, and each column to<view>.<column>. - Loads the Cube meta (once per board, retried after a failure) and collects the
drillMembersdeclared by each clicked measure. - Adds the board's runtime filters (including the User filter), which ThoughtSpot leaves out of the payload, as
<view>.<columnName>filters (IN/EQ→equals,NE→notEquals). Columns the view lacks, other operators, and members already filtered by the click or the answer are skipped.kpiDrillActiondoes the same. - Opens a
RecordsTablewithdimensions: drillMembers, ameasureFilterper clicked measure, the equality filters, and date filters astimeDimensions. A clicked-point filter wins over an answer filter on the same member. Above the table, a card lists every attribute of the clicked point (selected or not) with its value from the payload, a time-bucketed one as the first day of its bucket.
A click that can't be mapped (no clicked point or measure, formula columns, unmapped model, unknown view/measure/member, no drill members, Cube meta unavailable) leaves the modal closed and is refused with that reason.
Drilldown summaries
A clicked measure that declares meta.drilldownSummary opens metric cards above its records (SummaryDrilldown) instead of a plain RecordsTable:
{
name: `count`,
alias: `sent`,
title: `Emails Sent`,
meta: {
drilldownSummary: {
titleDimension: `account_name`,
heading: `Email activity`,
measures: [
`sent`,
{ measure: `bounced`, percentage: `bounced_rate` },
{ measure: `opened`, percentage: `opened_rate` },
{ measure: `replied`, percentage: `replied_rate`, avgPerDay: `replied_per_day` },
],
},
},
}
| Field | Required | Meaning |
|---|---|---|
measures | yes, unless dimensions is set | Cards, in order: a view measure name, or { measure, percentage?, avg?, avgPerDay? }. Include the clicked measure: its card is selected first and lists the original records. |
dimensions | no | View dimensions shown together in one card above the measure cards, as label and value (e.g. account_name, account_owner, last_contacted). |
titleDimension | no | View dimension whose value prefixes the modal title (Acme - Emails Sent (7)), when the click restricts it to a single value. Otherwise the title stays Drilldown - …. |
heading | no | Heading above the cards, shown as written (not localized). |
The snake-case spellings title_dimension and avg_per_day are also accepted. Under a card's total, each declared detail measure shows its value: percentage as a percent (the measure holds a 0–1 fraction, e.g. 0.245 → 24.5%), avg as Avg 2.3, and avgPerDay as 1.4 / day, each with at most one decimal. A detail whose value is empty is left out.
A dimension can declare meta.drilldownSummary too. "Drill Down" on a table cell of that dimension (a click with no selected measure) opens its summary, scoped to the clicked value, the answer filters and the runtime filters, titled <titleDimension value> - <heading> (e.g. Acme - Account). No records list below until a measure card is selected. A dimension with no drilldownSummary is refused as before.
On a measure click, with or without a summary, each attribute of the clicked point (selected or not) whose dimension declares a drilldownSummary with dimensions also shows that summary's heading and dimensions card, above the measure's cards, or above the clicked-attributes card and the records when the measure declares no summary (e.g. the row's Account card above Activity). Its measure cards are not shown there. An invalid one is refused, and the rest of the drilldown still opens.
The dimensions card is display only. It queries the dimensions over the click's filters, without the date range, and formats each value by its Cube type: dates as Oct 1, 2026, numbers with grouping (except a dimension whose meta.type is id, shown as is, e.g. 28473), and an empty value as -. A dimension whose meta.link names another member of the view (e.g. account_name → account_url) links its value to that member's url, queried alongside but not shown; with an empty url it is plain text. It only shows when that matches exactly one record (e.g. the click is on one account); otherwise it is left out. A summary with dimensions and no measures shows only that card above the records.
The cards query the summary measures over the click's filters and date range. Clicking a card whose measure declares drillMembers swaps the table below to those records (through buildMeasureDrillQuery); other cards are not clickable. When the card's measure declares a drilldownSummary of its own (with different card measures), the click stacks a new frame with that summary instead, and the modal's back arrow returns to the previous one. Members are named relative to the view. A summary listing members absent from the view is refused with INVALID_SUMMARY, and the records open in a plain RecordsTable.
Records table cells
RecordsTable turns the Cube annotation of each queried member into a column through mapColumnsToTableFormat (utils/tableUtils):
| Member annotation | Cell |
|---|---|
meta.hidden: true | Column left out |
format: 'link' | The column's short title, linking to the cell's url |
meta.type: 'id' | The value as is, without number grouping |
meta.link naming a queried member of the view | The value, linking to that member's url in the same row; plain text when the url is empty |
A measure declaring drillMembers | Clickable, drilling into those records |
When several apply, meta.link wins over meta.type: 'id', which wins over format: 'link'. An empty value shows as -. Relative urls are resolved against the app's origin (toHostUrl). Every link opens in a new tab through NewTabLink, followed by an open-in-new-tab icon that is hidden from assistive technology, so the link's accessible name stays its text. The dimensions card links its values with the same NewTabLink.
"Drill Down" on a KPI (kpiDrillAction)
On a KPI visualization, kpiDrillAction (through resolveKpiDrillQuery) reads the KPI's measure columns and the answer's filters from embedAnswerData, maps them to Cube as above, and opens a RecordsTable with the meta.drilldown declared by each measure:
measures:
- name: count
meta:
drilldown:
dimensions: [id, owner]
measures: [duration]
filters:
- { member: duration, operator: measureFilter }
Members are named relative to the view. A KPI whose measures declare no meta.drilldown, or that lists members absent from the view, is refused with that reason.
In the records table, members with format: "link" (for example recordings.url) render as a link labelled with the member's shortTitle that opens the row's URL in a new tab; an empty value shows -.
Members with meta.hidden: true are queried but not shown as columns. A member with meta.link names the member (relative to the view, or fully qualified) holding its URL: the cell value renders as a link to that URL in a new tab, and as plain text when the URL is empty or the linked member is not queried.
A measure in the records table that declares drillMembers is clickable. The click (through buildMeasureDrillQuery) stacks a RecordsTable frame, with a back button, listing that measure's drillMembers with a measureFilter on it, the clicked row's non-time dimension values (notSet when empty), and the parent query's filters (without its measureFilters) and time dimensions.
Controlling visible actions
ThoughtSpot embeds expose a large set of toolbar and context-menu actions. You control which ones appear with visibleActions (allowlist) or hiddenActions (denylist) — pick one, never both.
Important:
visibleActions/hiddenActionsaffect the UI only. ThoughtSpot RBAC and row-level security (ts_var()) remain the authoritative authorization layer.
GENERAL_VISIBLE_ACTIONS — the read-only consumer preset
Enables AI highlights, Spotter/Ask AI, scheduling, all download formats, filtering and the personalized views dropdown — hides everything else (including native drill-down). The Liveboard's custom actions are added to it automatically:
import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'
<AnalyticsLiveboard
viewKey={LIVEBOARDS.myPage.viewKey}
liveboardObjId="<obj-id>"
visibleActions={GENERAL_VISIBLE_ACTIONS}
/>
Extending the preset
import { Action } from '@thoughtspot/visual-embed-sdk'
<AnalyticsLiveboard
viewKey={LIVEBOARDS.myPage.viewKey}
liveboardObjId="<obj-id>"
visibleActions={[
...GENERAL_VISIBLE_ACTIONS,
Action.Edit,
Action.ShareViz,
]}
/>
Full Action reference: https://developers.thoughtspot.com/docs/embed-actions
useThoughtSpotAuth
Use the hook directly only when you need full control over the auth lifecycle outside of a Liveboard context (e.g. to guard other UI):
import { useThoughtSpotAuth } from '@shared/advanced-analytics'
export function CustomPage() {
const { status, error } = useThoughtSpotAuth()
if (status === 'loading') return <MyCustomSkeleton />
if (status === 'error') return <MyCustomError error={error} />
return <MyCustomEmbed />
}
Must be rendered inside AdvancedAnalyticsProvider.
Tests
cd infrastructure/jest && JEST_RUN_ALL_TESTS=1 npx jest --no-coverage shared/advanced-analytics
| Spec | Covers |
|---|---|
StandaloneLiveboard.spec.tsx | Auth lifecycle (idle/loading/error), custom fallbacks, auth error reported once, embed errors (pre- and post-render, sanitized Sentry payload), lifecycle callbacks (onLoad, onLiveboardRendered, onFilterChanged), filter sync wiring (including defaultFilters kept off the SDK), skeleton while URL filters apply, remount on embedKey, runtimeFilters passthrough, Liveboard change reset, locale resolution, and obj_id resolution (waits for auth, skeleton while resolving, resolved GUID embedded, resolution and auth errors reported once, obj_id in error reports), custom actions wiring (definitions and handler on the embed, defaults, ids merged into visibleActions, modal provider), and embed size |
components.spec.tsx | DefaultErrorState: generic copy by default and for reason="error", maintenance copy for reason="maintenance" |
AnalyticsLiveboard.spec.tsx | The views layer: clears tsFilters on mount, provider options, views from Salesloft (default and given), toolbar and embed wiring, frame override |
LiveboardToolbar.spec.tsx | Renders its actions in order |
ThoughtSpotAuthContext.spec.tsx | SDK init once, locale resolution and fallback, failures before and after login (self-healing vs fatal), recovery |
thoughtspotAuthService.spec.ts | Token request, response mapping, empty/invalid token, request failures |
liveboardService.spec.ts | metadata/search request with and without include_details (token, body, not-ok hook), exact obj_id match, no match, request and token failures |
useLiveboardQuery.spec.tsx | Details resolved and cached under the obj_id query key, errors, disabled until auth is ready; without waiting for the details (GUID with empty details, both requested at once, details filling in, details failing alone, GUID failing); the stored Liveboard (written on success, answered first, trusted while fresh, asked again in the background when stale, not shared between users); loadDetails (known details, stale details, joining the request in flight) |
usePrefetchLiveboard.spec.tsx | Details and GUID requested once auth is ready, only the details when the embed waits for them, nothing before auth, once per mount |
liveboardCache.spec.ts | Write and read back, scope by cluster, user and obj_id, maximum age, entries that are not valid (another version, bad shape, not JSON), storage that throws |
needsLiveboardDetails.spec.ts | Only a scope function needs the details |
useLiveboardFilterUrlSync.spec.ts | URL → Liveboard (UpdateFilters on the UpdateFilters subscription, sent once, "No answer found" treated as applied, safety net with warning, legacy runtime filters ignored), readiness signals (subscription and LiveboardRendered in either order, applied once, listener removed on unmount), navigation while mounted (reload when a column is dropped, in-place update otherwise, only URL columns sent, no reload for own writes, back to the previous state after a user change, a navigation between two empty states still reports, a user read waiting for its debounce is dropped when a navigation applies a state), skeleton hold (release on success, failure and timeout; also while a navigation is applied with a listener, released at the board's report, at once when already readable, on timeout and on failure, and never without a listener), Liveboard → URL (debounce, push for user changes and replace for the hook's own writes, no-op when unchanged, custom param, disabled, state too large), default filters (sync off, URL without state, URL wins, explicit empty state, navigation, applied once, latest defaults on a new board, invalid defaults, skeleton), liveboardId change, stale responses, sanitized logs, cleanup on unmount |
filterSync.spec.ts | URL read/write (including the write-side length limit, missing vs explicitly empty state, keepEmpty), state resolution (URL vs defaults), default filter validation, validation of untrusted input (types, applicability, length and count limits, legacy runtime filters), UpdateFilters payload, GetFilters response parsing (value and date filters, runtime filters ignored, malformed entries) |
embedError.spec.ts | Allowlisted error report for Error instances, SDK payloads and trigger rejections; truncation and caps; duplicate signature; Sentry exception titles |
drilldown.spec.ts | Epoch → date range (period filters end the day before highEpoch, custom ranges on it), click → Cube members (clicked and answer filters, negation, date filters, precedence, every failure reason), drill member resolution, query building, measure drill query, DrilldownClick normalization |
useLiveboardCustomActions.spec.tsx | SDK definitions (plain fields, defaults, scope, scope resolvers), duplicate ids, stable handler, dispatch by id, loadDetails (details used on click, waiting for them, failure reported), errors and refusals reported once, Cube meta cached and retried, navigate (filters in the URL, unsafe routes refused) |
recordsDrilldownAction.spec.tsx | Records query opened in the modal, modelViews and TS_MODEL_SL_VIEW lookup, refusals (unmapped model, no drill members, meta unavailable); kpiDrillAction scope, meta.drilldown query and refusals |
factories.spec.tsx | createOpenLiveboardAction (modal-sized embed, runtime and default filters, refusal without target) and createNavigateAction |
AnalyticsModalProvider.spec.tsx | Open, close, stacked frames and back, stable API, failing frame contained and reported, nested providers reused |
AdvancedAnalyticsProvider.spec.tsx | Children rendered inside the modal provider, inside auth |
RecordsTable.spec.tsx | Rows and first-page query, loading state, link columns, meta.hidden and meta.link, measure drilldown in a stacked frame |
clickFilters.spec.ts / navigation.spec.ts | Click → runtime / Liveboard filters (column map, primitive values only), filter merging by column; in-app path validation and URL filters |
dateFilter.spec.ts | Default and partial ranges, fiscal tokens, resolution to the first and last second of the days in local time (relative, calendar year and quarter, ISO, fiscal with the tenant's periods), ranges that cannot be read or end before they start, the BW_INC runtime filter |
useDateRuntimeFilters.spec.ts | The filters the embed opens with (none while loading or unavailable), no event for the opening range, a new range sent with every filter and without changing the embed's filters, once per range, waiting for the render, failure reported and the embed rebuilt, "No answer found" counted as applied |
useDateRangeFilter.spec.ts | No filter without a date column, default and view ranges, a calendar range not waiting for the fiscal calendar, a fiscal range waiting then resolving, unavailable when the calendar cannot be loaded, picking a fiscal range after the calendar was fetched |
DateRangeFilter.spec.tsx | Opens on the last 7 days, reads and writes the range through the saved views store, offers the fiscal options |
hostParams.spec.ts | Host params read from name[] and name, written with every other param kept, removed when unset, round trip, order-insensitive signature, store values as strings |
useHostParamsUrlSync.spec.ts | Store → URL (replace on open, push after, nothing when equal, other params kept, removal), URL → store (back or a link, nothing on open, no echo, params it does not own ignored) |
viewParams.spec.ts | The date range and selections are saved with a view; other params are dropped |
Saved views specs:
| Spec | Covers |
|---|---|
useLiveboardViewBridge.spec.ts | Every view opens through its own filters, baseline and modified state (opening state, echo, change, going back, state reported before the baseline, a saved view compared with what the board reports), Reset and view switches open the view in place and wait for the next report as the new baseline, a URL that already held the view keeps the last state read as the baseline, saving in place and as a new view |
standardViews.spec.ts | One view per definition, ids equal to the position and unchanged when a definition is appended, the first definition (id 0) being the one the engine opens by default, all flagged default, filters stored as the string the Liveboard reads |
viewSections.spec.ts | A view saved as default stays out of "Views from Salesloft"; views with no default flag are flagged; order and fields kept; no mutation; a list that has not loaded yet |
filtersParam.spec.ts | Sets, replaces and removes the tsFilters param, keeps other params, no history entry when nothing changes, returns whether the URL changed |
useLiveboardFilterUrlSync.spec.ts also covers that a change to a param it does not own does not re-apply its filters, and that a host param written while a read is in flight is kept. It also covers the reported filter state (baseline, user and navigation sources, the wait for every applied filter and its values, discarding a read already in flight when a state is applied, closing it at the next change event, apply failures, no reads without a listener or with the sync off), and StandaloneLiveboard.spec.tsx that onFilterStateChange reaches the hook and stays off the SDK embed. AnalyticsLiveboard.spec.tsx also covers the date range (picker and filter only with a dateColumn, the column not passed on, a range change marking the view as modified) and keeping the host params in step with the URL. It covers the views layer: it leaves the tsFilters param alone on mount, stores the views under the viewKey without rewriting the URL, builds the views from Salesloft (one "Default" view when none are given or the array is empty), and wires the toolbar and the embed to the bridge.
useLiveboardFilterUrlSync.spec.ts uses fake timers without faking setImmediate / nextTick, because the shared jest setup replaces global.Promise with the promise polyfill, which runs on them.
Pending items
| Item | Owner | Notes |
|---|---|---|
| CORS for rhapsody origins on the ThoughtSpot cluster | ThoughtSpot admin | Required by liveboardObjId: the browser calls metadata/search directly |
Maintenance check (system/banner), removed | Analytics Aces / ThoughtSpot admin | Removed on 2026-11-03: from a browser at https://localhost.qasalesloft.com, salesloft-dev answered system/banner with the HTML "Idle Screen" page and tenant-state: Starting, with no CORS headers, while /system and /auth/session/user returned access-control-allow-origin for that origin. The docs do not say whether system/banner sends CORS headers. Before bringing it back, confirm the call from the browser on every environment, the under_maintenance: true answer and what it returns while the cluster is idle |
| Liveboards for production pages | Analytics Aces | The example pages reference demo Liveboards by obj_id |
Tab GUIDs (activeTabId) per environment | Analytics Aces | Tab GUIDs also differ between environments and are not resolved from obj_ids |
| Model → Cube view mapping for drilldowns | Analytics Aces / Backend | TS_MODEL_SL_VIEW only maps MeetingsModel; resolving per column needs an analytics-api proxy for ThoughtSpot's metadata API |
THOUGHTSPOT_URL in deploy manifests | Infra | Add to dev.env, ci.env, and prod manifests |
| analytics-api PR #2030 merged | Backend | Unblocks end-to-end testing |
| Compare each tab's numbers with its Analytics Hub report for the same range and users | Analytics Aces | Only that the tiles load and respond to the range was checked, not that the figures match |
Translate saved views that keep a date filter in tsFilters, and the views users saved in the Analytics Hub reports | Analytics Aces | Views saved before the date range filter keep their date in tsFilters, and the reports' views use from, to, group_id and account_id without tsFilters; both are ignored by the picker today |
Does UpdateRuntimeFilters add to or replace the filters the embed opened with? | Analytics Aces / ThoughtSpot | Test on a Liveboard where the user filter changes the numbers; until then every event sends the full list |
| Time zone of the day boundaries | Analytics Aces | Days use the browser's zone; confirm against the organization's zone in ThoughtSpot and the Cube view |
| Weekends off and comparison periods | Product | Not expressible as a runtime filter on a date column |
| Saved views gate on page load | Analytics Aces | The embed waits for SavedViewsProvider, the longest wait before the iframe exists on a full page load. See the saved views gate |
A new Liveboard added to LIVEBOARDS needs its view key in Melody's list | Backend | The nine current keys accepted a create on 2026-11-06 (QA). Create returns 422 for a view outside the list: see Backend |
| Default view stored in the browser | Analytics Aces / Product | Decide whether a per-browser default is enough or Melody's is_default should be used |
| ThoughtSpot access groups and RBAC | ThoughtSpot / Product | Consumption vs authoring capabilities |
Tenant RLS (ts_var) configuration | ThoughtSpot / Data | Tenant isolation validation required before production |
Environment variable
Registered in infrastructure/server/src/envs.ts:
THOUGHTSPOT_URL: {
owner: '@Salesloft/analytics-aces',
fallback: 'https://salesloft-clari.thoughtspot.cloud',
},
Backend contract
| Property | Value |
|---|---|
| Service | analytics-api |
| Endpoint | GET /v2/thoughtspot/embed_token |
| Auth | Salesloft Bearer token (resolved server-side) |
| Response | { token: string, expires_in: number } |
| Reference | analytics-api PR #2030 |
Saved views of a Liveboard use a third service:
| Property | Value |
|---|---|
| Service | Melody public API (${API_GATEWAY_URL}/v2), called by @shared/saved-views |
| Endpoints | GET, POST saved_list_views.json, PUT, DELETE saved_list_views/:id.json, no new ones |
view | One advanced_analytics_* key per Liveboard (LIVEBOARDS.<name>.viewKey); all nine accepted a create on 2026-11-06 (QA) |
| Payload | view_params: { tsFilters: "<JSON string>", view_created_by: <user id> } |
References
| Topic | URL |
|---|---|
| Visual Embed SDK — getting started | https://developers.thoughtspot.com/docs/tsembed |
| Trusted Authentication | https://developers.thoughtspot.com/docs/trusted-auth-secret-key |
| Embed auth overview | https://developers.thoughtspot.com/docs/embed-auth |
| React embed components | https://developers.thoughtspot.com/docs/react-app-embed |
| Liveboard embed | https://developers.thoughtspot.com/docs/embed-liveboard |
Supported locales (locale prop) | https://developers.thoughtspot.com/docs/set-locale |
| Runtime filters | https://developers.thoughtspot.com/docs/runtime-filters |
Actions (visibleActions / hiddenActions) | https://developers.thoughtspot.com/docs/embed-actions |
| EmbedEvent reference | https://developers.thoughtspot.com/docs/embed-events |
| SDK changelog | https://developers.thoughtspot.com/docs/changelog |
Design system Box | https://salesloft.design/docs/components/box |
Design system Text | https://salesloft.design/docs/components/text |