mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
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.
1059 lines
41 KiB
TypeScript
1059 lines
41 KiB
TypeScript
import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
|
|
import { loaders } from 'kea-loaders'
|
|
import { actionToUrl, router, urlToAction } from 'kea-router'
|
|
|
|
import api from 'lib/api'
|
|
import { isValidPropertyFilter } from 'lib/components/PropertyFilters/utils'
|
|
import { getDefaultInterval } from 'lib/utils/dateFilters'
|
|
import { teamLogic } from 'scenes/teamLogic'
|
|
import { urls } from 'scenes/urls'
|
|
|
|
import {
|
|
InsightsQLFilters,
|
|
InsightsQLQueryResponse,
|
|
MCPHarnessBreakdownItem,
|
|
MCPToolCallBreakdownItem,
|
|
MCPToolCallsAndErrorsItem,
|
|
NodeKind,
|
|
} from '~/queries/schema/schema-general'
|
|
import { AnyPropertyFilter, IntervalType, TeamType } from '~/types'
|
|
|
|
import type { TeamPublicType } from '../../../frontend/src/types'
|
|
import { mcpClusteringLogic } from './clustering/mcpClusteringLogic'
|
|
import type { MCPIntentClusterApi } from './generated/api.schemas'
|
|
import { BUCKET_FORMAT, buildBucketKeys, lastBucketIsInProgress, normalizeBucket, resolveWindow } from './timeBuckets'
|
|
|
|
export interface DateFilter {
|
|
dateFrom: string | null
|
|
dateTo: string | null
|
|
}
|
|
|
|
const DEFAULT_DATE_FILTER: DateFilter = { dateFrom: '-7d', dateTo: null }
|
|
|
|
// KPI tiles compare the selected window against the immediately preceding window
|
|
// of equal length. The current/previous split is applied in `buildKPIs` against
|
|
// the time buckets, so the query only needs the doubled date range. `__BUCKET__`
|
|
// is replaced with a dateTrunc at the active interval at call time.
|
|
//
|
|
// Key on the canonical event only — also matching the legacy `mcp_tool_call` alias would double-count.
|
|
const KPI_QUERY = `
|
|
SELECT
|
|
__BUCKET__ AS bucket,
|
|
countDistinctIf($session_id, $session_id != '') AS sessions,
|
|
count() AS tool_calls,
|
|
countIf(toBool(properties.$mcp_is_error)) AS errors,
|
|
round(quantile(0.95)(toFloat(properties.$mcp_duration_ms))) AS p95
|
|
FROM events
|
|
WHERE event = '$mcp_tool_call'
|
|
AND properties.$mcp_tool_name IS NOT NULL
|
|
AND properties.$mcp_tool_name != ''
|
|
AND {filters}
|
|
GROUP BY bucket
|
|
ORDER BY bucket
|
|
`
|
|
|
|
// Distinct MCP users for the "Users" tile — how many distinct people made tool calls.
|
|
// Counted over the doubled window like the KPI query, then split into the selected period
|
|
// and its equal-length predecessor with a single conditional uniq so the comparison is a
|
|
// true distinct-person count (summing per-bucket distinct users would over-count anyone
|
|
// active on more than one day). `__CUR_START__` is the selected-period boundary, injected
|
|
// as a timezone-aware toDateTime at call time.
|
|
const USERS_QUERY = `
|
|
SELECT
|
|
uniqIf(person_id, timestamp >= __CUR_START__) AS current_users,
|
|
uniqIf(person_id, timestamp < __CUR_START__) AS prior_users
|
|
FROM events
|
|
WHERE event = '$mcp_tool_call'
|
|
AND properties.$mcp_tool_name IS NOT NULL
|
|
AND properties.$mcp_tool_name != ''
|
|
AND {filters}
|
|
`
|
|
|
|
// Per-session rollup powering the Notable sessions block. The selector
|
|
// applies fixed rules over this set; no per-rule SQL.
|
|
const SESSION_ROWS_QUERY = `
|
|
SELECT
|
|
$session_id AS session_id,
|
|
count() AS tool_calls,
|
|
countIf(toBool(properties.$mcp_is_error)) AS errors,
|
|
round(countIf(toBool(properties.$mcp_is_error)) * 100.0 / count(), 1) AS error_rate_pct,
|
|
dateDiff('second', min(timestamp), max(timestamp)) AS duration_seconds,
|
|
uniq(toString(properties.$mcp_tool_name)) AS distinct_tools,
|
|
max(timestamp) AS last_seen
|
|
FROM events
|
|
WHERE event = '$mcp_tool_call'
|
|
AND $session_id != ''
|
|
AND properties.$mcp_tool_name IS NOT NULL
|
|
AND properties.$mcp_tool_name != ''
|
|
AND {filters}
|
|
GROUP BY session_id
|
|
HAVING tool_calls >= 1
|
|
ORDER BY tool_calls DESC
|
|
LIMIT 500
|
|
`
|
|
|
|
// Mirrors MCPToolQualityRowsQueryRunner (products/mcp_analytics/backend/insightsql_queries/tool_quality_tables.py)
|
|
// for the compact reliability matrix on the overview. Limited columns + 50 rows.
|
|
const TOOL_ROWS_QUERY = `
|
|
SELECT
|
|
toString(properties.$mcp_tool_name) AS tool,
|
|
count() AS total_calls,
|
|
countIf(toBool(properties.$mcp_is_error)) AS errors,
|
|
round(countIf(toBool(properties.$mcp_is_error)) * 100.0 / count(), 1) AS error_rate_pct,
|
|
round(quantile(0.95)(toFloat(properties.$mcp_duration_ms))) AS p95_duration_ms
|
|
FROM events
|
|
WHERE event = '$mcp_tool_call'
|
|
AND properties.$mcp_tool_name IS NOT NULL
|
|
AND properties.$mcp_tool_name != ''
|
|
AND {filters}
|
|
GROUP BY tool
|
|
ORDER BY total_calls DESC
|
|
LIMIT 50
|
|
`
|
|
|
|
// The harness breakdown is resolved server-side by the MCPHarnessBreakdownQuery
|
|
// runner (products/mcp_analytics/backend/insightsql_queries/harness_breakdown.py) — the
|
|
// single source of truth for client → harness labelling — so the tile reads typed,
|
|
// already-bucketed rows rather than re-deriving the labels in the browser.
|
|
|
|
// The `__BUCKET__` expression the KPI query is built with. toString() is load-bearing: a bare
|
|
// dateTrunc returns a typed DateTime that the query API serializes with the project's UTC offset
|
|
// attached (2026-07-21T00:00:00-07:00), which the client then reads back as an instant and
|
|
// converts, shifting every bucket away from the wall-clock keys it joins and compares against.
|
|
// Rendering it server-side leaves a plain string with nothing left to reinterpret, matching the
|
|
// backend runners (dashboard_series.py, tool_quality_tables.py, tool_tables.py).
|
|
const bucketExpr = (interval: IntervalType): string => `toString(dateTrunc('${interval}', timestamp))`
|
|
|
|
export interface BucketRow {
|
|
bucket: string
|
|
sessions: number
|
|
tool_calls: number
|
|
errors: number
|
|
p95: number
|
|
}
|
|
|
|
export interface KPIMetric {
|
|
value: number
|
|
previousValue: number
|
|
deltaPct: number | null
|
|
sparkline: number[]
|
|
sparklineLabels: string[]
|
|
goodDirection: 'up' | 'down'
|
|
}
|
|
|
|
export interface KPIData {
|
|
sessions: KPIMetric
|
|
toolCalls: KPIMetric
|
|
errorRatePct: KPIMetric
|
|
p95LatencyMs: KPIMetric
|
|
}
|
|
|
|
export interface ToolRow {
|
|
tool: string
|
|
total_calls: number
|
|
errors: number
|
|
error_rate_pct: number
|
|
p95_duration_ms: number
|
|
}
|
|
|
|
export interface ActivityRow {
|
|
day: string
|
|
successes: number
|
|
errors: number
|
|
}
|
|
|
|
export interface DailyActivity {
|
|
labels: string[]
|
|
successes: number[]
|
|
errors: number[]
|
|
}
|
|
|
|
export interface ToolDailyRow {
|
|
day: string
|
|
tool: string
|
|
calls: number
|
|
}
|
|
|
|
export interface ToolDailySeries {
|
|
labels: string[]
|
|
tools: { tool: string; data: number[] }[]
|
|
}
|
|
|
|
export interface HarnessRow {
|
|
category: string
|
|
total_calls: number
|
|
errors: number
|
|
error_rate_pct: number
|
|
sessions: number
|
|
}
|
|
|
|
export interface SessionRow {
|
|
session_id: string
|
|
tool_calls: number
|
|
errors: number
|
|
error_rate_pct: number
|
|
duration_seconds: number
|
|
distinct_tools: number
|
|
last_seen: string
|
|
}
|
|
|
|
export type NotableRule = 'worst_error_rate' | 'all_fail' | 'most_exploratory' | 'exemplar' | 'high_activity'
|
|
|
|
// Calls the busiest session needs before it is worth flagging. Absolute rather than relative to the
|
|
// median, because the query returns the 500 busiest sessions, so the median of what we get is not the
|
|
// account's median.
|
|
const MIN_OUTLIER_CALLS = 5
|
|
|
|
// Below this, a session is an ordinary task rather than an exploration.
|
|
const MIN_JOURNEY_TOOLS = 4
|
|
|
|
export interface NotableSession {
|
|
rule: NotableRule
|
|
label: string
|
|
session: SessionRow
|
|
}
|
|
|
|
const EMPTY_METRIC: KPIMetric = {
|
|
value: 0,
|
|
previousValue: 0,
|
|
deltaPct: null,
|
|
sparkline: [],
|
|
sparklineLabels: [],
|
|
goodDirection: 'up',
|
|
}
|
|
const EMPTY_KPIS: KPIData = {
|
|
sessions: { ...EMPTY_METRIC, goodDirection: 'up' },
|
|
toolCalls: { ...EMPTY_METRIC, goodDirection: 'up' },
|
|
errorRatePct: { ...EMPTY_METRIC, goodDirection: 'down' },
|
|
p95LatencyMs: { ...EMPTY_METRIC, goodDirection: 'down' },
|
|
}
|
|
|
|
// Keep the stacked bar legible: only the busiest tools get their own segment; the long tail is
|
|
// folded into a single "Other" series so the chart can't sprout dozens of repeating-colour bands.
|
|
const TOOL_SERIES_LIMIT = 8
|
|
|
|
// Pivot flat (day, tool, calls) rows into a label array + one data series per tool, tools ordered
|
|
// by total volume (biggest first) so the stack and legend read consistently. When `bucketKeys` is
|
|
// supplied the labels span the full selected window (zero-filling empty buckets) so the x-axis
|
|
// matches the date range instead of collapsing to the days that happened to have calls; without it
|
|
// the labels fall back to the days present in the rows (a plain pivot, used in tests).
|
|
export function buildToolDailySeries(rows: ToolDailyRow[], bucketKeys?: string[]): ToolDailySeries {
|
|
const days = bucketKeys ?? [...new Set(rows.map((r) => r.day))].sort()
|
|
const totalByTool = new Map<string, number>()
|
|
const byToolDay = new Map<string, Map<string, number>>()
|
|
for (const row of rows) {
|
|
totalByTool.set(row.tool, (totalByTool.get(row.tool) ?? 0) + row.calls)
|
|
let dayMap = byToolDay.get(row.tool)
|
|
if (!dayMap) {
|
|
dayMap = new Map<string, number>()
|
|
byToolDay.set(row.tool, dayMap)
|
|
}
|
|
dayMap.set(row.day, (dayMap.get(row.day) ?? 0) + row.calls)
|
|
}
|
|
const seriesFor = (tool: string): number[] => days.map((day) => byToolDay.get(tool)!.get(day) ?? 0)
|
|
const ranked = [...totalByTool.entries()].sort((a, b) => b[1] - a[1]).map(([tool]) => tool)
|
|
const tools = ranked.slice(0, TOOL_SERIES_LIMIT).map((tool) => ({ tool, data: seriesFor(tool) }))
|
|
const rest = ranked.slice(TOOL_SERIES_LIMIT)
|
|
if (rest.length > 0) {
|
|
tools.push({ tool: 'Other', data: days.map((_, i) => rest.reduce((sum, t) => sum + seriesFor(t)[i], 0)) })
|
|
}
|
|
return { labels: days, tools }
|
|
}
|
|
|
|
export function deltaPct(current: number, previous: number): number | null {
|
|
if (previous === 0) {
|
|
return current === 0 ? 0 : null
|
|
}
|
|
return ((current - previous) / previous) * 100
|
|
}
|
|
|
|
// Window resolution, bucket keys, and the BUCKET_FORMAT contract are shared with the tab/detail
|
|
// surfaces — see ./timeBuckets. The dashboard adds only the KPI-comparison window below, built on
|
|
// those shared primitives.
|
|
|
|
// Project the daily success/error rows onto the full set of buckets, defaulting empty buckets to 0.
|
|
export function buildDailyActivity(rows: ActivityRow[], bucketKeys: string[]): DailyActivity {
|
|
const byDay = new Map(rows.map((r) => [r.day, r]))
|
|
return {
|
|
labels: bucketKeys,
|
|
successes: bucketKeys.map((k) => byDay.get(k)?.successes ?? 0),
|
|
errors: bucketKeys.map((k) => byDay.get(k)?.errors ?? 0),
|
|
}
|
|
}
|
|
|
|
export interface KpiWindow {
|
|
dateFrom: string
|
|
dateTo: string
|
|
currentStartBucket: string
|
|
}
|
|
|
|
// Extend the resolved window back by an equal number of `interval` buckets so a
|
|
// single query returns both the selected period and its prior period.
|
|
// `currentStartBucket` is the cutoff `buildKPIs` splits on — formatted to match
|
|
// dateTrunc's DateTime output.
|
|
export function buildKpiWindow(dateFilter: DateFilter, timezone: string, interval: IntervalType): KpiWindow {
|
|
const { start, end } = resolveWindow(dateFilter.dateFrom, dateFilter.dateTo, timezone)
|
|
// The selected period covers the inclusive buckets [start, end] — one more than
|
|
// end.diff(start). Step the prior window back by that same count so the two
|
|
// halves of the comparison span an equal number of buckets.
|
|
const selectedBuckets = Math.max(1, end.diff(start, interval) + 1)
|
|
const priorStart = start.subtract(selectedBuckets, interval)
|
|
return {
|
|
dateFrom: priorStart.toISOString(),
|
|
dateTo: end.toISOString(),
|
|
currentStartBucket: start.startOf(interval).format(BUCKET_FORMAT),
|
|
}
|
|
}
|
|
|
|
// Merge the dashboard's active filters with a doubled comparison window's date range.
|
|
// Shared by the KPI and Users loaders so both tiles are scoped to the exact same window —
|
|
// the tile-parity the reload test asserts. Keep the two loaders reading from here so the
|
|
// window/filter plumbing can't drift between them.
|
|
function kpiWindowFilters(queryFilters: InsightsQLFilters, kpiWindow: KpiWindow): InsightsQLFilters {
|
|
return { ...queryFilters, dateRange: { date_from: kpiWindow.dateFrom, date_to: kpiWindow.dateTo } }
|
|
}
|
|
|
|
function parseRows(rawRows: unknown[][]): BucketRow[] {
|
|
return rawRows.map((r) => ({
|
|
bucket: String(r[0]),
|
|
sessions: Number(r[1] ?? 0),
|
|
tool_calls: Number(r[2] ?? 0),
|
|
errors: Number(r[3] ?? 0),
|
|
p95: Number(r[4] ?? 0),
|
|
}))
|
|
}
|
|
|
|
// Buckets at or after `currentStartBucket` belong to the selected window; the
|
|
// rest are the equal-length window immediately before it.
|
|
export function buildKPIs(rows: BucketRow[], currentStartBucket: string): KPIData {
|
|
const current = rows.filter((r) => r.bucket >= currentStartBucket).sort((a, b) => a.bucket.localeCompare(b.bucket))
|
|
const previous = rows.filter((r) => r.bucket < currentStartBucket)
|
|
|
|
// Newest bucket with latency data — p95 === 0 marks a bucket without any.
|
|
const latestP95 = (rows_: BucketRow[]): number =>
|
|
[...rows_].sort((a, b) => b.bucket.localeCompare(a.bucket)).find((r) => r.p95 > 0)?.p95 ?? 0
|
|
|
|
// Newest bucket with any calls — a 0% rate from an empty bucket would be meaningless.
|
|
const latestErrorRate = (rows_: BucketRow[]): number => {
|
|
const row = [...rows_].sort((a, b) => b.bucket.localeCompare(a.bucket)).find((r) => r.tool_calls > 0)
|
|
return row ? (row.errors / row.tool_calls) * 100 : 0
|
|
}
|
|
|
|
const curSessions = current.reduce((acc, r) => acc + r.sessions, 0)
|
|
const curCalls = current.reduce((acc, r) => acc + r.tool_calls, 0)
|
|
const curP95 = latestP95(current)
|
|
|
|
const prevSessions = previous.reduce((acc, r) => acc + r.sessions, 0)
|
|
const prevCalls = previous.reduce((acc, r) => acc + r.tool_calls, 0)
|
|
const prevP95 = latestP95(previous)
|
|
|
|
const curErrorRate = latestErrorRate(current)
|
|
const prevErrorRate = latestErrorRate(previous)
|
|
const sparklineLabels = current.map((r) => r.bucket)
|
|
|
|
return {
|
|
sessions: {
|
|
value: curSessions,
|
|
previousValue: prevSessions,
|
|
deltaPct: deltaPct(curSessions, prevSessions),
|
|
sparkline: current.map((r) => r.sessions),
|
|
sparklineLabels,
|
|
goodDirection: 'up',
|
|
},
|
|
toolCalls: {
|
|
value: curCalls,
|
|
previousValue: prevCalls,
|
|
deltaPct: deltaPct(curCalls, prevCalls),
|
|
sparkline: current.map((r) => r.tool_calls),
|
|
sparklineLabels,
|
|
goodDirection: 'up',
|
|
},
|
|
errorRatePct: {
|
|
value: curErrorRate,
|
|
previousValue: prevErrorRate,
|
|
deltaPct: deltaPct(curErrorRate, prevErrorRate),
|
|
sparkline: current.map((r) => (r.tool_calls ? (r.errors / r.tool_calls) * 100 : 0)),
|
|
sparklineLabels,
|
|
goodDirection: 'down',
|
|
},
|
|
p95LatencyMs: {
|
|
value: curP95,
|
|
previousValue: prevP95,
|
|
deltaPct: deltaPct(curP95, prevP95),
|
|
sparkline: current.map((r) => r.p95),
|
|
sparklineLabels,
|
|
goodDirection: 'down',
|
|
},
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface mcpDashboardOverviewLogicValues {
|
|
clusters: readonly MCPIntentClusterApi[] // mcpClusteringLogic
|
|
hasSnapshot: boolean // mcpClusteringLogic
|
|
totalClusterCount: number // mcpClusteringLogic
|
|
currentTeam: TeamPublicType | TeamType | null // teamLogic
|
|
timezone: string // teamLogic
|
|
activityIncompleteTail: boolean
|
|
activityRows: ActivityRow[]
|
|
activityRowsLoading: boolean
|
|
bucketKeys: string[]
|
|
dailyActivity: DailyActivity
|
|
dateFilter: DateFilter
|
|
filterTestAccounts: boolean
|
|
filterTestAccountsOverride: boolean | null
|
|
harnessRows: HarnessRow[]
|
|
harnessRowsLoading: boolean
|
|
intentClusterCount: KPIMetric
|
|
interval: IntervalType
|
|
kpiIncompleteTail: boolean
|
|
kpis: KPIData
|
|
kpisLoading: boolean
|
|
notableSessions: NotableSession[]
|
|
propertyFilters: AnyPropertyFilter[]
|
|
queryFilters: InsightsQLFilters
|
|
sessionRows: SessionRow[]
|
|
sessionRowsLoading: boolean
|
|
toolDailyRows: ToolDailyRow[]
|
|
toolDailyRowsLoading: boolean
|
|
toolDailySeries: ToolDailySeries
|
|
toolRows: ToolRow[]
|
|
toolRowsLoading: boolean
|
|
users: KPIMetric
|
|
usersLoading: boolean
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface mcpDashboardOverviewLogicActions {
|
|
loadActivityRows: (_: void) => void
|
|
loadActivityRowsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadActivityRowsSuccess: (
|
|
activityRows: ActivityRow[],
|
|
payload?: void
|
|
) => {
|
|
activityRows: ActivityRow[]
|
|
payload?: void
|
|
}
|
|
loadHarnessRows: (_: void) => void
|
|
loadHarnessRowsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadHarnessRowsSuccess: (
|
|
harnessRows: {
|
|
category: string
|
|
error_rate_pct: number
|
|
errors: number
|
|
sessions: number
|
|
total_calls: number
|
|
}[],
|
|
payload?: void
|
|
) => {
|
|
harnessRows: {
|
|
category: string
|
|
error_rate_pct: number
|
|
errors: number
|
|
sessions: number
|
|
total_calls: number
|
|
}[]
|
|
payload?: void
|
|
}
|
|
loadKPIs: (_: void) => void
|
|
loadKPIsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadKPIsSuccess: (
|
|
kpis: KPIData,
|
|
payload?: void
|
|
) => {
|
|
kpis: KPIData
|
|
payload?: void
|
|
}
|
|
loadSessionRows: (_: void) => void
|
|
loadSessionRowsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadSessionRowsSuccess: (
|
|
sessionRows: {
|
|
distinct_tools: number
|
|
duration_seconds: number
|
|
error_rate_pct: number
|
|
errors: number
|
|
last_seen: string
|
|
session_id: string
|
|
tool_calls: number
|
|
}[],
|
|
payload?: void
|
|
) => {
|
|
sessionRows: {
|
|
distinct_tools: number
|
|
duration_seconds: number
|
|
error_rate_pct: number
|
|
errors: number
|
|
last_seen: string
|
|
session_id: string
|
|
tool_calls: number
|
|
}[]
|
|
payload?: void
|
|
}
|
|
loadToolDailyRows: (_: void) => void
|
|
loadToolDailyRowsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadToolDailyRowsSuccess: (
|
|
toolDailyRows: ToolDailyRow[],
|
|
payload?: void
|
|
) => {
|
|
toolDailyRows: ToolDailyRow[]
|
|
payload?: void
|
|
}
|
|
loadToolRows: (_: void) => void
|
|
loadToolRowsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadToolRowsSuccess: (
|
|
toolRows: {
|
|
error_rate_pct: number
|
|
errors: number
|
|
p95_duration_ms: number
|
|
tool: string
|
|
total_calls: number
|
|
}[],
|
|
payload?: void
|
|
) => {
|
|
toolRows: {
|
|
error_rate_pct: number
|
|
errors: number
|
|
p95_duration_ms: number
|
|
tool: string
|
|
total_calls: number
|
|
}[]
|
|
payload?: void
|
|
}
|
|
loadUsers: (_: void) => void
|
|
loadUsersFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadUsersSuccess: (
|
|
users: KPIMetric,
|
|
payload?: void
|
|
) => {
|
|
users: KPIMetric
|
|
payload?: void
|
|
}
|
|
reloadAll: () => {
|
|
value: true
|
|
}
|
|
setDateFilter: (
|
|
dateFrom: string | null,
|
|
dateTo: string | null
|
|
) => {
|
|
dateFrom: string | null
|
|
dateTo: string | null
|
|
}
|
|
setFilterTestAccounts: (filterTestAccounts: boolean | null) => {
|
|
filterTestAccounts: boolean | null
|
|
}
|
|
setPropertyFilters: (properties: AnyPropertyFilter[]) => {
|
|
properties: AnyPropertyFilter[]
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface mcpDashboardOverviewLogicMeta {
|
|
__keaTypeGenInternalSelectorTypes: {
|
|
filterTestAccounts: (
|
|
filterTestAccountsOverride: boolean | null,
|
|
currentTeam: TeamPublicType | TeamType | null
|
|
) => boolean
|
|
queryFilters: (
|
|
dateFilter: DateFilter,
|
|
filterTestAccounts: boolean,
|
|
propertyFilters: AnyPropertyFilter[]
|
|
) => InsightsQLFilters
|
|
interval: (dateFilter: DateFilter) => IntervalType
|
|
bucketKeys: (dateFilter: DateFilter, timezone: string, interval: IntervalType) => string[]
|
|
activityIncompleteTail: (bucketKeys: string[], timezone: string, interval: IntervalType) => boolean
|
|
kpiIncompleteTail: (kpis: KPIData, timezone: string, interval: IntervalType) => boolean
|
|
dailyActivity: (activityRows: ActivityRow[], bucketKeys: string[]) => DailyActivity
|
|
toolDailySeries: (toolDailyRows: ToolDailyRow[], bucketKeys: string[]) => ToolDailySeries
|
|
notableSessions: (sessionRows: SessionRow[]) => NotableSession[]
|
|
intentClusterCount: (totalClusterCount: number) => KPIMetric
|
|
}
|
|
}
|
|
|
|
export type mcpDashboardOverviewLogicType = MakeLogicType<
|
|
mcpDashboardOverviewLogicValues,
|
|
mcpDashboardOverviewLogicActions,
|
|
Record<string, any>,
|
|
mcpDashboardOverviewLogicMeta
|
|
>
|
|
|
|
export const mcpDashboardOverviewLogic = kea<mcpDashboardOverviewLogicType>([
|
|
path(['products', 'mcp_analytics', 'frontend', 'mcpDashboardOverviewLogic']),
|
|
connect(() => ({
|
|
values: [
|
|
mcpClusteringLogic,
|
|
['clusters', 'hasSnapshot', 'totalClusterCount'],
|
|
teamLogic,
|
|
['timezone', 'currentTeam'],
|
|
],
|
|
})),
|
|
actions({
|
|
setDateFilter: (dateFrom: string | null, dateTo: string | null) => ({ dateFrom, dateTo }),
|
|
setFilterTestAccounts: (filterTestAccounts: boolean | null) => ({ filterTestAccounts }),
|
|
setPropertyFilters: (properties: AnyPropertyFilter[]) => ({ properties }),
|
|
reloadAll: true,
|
|
}),
|
|
reducers({
|
|
dateFilter: [
|
|
DEFAULT_DATE_FILTER,
|
|
{
|
|
setDateFilter: (_, { dateFrom, dateTo }): DateFilter => ({ dateFrom, dateTo }),
|
|
},
|
|
],
|
|
// null until the user toggles — the effective value falls back to the team's
|
|
// test_account_filters_default_checked setting (see the filterTestAccounts selector).
|
|
filterTestAccountsOverride: [
|
|
null as boolean | null,
|
|
{
|
|
setFilterTestAccounts: (_, { filterTestAccounts }): boolean | null => filterTestAccounts,
|
|
},
|
|
],
|
|
propertyFilters: [
|
|
[] as AnyPropertyFilter[],
|
|
{
|
|
setPropertyFilters: (_, { properties }): AnyPropertyFilter[] => properties,
|
|
},
|
|
],
|
|
}),
|
|
loaders(({ values }) => ({
|
|
kpis: [
|
|
EMPTY_KPIS,
|
|
{
|
|
loadKPIs: async (_: void, breakpoint) => {
|
|
const { interval } = values
|
|
const kpiWindow = buildKpiWindow(values.dateFilter, values.timezone, interval)
|
|
const response = (await api.query({
|
|
kind: NodeKind.InsightsQLQuery,
|
|
query: KPI_QUERY.replace('__BUCKET__', bucketExpr(interval)),
|
|
filters: kpiWindowFilters(values.queryFilters, kpiWindow),
|
|
})) as InsightsQLQueryResponse
|
|
breakpoint()
|
|
const rows = parseRows((response?.results as unknown[][]) ?? [])
|
|
return buildKPIs(rows, kpiWindow.currentStartBucket)
|
|
},
|
|
},
|
|
],
|
|
users: [
|
|
EMPTY_METRIC,
|
|
{
|
|
loadUsers: async (_: void, breakpoint): Promise<KPIMetric> => {
|
|
const { interval, timezone } = values
|
|
const kpiWindow = buildKpiWindow(values.dateFilter, timezone, interval)
|
|
// Split the doubled window at the selected period's start. currentStartBucket is
|
|
// interval-aligned (buildKpiWindow → start.startOf(interval)), so comparing the raw
|
|
// `timestamp` against toDateTime(bucket, tz) lands on the same instant as the KPI
|
|
// tiles' dateTrunc bucket-string split — keeping this count consistent with them.
|
|
// (For rolling sub-day ranges the two halves can differ by up to one interval, the
|
|
// same bounded skew the KPI tiles already carry; splitting on the raw start instead
|
|
// would equalize the halves but desync Users from the other tiles, so don't.)
|
|
const curStart = `toDateTime('${kpiWindow.currentStartBucket}', '${timezone}')`
|
|
const response = (await api.query({
|
|
kind: NodeKind.InsightsQLQuery,
|
|
query: USERS_QUERY.replace(/__CUR_START__/g, curStart),
|
|
filters: kpiWindowFilters(values.queryFilters, kpiWindow),
|
|
})) as InsightsQLQueryResponse
|
|
breakpoint()
|
|
const row = (response?.results as unknown[][])?.[0] ?? []
|
|
const value = Number(row[0] ?? 0)
|
|
const previousValue = Number(row[1] ?? 0)
|
|
return {
|
|
value,
|
|
previousValue,
|
|
deltaPct: deltaPct(value, previousValue),
|
|
// No sparkline: the headline is a window-level distinct count, not a per-bucket series.
|
|
sparkline: [],
|
|
sparklineLabels: [],
|
|
goodDirection: 'up',
|
|
}
|
|
},
|
|
},
|
|
],
|
|
toolRows: [
|
|
[] as ToolRow[],
|
|
{
|
|
loadToolRows: async (_: void, breakpoint) => {
|
|
const response = (await api.query({
|
|
kind: NodeKind.InsightsQLQuery,
|
|
query: TOOL_ROWS_QUERY,
|
|
filters: values.queryFilters,
|
|
})) as InsightsQLQueryResponse
|
|
breakpoint()
|
|
const raw = (response?.results as unknown[][]) ?? []
|
|
return raw.map((r) => ({
|
|
tool: String(r[0] ?? ''),
|
|
total_calls: Number(r[1] ?? 0),
|
|
errors: Number(r[2] ?? 0),
|
|
error_rate_pct: Number(r[3] ?? 0),
|
|
p95_duration_ms: Number(r[4] ?? 0),
|
|
}))
|
|
},
|
|
},
|
|
],
|
|
sessionRows: [
|
|
[] as SessionRow[],
|
|
{
|
|
loadSessionRows: async (_: void, breakpoint) => {
|
|
const response = (await api.query({
|
|
kind: NodeKind.InsightsQLQuery,
|
|
query: SESSION_ROWS_QUERY,
|
|
filters: values.queryFilters,
|
|
})) as InsightsQLQueryResponse
|
|
breakpoint()
|
|
const raw = (response?.results as unknown[][]) ?? []
|
|
return raw.map((r) => ({
|
|
session_id: String(r[0] ?? ''),
|
|
tool_calls: Number(r[1] ?? 0),
|
|
errors: Number(r[2] ?? 0),
|
|
error_rate_pct: Number(r[3] ?? 0),
|
|
duration_seconds: Number(r[4] ?? 0),
|
|
distinct_tools: Number(r[5] ?? 0),
|
|
last_seen: String(r[6] ?? ''),
|
|
}))
|
|
},
|
|
},
|
|
],
|
|
harnessRows: [
|
|
[] as HarnessRow[],
|
|
{
|
|
loadHarnessRows: async (_: void, breakpoint) => {
|
|
const { dateRange, properties, filterTestAccounts } = values.queryFilters
|
|
const response = (await api.query({
|
|
kind: NodeKind.MCPHarnessBreakdownQuery,
|
|
dateRange,
|
|
properties,
|
|
filterTestAccounts,
|
|
})) as { results?: MCPHarnessBreakdownItem[] }
|
|
breakpoint()
|
|
return (response?.results ?? []).map((r) => ({
|
|
category: r.harness,
|
|
total_calls: r.total_calls,
|
|
errors: r.errors,
|
|
error_rate_pct: r.error_rate_pct,
|
|
sessions: r.sessions,
|
|
}))
|
|
},
|
|
},
|
|
],
|
|
activityRows: [
|
|
[] as ActivityRow[],
|
|
{
|
|
loadActivityRows: async (_: void, breakpoint): Promise<ActivityRow[]> => {
|
|
const { dateRange, properties, filterTestAccounts } = values.queryFilters
|
|
const response = (await api.query({
|
|
kind: NodeKind.MCPToolCallsAndErrorsQuery,
|
|
dateRange,
|
|
properties,
|
|
filterTestAccounts,
|
|
interval: values.interval,
|
|
})) as { results?: MCPToolCallsAndErrorsItem[] }
|
|
breakpoint()
|
|
return (response?.results ?? []).map((r) => ({
|
|
day: normalizeBucket(r.bucket),
|
|
successes: r.successes,
|
|
errors: r.errors,
|
|
}))
|
|
},
|
|
},
|
|
],
|
|
toolDailyRows: [
|
|
[] as ToolDailyRow[],
|
|
{
|
|
loadToolDailyRows: async (_: void, breakpoint): Promise<ToolDailyRow[]> => {
|
|
const { dateRange, properties, filterTestAccounts } = values.queryFilters
|
|
const response = (await api.query({
|
|
kind: NodeKind.MCPToolCallBreakdownQuery,
|
|
dateRange,
|
|
properties,
|
|
filterTestAccounts,
|
|
interval: values.interval,
|
|
})) as { results?: MCPToolCallBreakdownItem[] }
|
|
breakpoint()
|
|
return (response?.results ?? []).map((r) => ({
|
|
day: normalizeBucket(r.bucket),
|
|
tool: r.tool,
|
|
calls: r.calls,
|
|
}))
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
selectors({
|
|
// Effective toggle state: the user's explicit override, else the team's default.
|
|
filterTestAccounts: [
|
|
(s) => [s.filterTestAccountsOverride, s.currentTeam],
|
|
(override: boolean | null, currentTeam: TeamType | null): boolean =>
|
|
override ?? !!currentTeam?.test_account_filters_default_checked,
|
|
],
|
|
queryFilters: [
|
|
(s) => [s.dateFilter, s.filterTestAccounts, s.propertyFilters],
|
|
(
|
|
dateFilter: DateFilter,
|
|
filterTestAccounts: boolean,
|
|
propertyFilters: AnyPropertyFilter[]
|
|
): InsightsQLFilters => ({
|
|
dateRange: { date_from: dateFilter.dateFrom, date_to: dateFilter.dateTo },
|
|
filterTestAccounts,
|
|
// Drop incomplete picker rows and any malformed URL-hydrated entries before they fan out to every tile.
|
|
properties: propertyFilters.filter(isValidPropertyFilter),
|
|
}),
|
|
],
|
|
interval: [
|
|
(s) => [s.dateFilter],
|
|
(dateFilter: DateFilter): IntervalType => getDefaultInterval(dateFilter.dateFrom, dateFilter.dateTo),
|
|
],
|
|
bucketKeys: [
|
|
(s) => [s.dateFilter, s.timezone, s.interval],
|
|
(dateFilter: DateFilter, timezone: string, interval: IntervalType): string[] =>
|
|
buildBucketKeys(dateFilter.dateFrom, dateFilter.dateTo, timezone, interval),
|
|
],
|
|
// Whether the activity chart's final bucket is the current, still-running interval — the
|
|
// chart dashes that segment so a partial period doesn't read as a drop in tool calls.
|
|
activityIncompleteTail: [
|
|
(s) => [s.bucketKeys, s.timezone, s.interval],
|
|
(bucketKeys: string[], timezone: string, interval: IntervalType): boolean =>
|
|
lastBucketIsInProgress(bucketKeys, timezone, interval),
|
|
],
|
|
// Read off the KPI sparkline's own labels, not `bucketKeys`: the sparkline is not zero-filled,
|
|
// so on a day with no calls yet its last point is yesterday, which is settled.
|
|
kpiIncompleteTail: [
|
|
(s) => [s.kpis, s.timezone, s.interval],
|
|
(kpis: KPIData, timezone: string, interval: IntervalType): boolean =>
|
|
lastBucketIsInProgress(kpis.sessions.sparklineLabels, timezone, interval),
|
|
],
|
|
dailyActivity: [
|
|
(s) => [s.activityRows, s.bucketKeys],
|
|
(rows: ActivityRow[], bucketKeys: string[]): DailyActivity => buildDailyActivity(rows, bucketKeys),
|
|
],
|
|
toolDailySeries: [
|
|
(s) => [s.toolDailyRows, s.bucketKeys],
|
|
(rows: ToolDailyRow[], bucketKeys: string[]): ToolDailySeries => buildToolDailySeries(rows, bucketKeys),
|
|
],
|
|
notableSessions: [
|
|
(s) => [s.sessionRows],
|
|
(sessionRows: SessionRow[]): NotableSession[] => pickNotableSessions(sessionRows),
|
|
],
|
|
intentClusterCount: [
|
|
// The snapshot only stores the top clusters by call volume — report
|
|
// the run's true count, not the length of the truncated list.
|
|
(s) => [s.totalClusterCount],
|
|
(totalClusterCount: number): KPIMetric => ({
|
|
value: totalClusterCount,
|
|
previousValue: 0,
|
|
deltaPct: null,
|
|
sparkline: [],
|
|
sparklineLabels: [],
|
|
goodDirection: 'up',
|
|
}),
|
|
],
|
|
}),
|
|
listeners(({ actions }) => ({
|
|
setDateFilter: () => {
|
|
actions.reloadAll()
|
|
},
|
|
setFilterTestAccounts: () => {
|
|
actions.reloadAll()
|
|
},
|
|
setPropertyFilters: () => {
|
|
actions.reloadAll()
|
|
},
|
|
reloadAll: () => {
|
|
actions.loadKPIs()
|
|
actions.loadUsers()
|
|
actions.loadToolRows()
|
|
actions.loadSessionRows()
|
|
actions.loadHarnessRows()
|
|
actions.loadActivityRows()
|
|
actions.loadToolDailyRows()
|
|
},
|
|
})),
|
|
actionToUrl(({ values }) => {
|
|
const syncUrl = (): [string, Record<string, any>, Record<string, any>, { replace: boolean }] => {
|
|
const { currentLocation } = router.values
|
|
const searchParams = { ...currentLocation.searchParams }
|
|
if (values.dateFilter.dateFrom) {
|
|
searchParams.date_from = values.dateFilter.dateFrom
|
|
} else {
|
|
delete searchParams.date_from
|
|
}
|
|
if (values.dateFilter.dateTo) {
|
|
searchParams.date_to = values.dateFilter.dateTo
|
|
} else {
|
|
delete searchParams.date_to
|
|
}
|
|
// Absent param = follow the team default; an explicit override (incl. false) persists.
|
|
if (values.filterTestAccountsOverride === null) {
|
|
delete searchParams.filter_test_accounts
|
|
} else {
|
|
searchParams.filter_test_accounts = values.filterTestAccountsOverride
|
|
}
|
|
if (values.propertyFilters.length > 0) {
|
|
searchParams.properties = values.propertyFilters
|
|
} else {
|
|
delete searchParams.properties
|
|
}
|
|
return [currentLocation.pathname, searchParams, currentLocation.hashParams, { replace: true }]
|
|
}
|
|
return {
|
|
setDateFilter: syncUrl,
|
|
setFilterTestAccounts: syncUrl,
|
|
setPropertyFilters: syncUrl,
|
|
}
|
|
}),
|
|
urlToAction(({ actions, values, cache }) => ({
|
|
[urls.mcpAnalyticsDashboard()]: (_, searchParams) => {
|
|
const dateFrom =
|
|
typeof searchParams.date_from === 'string' ? searchParams.date_from : DEFAULT_DATE_FILTER.dateFrom
|
|
const dateTo = typeof searchParams.date_to === 'string' ? searchParams.date_to : null
|
|
// Absent param leaves the override null (effective value follows the team default).
|
|
const rawFilter = searchParams.filter_test_accounts
|
|
const filterOverride = rawFilter === undefined ? null : rawFilter === true || rawFilter === 'true'
|
|
const properties = Array.isArray(searchParams.properties) ? searchParams.properties : []
|
|
const dateChanged = dateFrom !== values.dateFilter.dateFrom || dateTo !== values.dateFilter.dateTo
|
|
const filterChanged = filterOverride !== values.filterTestAccountsOverride
|
|
const propertiesChanged = JSON.stringify(properties) !== JSON.stringify(values.propertyFilters)
|
|
// setDateFilter / setFilterTestAccounts / setPropertyFilters each reload via their listeners.
|
|
if (dateChanged) {
|
|
actions.setDateFilter(dateFrom, dateTo)
|
|
}
|
|
if (filterChanged) {
|
|
actions.setFilterTestAccounts(filterOverride)
|
|
}
|
|
if (propertiesChanged) {
|
|
actions.setPropertyFilters(properties)
|
|
}
|
|
// URL already matches state (e.g. default filters) and afterMount deferred — load once.
|
|
if (!dateChanged && !filterChanged && !propertiesChanged && !cache.hasLoaded) {
|
|
actions.reloadAll()
|
|
}
|
|
cache.hasLoaded = true
|
|
},
|
|
})),
|
|
afterMount(({ actions, cache }) => {
|
|
// urlToAction owns the initial load whenever the dashboard URL carries filter
|
|
// params; this is the fallback for a param-less mount (and off-route mounts in
|
|
// tests, where urlToAction never fires). The cache.hasLoaded guard keeps a
|
|
// deep-linked load from firing twice.
|
|
const { searchParams } = router.values
|
|
const hasUrlFilters =
|
|
typeof searchParams.date_from === 'string' ||
|
|
typeof searchParams.date_to === 'string' ||
|
|
typeof searchParams.filter_test_accounts !== 'undefined' ||
|
|
Array.isArray(searchParams.properties)
|
|
if (!hasUrlFilters && !cache.hasLoaded) {
|
|
cache.hasLoaded = true
|
|
actions.reloadAll()
|
|
}
|
|
}),
|
|
])
|
|
|
|
function median(values: number[]): number {
|
|
if (values.length === 0) {
|
|
return 0
|
|
}
|
|
const sorted = [...values].sort((a, b) => a - b)
|
|
const mid = Math.floor(sorted.length / 2)
|
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
|
}
|
|
|
|
// Pick at most one session per rule, so every row has a reason to be there. Deliberately returns
|
|
// short, or empty, when nothing qualifies.
|
|
export function pickNotableSessions(rows: SessionRow[]): NotableSession[] {
|
|
if (rows.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const medianCalls = median(rows.map((r) => r.tool_calls))
|
|
const medianDuration = median(rows.map((r) => r.duration_seconds))
|
|
const busyThreshold = Math.max(medianCalls, 3)
|
|
const used = new Set<string>()
|
|
const picked: NotableSession[] = []
|
|
|
|
const take = (rule: NotableRule, label: string, candidate: SessionRow | undefined): void => {
|
|
if (!candidate || used.has(candidate.session_id)) {
|
|
return
|
|
}
|
|
used.add(candidate.session_id)
|
|
picked.push({ rule, label, session: candidate })
|
|
}
|
|
|
|
// 1. Worst error rate at high volume — top err% among above-median-volume sessions
|
|
const highVolume = rows.filter((r) => r.tool_calls >= busyThreshold && r.error_rate_pct > 0)
|
|
take(
|
|
'worst_error_rate',
|
|
'Worst error rate at high volume',
|
|
[...highVolume].sort((a, b) => b.error_rate_pct - a.error_rate_pct || b.tool_calls - a.tool_calls)[0]
|
|
)
|
|
|
|
// 2. All-fail session — error_rate_pct === 100 AND tool_calls >= 3
|
|
const allFail = rows.filter((r) => r.error_rate_pct >= 100 && r.tool_calls >= 3)
|
|
take(
|
|
'all_fail',
|
|
'Every call failed, likely an auth scope issue',
|
|
[...allFail].sort((a, b) => b.tool_calls - a.tool_calls)[0]
|
|
)
|
|
|
|
// 3. Most exploratory — highest distinct_tools count, among sessions that are journeys at all
|
|
const journeys = rows.filter((r) => r.distinct_tools >= MIN_JOURNEY_TOOLS)
|
|
take(
|
|
'most_exploratory',
|
|
'Most exploratory journey',
|
|
[...journeys].sort((a, b) => b.distinct_tools - a.distinct_tools || b.tool_calls - a.tool_calls)[0]
|
|
)
|
|
|
|
// 4. Exemplar — zero errors, above-median calls, faster than median duration
|
|
const exemplars = rows.filter(
|
|
(r) => r.error_rate_pct === 0 && r.tool_calls >= busyThreshold && r.duration_seconds <= medianDuration
|
|
)
|
|
take('exemplar', 'Concise success', [...exemplars].sort((a, b) => b.tool_calls - a.tool_calls)[0])
|
|
|
|
// 5. High activity — the busiest session, once it is busy enough to be worth reading. Being the
|
|
// largest of a small set is not notable: on a quiet account that can be a single tool call.
|
|
const busiest = [...rows].sort((a, b) => b.tool_calls - a.tool_calls)[0]
|
|
if (busiest && busiest.tool_calls >= MIN_OUTLIER_CALLS) {
|
|
take('high_activity', 'High activity', busiest)
|
|
}
|
|
|
|
return picked
|
|
}
|