Files
insights/products/mcp_analytics/frontend/mcpAnalyticsOnboardingLogic.ts
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

261 lines
12 KiB
TypeScript

import { MakeLogicType, afterMount, connect, kea, listeners, path, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { productSetupStatusLogic } from 'lib/components/ProductEmptyState/productSetupStatusLogic'
import type { ProductSetupStatus } from 'lib/components/ProductEmptyState/types'
import { addProductIntent } from 'lib/utils/product-intents'
import { userLogic } from 'scenes/userLogic'
import { InsightsQLQueryResponse, NodeKind, ProductIntentContext, ProductKey } from '~/queries/schema/schema-general'
import { asNumber } from './queryResultParsers'
// These intents fire on view, not on a user action, so during impersonation they'd register
// staff navigation as the team's intent — and the read-only middleware rejects the write (403),
// toasting on every mount. Skip firing while impersonating.
function isImpersonatedSession(): boolean {
return userLogic.findMounted()?.values.user?.is_impersonated ?? false
}
export type MCPOnboardingState = 'not-instrumented' | 'connected-no-calls' | 'onboarded'
/** Maps MCP's three-state model onto the app-wide product setup vocabulary. */
export function emptyStateStatusForOnboardingState(onboardingState: MCPOnboardingState | null): ProductSetupStatus {
if (onboardingState === null) {
return 'loading'
}
if (onboardingState === 'onboarded') {
return 'has-data'
}
if (onboardingState === 'connected-no-calls') {
return 'waiting-for-data'
}
return 'needs-setup'
}
/**
* Which question the dashboard answers, by volume. `activity` is for "what are
* agents doing with my server?" — a live feed, verbatim intents, and an
* instrumentation checklist; windowed metrics would be noise at this volume.
* `metrics` is the standard dashboard for "is it healthy?". Volume-gated rather
* than time-gated so a low-traffic server never regresses to an empty dashboard.
*/
export type MCPDashboardStage = 'activity' | 'metrics'
/** Metrics and trends unlock on lifetime volume… */
export const METRICS_UNLOCK_LIFETIME_CALLS = 300
/** …or on sustained density, whichever comes first. */
export const METRICS_UNLOCK_7D_CALLS = 250
export interface MCPOnboardingSignals {
/** A client has completed the MCP handshake — proves the SDK wrap is live. */
hasInitialize: boolean
/** A tool has actually been called — the server is in use. */
hasToolCall: boolean
/** Lifetime `$mcp_tool_call` count — drives the activity/metrics stage gate. */
toolCallsTotal: number
/** `$mcp_tool_call` count over the last 7 days — the density half of the gate. */
toolCalls7d: number
/** Timestamp of the first tool call, for "since June 30" copy. Null before the first call. */
firstCallAt: string | null
}
// Signal funnel over all history. `$mcp_initialize` fires when an agent connects
// (before any tool call), so it distinguishes "instrumented but no traffic yet"
// from "not instrumented at all". Counts (not booleans) so the same query drives
// the activity/metrics stage gate. Deliberately no `properties.*` access: event-name +
// timestamp filters hit the events sort key, so this stays cheap even on projects
// with millions of MCP events — property-derived stats live in mcpEarlyDataLogic,
// whose queries are time-bounded.
const ONBOARDING_SIGNAL_QUERY = `
SELECT
countIf(event = '$mcp_initialize') > 0 AS has_initialize,
countIf(event = '$mcp_tool_call') AS tool_calls_total,
countIf(event = '$mcp_tool_call' AND timestamp >= now() - INTERVAL 7 DAY) AS tool_calls_7d,
minIf(timestamp, event = '$mcp_tool_call') AS first_call_at
FROM events
WHERE event IN ('$mcp_initialize', '$mcp_tool_call')
`
// While the user is onboarding (or watching the activity stage fill in) we re-check
// on a timer so the page advances on its own as events land. The disposables plugin
// pauses this on hidden tabs and tears it down on unmount.
const POLL_INTERVAL_MS = 20000
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface mcpAnalyticsOnboardingLogicValues {
setupStatus: ProductSetupStatus // productSetupStatusLogic
dashboardStage: MCPDashboardStage | null
isOnboarded: boolean
onboardingState: MCPOnboardingState | null
signals: MCPOnboardingSignals | null
signalsLoading: boolean
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface mcpAnalyticsOnboardingLogicActions {
setDetectedStatus: (status: ProductSetupStatus) => {
status: ProductSetupStatus
} // productSetupStatusLogic
loadSignals: (_: void) => void
loadSignalsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadSignalsSuccess: (
signals: MCPOnboardingSignals,
payload?: void
) => {
signals: MCPOnboardingSignals
payload?: void
}
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface mcpAnalyticsOnboardingLogicMeta {
__keaTypeGenInternalSelectorTypes: {
onboardingState: (signals: MCPOnboardingSignals | null) => MCPOnboardingState | null
isOnboarded: (onboardingState: MCPOnboardingState | null) => boolean
dashboardStage: (signals: MCPOnboardingSignals | null) => MCPDashboardStage | null
}
}
export type mcpAnalyticsOnboardingLogicType = MakeLogicType<
mcpAnalyticsOnboardingLogicValues,
mcpAnalyticsOnboardingLogicActions,
Record<string, any>,
mcpAnalyticsOnboardingLogicMeta
>
export const mcpAnalyticsOnboardingLogic = kea<mcpAnalyticsOnboardingLogicType>([
path(['products', 'mcp_analytics', 'frontend', 'mcpAnalyticsOnboardingLogic']),
connect(() => ({
actions: [productSetupStatusLogic({ productKey: ProductKey.MCP_ANALYTICS }), ['setDetectedStatus']],
values: [productSetupStatusLogic({ productKey: ProductKey.MCP_ANALYTICS }), ['status as setupStatus']],
})),
loaders({
signals: {
__default: null as MCPOnboardingSignals | null,
loadSignals: async (_: void, breakpoint): Promise<MCPOnboardingSignals> => {
// Force a fresh calculation instead of reading a cached result. The first
// events on a new project land a beat after capture returns 200, so a poll
// during that gap caches `[0,0]` — and with the default cache TTL the page
// would then keep serving that stale "not onboarded" answer for up to a
// minute after the data is actually queryable, dulling the "you're connected!"
// moment. Forcing keeps the flip near-instant. Polling stops once the project
// graduates to the full dashboard (see loadSignalsSuccess), so this is bounded
// to the onboarding + activity window, and the query itself is cheap (event-name
// counts on the sort key).
const response = (await api.query(
{
kind: NodeKind.InsightsQLQuery,
query: ONBOARDING_SIGNAL_QUERY,
},
{ refresh: 'force_blocking' }
)) as InsightsQLQueryResponse
breakpoint()
const row = (response?.results?.[0] as unknown[] | undefined) ?? []
const toolCallsTotal = asNumber(row[1])
return {
hasInitialize: asNumber(row[0]) > 0,
hasToolCall: toolCallsTotal > 0,
toolCallsTotal,
toolCalls7d: asNumber(row[2]),
// minIf() returns the epoch sentinel when nothing matched — only trust it
// once we know at least one call exists.
firstCallAt: toolCallsTotal > 0 && typeof row[3] === 'string' ? row[3] : null,
}
},
},
}),
selectors({
onboardingState: [
(s) => [s.signals],
(signals: MCPOnboardingSignals | null): MCPOnboardingState | null => {
if (!signals) {
return null
}
if (signals.hasToolCall) {
return 'onboarded'
}
if (signals.hasInitialize) {
return 'connected-no-calls'
}
return 'not-instrumented'
},
],
isOnboarded: [
(s) => [s.onboardingState],
(onboardingState: MCPOnboardingState | null): boolean => onboardingState === 'onboarded',
],
dashboardStage: [
(s) => [s.signals],
(signals: MCPOnboardingSignals | null): MCPDashboardStage | null => {
if (!signals || !signals.hasToolCall) {
return null
}
return signals.toolCallsTotal >= METRICS_UNLOCK_LIFETIME_CALLS ||
signals.toolCalls7d >= METRICS_UNLOCK_7D_CALLS
? 'metrics'
: 'activity'
},
],
}),
listeners(({ actions, values, cache }) => ({
loadSignalsFailure: () => {
// A failing signal query must not strand the empty-state gate on its
// spinner. If nothing (preload included) has answered yet, publish
// `unknown` so the gate fails open to the real scene - the poll keeps
// retrying and a later success replaces this. A failure never downgrades
// an existing answer.
if (values.setupStatus === 'loading') {
actions.setDetectedStatus('unknown')
}
},
loadSignalsSuccess: () => {
// Feed the app-wide setup-status layer (drives the scene empty-state gate).
actions.setDetectedStatus(emptyStateStatusForOnboardingState(values.onboardingState))
// Mark the diagnostic middle of the funnel: the SDK is connected but no
// tool calls have landed yet. Separates an install problem (never
// instrumented) from a traffic problem (wired up, but the server isn't
// being called). Registered once per mount so the poll doesn't repeat it.
if (
values.onboardingState === 'connected-no-calls' &&
!cache.registeredConnected &&
!isImpersonatedSession()
) {
cache.registeredConnected = true
void addProductIntent({
product_type: ProductKey.MCP_ANALYTICS,
intent_context: ProductIntentContext.MCP_ANALYTICS_CONNECTED,
})
}
// Keep polling through the activity stage so the summary advances live;
// stop for good once the volume gate graduates the project to metrics.
if (values.dashboardStage === 'metrics') {
cache.disposables.dispose('poll')
}
},
})),
afterMount(({ actions, cache }) => {
actions.loadSignals()
// Register product intent so activation lands in the standard cross-customer
// funnel (`has_activated_mcp_analytics` flips once tool calls arrive too).
if (!isImpersonatedSession()) {
void addProductIntent({
product_type: ProductKey.MCP_ANALYTICS,
intent_context: ProductIntentContext.MCP_ANALYTICS_VIEWED,
})
}
cache.disposables.add(() => {
const id = window.setInterval(() => actions.loadSignals(), POLL_INTERVAL_MS)
return () => clearInterval(id)
}, 'poll')
}),
])