Advanced Analytics
Overview
This document describes the frontend implementation for embedding ThoughtSpot analytics into rhapsody using ThoughtSpot's Trusted Authentication (Trusted Auth) flow. Everything lives in @shared/advanced-analytics — auth context, hooks, low-level SDK wrappers, and the high-level AnalyticsLiveboard component that any app can drop in directly.
@app/advanced-analytics is the first consumer: three flag-gated routes that exercise the embed end to end. See Host app.
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
└── src/
├── index.ts
├── types.ts
├── constants.ts
├── services/thoughtspot/
│ └── thoughtspotAuthService.ts
├── context/
│ └── ThoughtSpotAuthContext.tsx
├── hooks/
│ ├── useThoughtSpotAuth.ts
│ ├── useEventCallback.ts ← stable listener identities for the SDK
│ ├── useLiveboardUrlSync.ts ← tab ⇄ URL synchronization
│ └── useLiveboardUrlParams.ts ← React Router v5 query param adapter
├── utils/
│ └── liveboardUrlState.ts ← query param key + route parsing
└── components/
├── AnalyticsLiveboard/ ← recommended entry point for pages
├── ThoughtSpotLiveboard/ ← low-level SDK wrapper
├── ThoughtSpotSearch/
└── ThoughtSpotSpotter/
The app that consumes it:
apps/advanced-analytics/
├── entry.ts ← routes, nav items, shortcuts, feature flag
└── src/
├── bridge-loaders/routesBridge.tsx
└── pages/
├── AdvancedAnalytics/ ← flag guard + auth provider + <Switch>
├── SimpleExample/
├── TabsExample/
└── FiltersExample/
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. Each route is a minimal page that renders AnalyticsLiveboard against a different Liveboard.
Routes
Registered in apps/advanced-analytics/entry.ts via registerRoutes, all resolved through one bridge loader (src/bridge-loaders/routesBridge.tsx → AdvancedAnalytics).
| Route | Page | Liveboard | Notes |
|---|---|---|---|
/advanced-analytics/simple | SimpleExample | dfe15989-…21639 | urlSync with an explicit defaultTabId |
/advanced-analytics/tabs | TabsExample | d5f75047-…0e395 | urlSync without defaultTabId — first tab wins |
/advanced-analytics/filters | FiltersExample | c31b5ec1-…cc86b | Liveboard with persisted filters; filter state is not synced to the URL |
All three pages call useLiveboardUrlParams() and pass GENERAL_VISIBLE_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.
Global navigation
Three items under the advancedAnalytics category, with keyboard shortcuts:
| Name | Target | Order | Shortcut |
|---|---|---|---|
| Simple | /advanced-analytics/simple | 1 | a 1 |
| Tabs | /advanced-analytics/tabs | 2 | a 2 |
| Filters | /advanced-analytics/filters | 3 | a 3 |
Page shape
Every page is the same four lines — the package absorbs auth, loading, error and URL sync:
import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
useLiveboardUrlParams,
} from '@shared/advanced-analytics'
const LIVEBOARD_ID = 'c31b5ec1-0c4a-43dc-b7cf-ce487e3cc86b'
const DEFAULT_TAB_ID = '20335746-9db3-4a00-9d4b-43e8f2cd1e92'
export function SimpleExample() {
const { urlState, setUrlState } = useLiveboardUrlParams()
return (
<AnalyticsLiveboard
liveboardId={LIVEBOARD_ID}
visibleActions={GENERAL_VISIBLE_ACTIONS}
urlSync={{
urlState,
onUrlStateChange: setUrlState,
defaultTabId: DEFAULT_TAB_ID,
}}
/>
)
}
Token flow and rotation
Token refresh is driven by the SDK, not a manual timer. There is no client-side
token cache anywhere in this package — analytics-api already caches short-lived
tokens server-side per (tenant, user), and duplicating that on the frontend
would risk handing the same single-use token to two embeds at once.
ThoughtSpotAuthProvider mounts
│
└─ init() called once with a getAuthToken callback (synchronous).
status becomes 'ready' immediately — the SDK does not need a completed
handshake to start rendering.
│
├─ 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.
No upfront probe request. The provider issues no token request of its own before init(). An earlier version fetched a token up front purely to fail fast; that token was discarded and the SDK immediately fetched another, so it produced a duplicate GET /v2/thoughtspot/embed_token and put a full round-trip in front of the entire load — the skeleton stayed up for the whole probe before the iframe could even start. Auth failures are now surfaced by the SDK's own AuthStatus.FAILURE event and by the first getAuthToken rejection, which report the same condition at no cost.
Only the first handshake collapses the UI. Once a token has been issued the embed is live; a later refresh failure is left to the SDK to retry rather than blanking a rendered board.
callPrefetch: true is set on init() so ThoughtSpot's static assets warm in parallel with the handshake.
Token rotation requirement: ThoughtSpot Trusted Auth tokens are single-use. Returning the same token string twice triggers a "Duplicate token" error. getAuthToken therefore always calls fetchThoughtSpotToken() directly, uncached, on every invocation.
New API client: thoughtSpotApi
Added to @shared/requests.
export const thoughtSpotApi = createInstance({
prefixUrl: () => `${window.ENV.ANALYTICS_API}/v2/thoughtspot`,
hooks: {
beforeRequest: [includeUserTokenHook, requestorSourceHook],
afterResponse: [showErrorModal],
},
})
Same host as analyticsApi, different namespace (/v2/thoughtspot vs /v2/analytics).
Embedded components
All components require ThoughtSpotAuthProvider to be mounted above them in the tree.
ThoughtSpotLiveboard, ThoughtSpotSearch, and ThoughtSpotSpotter are built on the official SDK React components from @thoughtspot/visual-embed-sdk/react. This means:
- No manual
useEffect, DOM refs, or.render()calls — the SDK manages the iframe lifecycle. - Props update reactively (the SDK uses deep-compare internally, so passing objects like
runtimeFiltersdirectly is safe). - Event handlers are passed as
onEventNameprops instead of chained.on(EmbedEvent.X, ...)calls.
AnalyticsLiveboard ← recommended for pages
The high-level wrapper for pre-built dashboards. Handles the auth lifecycle internally so pages only need a liveboardId. See the Integration guide for full usage and override props.
import { AnalyticsLiveboard } from '@shared/advanced-analytics'
<AnalyticsLiveboard liveboardId="<liveboard-guid>" />
ThoughtSpotLiveboard
SDK React component wrapper for pre-built dashboards. Does not manage loading or error states — use this only when you need full control over the surrounding UI, or when building something other than a standard page embed.
<ThoughtSpotLiveboard
liveboardId="<liveboard-guid>"
vizId="<optional-viz-guid>" // pin to a single visualization
runtimeFilters={[...]} // UI context filters (not authorization)
visibleActions={[...]} // mutually exclusive with hiddenActions
hiddenActions={[...]} // mutually exclusive with visibleActions
/>
ThoughtSpotSearch
For search and Answers.
<ThoughtSpotSearch
dataSourceIds={['<worksheet-guid>']}
searchQuery="revenue by region"
hideDataSources={true}
runtimeFilters={[...]}
visibleActions={[...]}
/>
ThoughtSpotSpotter
For conversational analytics (Spotter AI).
<ThoughtSpotSpotter
worksheetId="<worksheet-guid>"
visibleActions={[...]}
/>
visibleActions/hiddenActions: Mutually exclusive — configure one or the other, never both. They shape the UI only; ThoughtSpot RBAC enforces authorization regardless of what is shown.
runtimeFilters: Carry Salesloft UI context into the analysis but are not an authorization control. ThoughtSpot RLS (ts_var()) remains the authoritative tenant isolation layer.
SDK events handled
| Event | Behavior |
|---|---|
AuthInit | Token handshake initiated (expected on load and after expiry) |
AuthExpire | Logs a warning; SDK automatically calls getAuthToken to refresh |
Load | iframe loaded successfully |
LiveboardRendered | AnalyticsLiveboard removes the skeleton overlay and reveals the iframe content. useLiveboardUrlSync also uses it as the gate that unlocks host events |
RouteChange | Source of the active tab GUID — the SDK has no dedicated tab event, so the tab is parsed out of data.currentPath |
Error | Captured to Sentry for engineering visibility. Does not collapse the UI — ThoughtSpot fires onError for recoverable conditions (auth handshake retries, non-fatal init warnings) during normal load. Only ThoughtSpotAuthContext failures (status === 'error') surface the error UI. |
Host events triggered into the iframe (via the embed ref):
| Host event | Trigger |
|---|---|
SetActiveTab | The URL carries a tab different from the one the embed is showing |
URL synchronization (tabs)
The active Liveboard tab is kept in sync with the browser query string in both directions, so a Liveboard view can be linked, bookmarked, and restored on reload.
| Query param | Content |
|---|---|
ts_tab | GUID of the active tab |
iframe AnalyticsLiveboard (urlSync) useLiveboardUrlParams
│ │ │
│ RouteChange │ │
├─────────────────────────────>│ onUrlStateChange(next) │
│ ├───────────────────────────────>│ history.replace(?ts_tab=…)
│ │ │
│ │ urlState (new props) │
│ SetActiveTab │<───────────────────────────────┤
│<─────────────────────────────┤ │
The urlSync prop ← recommended
AnalyticsLiveboard runs the synchronization internally. Pages only pass the URL state and the setter — no hook call, no prop spreading:
<AnalyticsLiveboard
liveboardId={LIVEBOARD_ID}
urlSync={{
urlState, // LiveboardUrlState read from the query string
onUrlStateChange, // called when the iframe reports a change
defaultTabId, // tab used when the URL carries none
}}
/>
Omitting urlSync leaves the component untouched — the sync stays inert and ts_tab is never written.
Your own onLiveboardRendered and onRouteChange props still work while urlSync is active; the component calls the internal handler first and then yours.
useLiveboardUrlSync
The hook behind urlSync, exported for ThoughtSpotLiveboard or a custom wrapper. Router-agnostic — it never touches window.location. The caller supplies the current state and a setter, which keeps the package usable from React Router v5, v6, or any other router.
const { liveboardProps, embedRef } = useLiveboardUrlSync({
urlState,
onUrlStateChange,
defaultTabId,
})
liveboardProps is spread onto the Liveboard component and carries ref, activeTabId, onLiveboardRendered and onRouteChange. embedRef is exposed for callers that need to trigger additional host events.
Four behaviors are worth knowing about:
activeTabIdis frozen on mount. It is only the initial tab. Later tab changes go throughHostEvent.SetActiveTab, because changing the prop would remount the iframe and reload the whole Liveboard.- Every listener is wrapped in
useEventCallback. The SDK mounts the embed insideuseDeepCompareEffect(..., [viewConfig, listeners]), and deep comparison falls back to reference equality for functions. A handler that gets a new identity on re-render destroys the embed and builds a new one — the iframe visibly reloads and the just-selected tab snaps back. - Host events are gated on
LiveboardRendered. Triggering before the embed is ready is a no-op in the SDK. - Loop guards. The last tab pushed in either direction is recorded, so an update coming from the iframe is never echoed back into it, and vice versa.
Router adapter (React Router v5)
useLiveboardUrlParams ships with the package and binds LiveboardUrlState to
the query string. It reads ts_tab from useLocation and writes it back with
history.replace — not push — so interacting with the Liveboard does not fill
the browser history. Params other than the Liveboard ones are left untouched.
Consumers that use a different router (or none) can skip it and pass their own
urlState / onUrlStateChange pair to urlSync.
Full page example
import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
useLiveboardUrlParams,
} from '@shared/advanced-analytics'
export function SimpleExample() {
const { urlState, setUrlState } = useLiveboardUrlParams()
return (
<AnalyticsLiveboard
liveboardId={LIVEBOARD_ID}
visibleActions={GENERAL_VISIBLE_ACTIONS}
urlSync={{
urlState,
onUrlStateChange: setUrlState,
defaultTabId: DEFAULT_TAB_ID,
}}
/>
)
}
Exported helpers
| Export | Purpose |
|---|---|
AnalyticsLiveboard's urlSync prop | Runs the synchronization inside the component |
useLiveboardUrlSync | The synchronization hook, for custom wrappers |
LIVEBOARD_TAB_PARAM | Query param key (ts_tab) |
LiveboardUrlState | State type |
ThoughtSpotLiveboardRef | Embed handle exposing trigger(hostEvent, payload) |
Integration guide
1. Wrap routes with the provider
Mount ThoughtSpotAuthProvider once above all ThoughtSpot pages. It initializes the SDK and manages token rotation for the entire subtree. Guard the subtree with the feature flag at the same level.
import { Route, Switch, Redirect } from 'react-router-dom'
import { hasFeatureFlag } from '@rhapsody/feature-flags'
import { ThoughtSpotAuthProvider } from '@shared/advanced-analytics'
export const AdvancedAnalytics = () => {
if (!hasFeatureFlag('advanced_analytics')) {
return <Redirect to="/" />
}
return (
<ThoughtSpotAuthProvider>
<Switch>
<Route exact path="/advanced-analytics/simple" component={SimpleExample} />
<Route exact path="/advanced-analytics/tabs" component={TabsExample} />
<Route exact path="/advanced-analytics/filters" component={FiltersExample} />
</Switch>
</ThoughtSpotAuthProvider>
)
}
2. Use AnalyticsLiveboard in pages (recommended)
AnalyticsLiveboard is exported from @shared/advanced-analytics and handles the ThoughtSpot auth lifecycle internally. Any app that imports this package gets the same loading skeleton, friendly error state, and Sentry reporting out of the box — for free.
liveboardId is the only required prop. All other ThoughtSpotLiveboardProps are optional and forwarded as-is.
import { AnalyticsLiveboard } from '@shared/advanced-analytics'
export function SimpleExample() {
return <AnalyticsLiveboard liveboardId="<liveboard-guid>" />
}
Internally, AnalyticsLiveboard handles:
| Auth state | Default output |
|---|---|
loading | Pulsing dashboard skeleton (KPI cards + chart + table rows) |
error | Starlight EmptyState with DisconnectedImage pictogram, localized title and body + captureException to Sentry (tagged with liveboardId) |
ready | <ThoughtSpotLiveboard liveboardId={...} {...rest} /> with our skeleton overlaid until onLiveboardRendered fires |
Once auth is ready, the embed itself tracks a mutually exclusive lifecycle (EmbedStatus), reset whenever liveboardId changes:
| Embed status | Meaning | Default output |
|---|---|---|
pending | Mounted, no render confirmed yet | Embed mounted with the skeleton overlaid |
rendered | onLiveboardRendered fired | Embed, no overlay |
failed | An unrenderable error arrived before any render | Error state instead of the embed |
The pending → failed transition is guarded on purpose: an unrenderable error that arrives after a successful render is reported to Sentry but leaves the board on screen, because the SDK reports single-visualization failures through the same onError channel and blanking a board the user is already working with would be a regression.
Override props
Both states can be replaced per-usage with optional props. The Sentry call on error is always fired internally regardless of errorFallback:
// Replace only the loading state
<AnalyticsLiveboard
liveboardId="<guid>"
loadingFallback={<MySpinner />}
/>
// Replace only the error state
<AnalyticsLiveboard
liveboardId="<guid>"
errorFallback={<MyErrorBanner />}
/>
// Replace both
<AnalyticsLiveboard
liveboardId="<guid>"
loadingFallback={<MySpinner />}
errorFallback={<MyErrorBanner />}
/>
| Prop | Type | Required | Description |
|---|---|---|---|
liveboardId | string | ✅ | ThoughtSpot Liveboard GUID. Must be the bare GUID — never append /tab/<guid>. |
activeTabId | string | GUID of the tab the Liveboard opens on. Read once on mount — later changes require HostEvent.SetActiveTab (see URL synchronization). With urlSync set it acts as the last fallback, after urlState.tabId and urlSync.defaultTabId. | |
urlSync | UseLiveboardUrlSyncParams | Enables tab ⇄ query param synchronization inside the component. Takes urlState, onUrlStateChange and an optional defaultTabId (see URL synchronization). | |
loadingFallback | ReactNode | Replaces the default skeleton. | |
errorFallback | ReactNode | Replaces the default error message. Sentry capture still fires. | |
vizId | string | Pin the embed to a single visualization inside the Liveboard. | |
runtimeFilters | RuntimeFilter[] | UI context filters applied at query time. Not an authorization control — ThoughtSpot RLS (ts_var()) is the authoritative isolation layer. | |
visibleActions | Action[] | Allowlist of actions shown in the UI. Mutually exclusive with hiddenActions. | |
hiddenActions | Action[] | Denylist of actions hidden from the UI. Mutually exclusive with visibleActions. |
3. Low-level alternative: 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, or to build a different kind of embed):
import { ThoughtSpotLiveboard, 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 <ThoughtSpotLiveboard liveboardId="<liveboard-guid>" />
}
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 regardless of what is shown or hidden.
GENERAL_VISIBLE_ACTIONS — the read-only consumer preset
@shared/advanced-analytics exports a curated preset for standard read-only pages. It enables AI highlights, Spotter/Ask AI, scheduling, all download formats, drill-down, filtering, and the personalized views dropdown — and hides everything else (edit, share, manage, etc.):
import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'
<AnalyticsLiveboard
liveboardId="<guid>"
visibleActions={GENERAL_VISIBLE_ACTIONS}
/>
Current contents of GENERAL_VISIBLE_ACTIONS (defined in constants.ts):
import { Action } from '@thoughtspot/visual-embed-sdk'
export const GENERAL_VISIBLE_ACTIONS: Action[] = [
Action.AskAi,
Action.AddFilter,
Action.AIHighlights,
Action.Download,
Action.DownloadAsPdf,
Action.DownloadAsCsv,
Action.DownloadAsXlsx,
Action.DownloadAsPng,
Action.DrillDown,
Action.Schedule,
Action.SchedulesList,
Action.PersonalizedViewsDropdown,
]
Extending the preset for a specific page
Spread GENERAL_VISIBLE_ACTIONS and add the extra Action.* values you need:
import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'
import { Action } from '@thoughtspot/visual-embed-sdk'
<AnalyticsLiveboard
liveboardId="<guid>"
visibleActions={[
...GENERAL_VISIBLE_ACTIONS,
Action.Edit, // allow editing for this specific page
Action.ShareViz, // allow sharing individual visualizations
]}
/>
Creating a role-based action set
If different roles should see different actions, compute the list before rendering. Keep the logic co-located with the page — do not add role branches to constants.ts:
import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'
import { Action } from '@thoughtspot/visual-embed-sdk'
import { hasPermission } from '@rhapsody/permissions'
function useAnalyticsActions(): Action[] {
// Start from the read-only baseline
const actions = [...GENERAL_VISIBLE_ACTIONS]
// Managers and admins can also edit liveboards
if (hasPermission('manage_analytics') || hasPermission('admin_analytics')) {
actions.push(Action.Edit, Action.EditDetails)
}
// Admins can also share and manage access
if (hasPermission('admin_analytics')) {
actions.push(Action.Share, Action.ManageMonitor)
}
return actions
}
export function AnalyticsPage() {
const visibleActions = useAnalyticsActions()
return (
<AnalyticsLiveboard
liveboardId="<guid>"
visibleActions={visibleActions}
/>
)
}
Full Action reference
All available values are in the ThoughtSpot SDK enum. You can also browse them in your IDE from the import:
import { Action } from '@thoughtspot/visual-embed-sdk'
// Action.Edit, Action.Share, Action.Download, Action.DrillDown, ...
Full reference: https://developers.thoughtspot.com/docs/embed-actions
Tests
pnpm test -- shared/advanced-analytics
| Spec | Covers |
|---|---|
utils/liveboardUrlState.spec.ts | Tab extraction from the RouteChange path |
hooks/useLiveboardUrlSync.spec.ts | Initial tab resolution, activeTabId freezing, embed → URL publishing, URL → embed host events, and the loop guards in both directions |
utils/embedError.spec.ts | Embed-error classification — unrenderable error codes vs. recoverable ones, GRAPHQL_API_ERRORS message matching, allowlist sanitization (toEmbedErrorReport) and signature dedup |
components/AnalyticsLiveboard/AnalyticsLiveboard.spec.tsx | Sanitized Sentry reporting and dedup, state reset on liveboardId change, forwarded-ref cleanup, and auth status rendering |
Pending items
| Item | Owner | Notes |
|---|---|---|
| Liveboard / Worksheet GUIDs for production pages | Analytics Aces | The example pages hardcode demo GUIDs (SimpleExample, TabsExample, FiltersExample) |
| Filter ⇄ URL synchronization | Analytics Aces | Only the tab is synced today; ts_filters was removed pending a reliable filter contract |
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 |
| 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',
},
References
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 |