mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Workflow template cards, the template choosers, the auth wallpaper, insight previews, partner pages, SDK logos and every fixture stop pulling posthog's cloudinary; the CSP stops allowing it. The mascot easter egg leaves with its sprite package. insights-js resolves to @hanzo/insights for every consumer — the react wrapper's peer included, via a workspace package that carries the name — and its rrweb siblings map to our published forks. Icon names that outran our icon release alias to shipped glyphs.
826 lines
32 KiB
TypeScript
826 lines
32 KiB
TypeScript
import { useActions, useValues } from 'kea'
|
|
import { useMemo } from 'react'
|
|
|
|
import { IconArrowLeft, IconArrowRight, IconCopy } from '@hanzo/icons'
|
|
import { Button, Divider, Modal, Tooltip } from '@hanzo/elements'
|
|
import {
|
|
type ChartTheme,
|
|
type Series,
|
|
type TimeInterval,
|
|
TimeSeriesLineChart,
|
|
type TimeSeriesLineChartConfig,
|
|
} from '@hanzo/quill-charts'
|
|
import {
|
|
Card,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
Skeleton,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableEmpty,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@hanzo/quill-primitives'
|
|
|
|
import { useChartConfig, useChartTheme } from 'lib/charts/hooks'
|
|
import { TZLabel } from 'lib/components/TZLabel'
|
|
import { Markdown } from 'lib/elements/Markdown'
|
|
import { copyToClipboard } from 'lib/utils/copyToClipboard'
|
|
import { humanFriendlyNumber } from 'lib/utils/numbers'
|
|
import { PersonDisplay } from 'scenes/persons/PersonDisplay'
|
|
import { teamLogic } from 'scenes/teamLogic'
|
|
|
|
import { FeaturePreviewSceneGate } from '~/layout/scenes/components/FeaturePreviewSceneGate'
|
|
import { SceneContent } from '~/layout/scenes/components/SceneContent'
|
|
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
|
|
import type { MCPToolFailureOccurrenceItem } from '~/queries/schema/schema-general'
|
|
import { SceneExport } from '~/scenes/sceneTypes'
|
|
|
|
import { ToolDetailIntentsSection } from './clustering/ToolDetailIntentsSection'
|
|
import { formatMs, formatMsAsSeconds } from './dashboard/formatters'
|
|
import { HarnessLogo, HarnessPill } from './dashboard/harness'
|
|
import { MetricTile } from './dashboard/MetricTile'
|
|
import { mcpAnalyticsFeaturePreviewGate } from './featurePreviewGate'
|
|
import {
|
|
type DailyChartData,
|
|
IntentCoverage,
|
|
MCPAnalyticsToolDetailLogicProps,
|
|
type ResultRows,
|
|
ToolSummary,
|
|
mcpAnalyticsToolDetailLogic,
|
|
} from './mcpAnalyticsToolDetailLogic'
|
|
import { mcpToolQualityUrlWithDates } from './mcpAnalyticsToolQualityLogic'
|
|
import { formatBucketLabel } from './timeBuckets'
|
|
import { CreateFixTaskButton } from './tool-quality/CreateFixTaskButton'
|
|
import { type MCPErrorContext, formatErrorContext, mcpSessionUrl } from './tool-quality/errorContext'
|
|
|
|
export const scene: SceneExport<MCPAnalyticsToolDetailLogicProps> = {
|
|
component: MCPAnalyticsToolDetail,
|
|
logic: mcpAnalyticsToolDetailLogic,
|
|
paramsToProps: ({ params: { toolName } }) => ({
|
|
toolName: decodeURIComponent(toolName ?? ''),
|
|
}),
|
|
}
|
|
|
|
// Renderer for the "person" column in the Top users table. The loader maps each row's
|
|
// person into a [distinct_id, _, person_properties] array. Wrap it back into the shape
|
|
// PersonDisplay expects.
|
|
function renderPersonCell(value: unknown): JSX.Element {
|
|
if (!Array.isArray(value) || value.length === 0) {
|
|
return <span className="text-muted">—</span>
|
|
}
|
|
const [distinctId, , propertiesRaw] = value as [string, unknown, unknown]
|
|
let properties: Record<string, unknown> | undefined
|
|
if (propertiesRaw && typeof propertiesRaw === 'object') {
|
|
properties = propertiesRaw as Record<string, unknown>
|
|
} else if (typeof propertiesRaw === 'string') {
|
|
try {
|
|
properties = JSON.parse(propertiesRaw)
|
|
} catch {
|
|
properties = undefined
|
|
}
|
|
}
|
|
return (
|
|
<PersonDisplay person={{ distinct_id: distinctId, properties: properties ?? {} }} withIcon noPopover={false} />
|
|
)
|
|
}
|
|
|
|
// Harness logos for a row. Labels are resolved (deduped + sorted) server-side; the column
|
|
// just maps each label to its logo and stays on one line.
|
|
function HarnessLogos({ labels }: { labels: string[] }): JSX.Element {
|
|
if (labels.length === 0) {
|
|
return <span className="text-muted">—</span>
|
|
}
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
{labels.map((category) => (
|
|
<HarnessLogo key={category} category={category} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface ResultColumn {
|
|
header: string
|
|
align?: 'left' | 'right'
|
|
expand?: boolean
|
|
render: (row: unknown[]) => React.ReactNode
|
|
}
|
|
|
|
const neighborColumns: ResultColumn[] = [
|
|
{ header: 'Tool', expand: true, render: (r) => <span className="font-mono">{String(r[0] ?? '')}</span> },
|
|
{ header: 'In same conversation', align: 'right', render: (r) => humanFriendlyNumber(Number(r[1] ?? 0)) },
|
|
]
|
|
|
|
// Card-wrapped quill table matching the dashboard table cards.
|
|
function ResultTable({
|
|
title,
|
|
description,
|
|
action,
|
|
rows,
|
|
loading,
|
|
columns,
|
|
emptyMessage = 'No data for the selected date range.',
|
|
onRowClick,
|
|
}: {
|
|
title?: React.ReactNode
|
|
description?: React.ReactNode
|
|
action?: React.ReactNode
|
|
rows: ResultRows
|
|
loading: boolean
|
|
columns: ResultColumn[]
|
|
emptyMessage?: string
|
|
onRowClick?: (rowIndex: number) => void
|
|
}): JSX.Element {
|
|
return (
|
|
<Card size="sm" className="gap-0">
|
|
{title != null && (
|
|
<CardHeader
|
|
className={`border-b border-border pb-3${
|
|
action != null ? ' !flex flex-row items-center justify-between gap-2' : ''
|
|
}`}
|
|
>
|
|
<CardTitle>{title}</CardTitle>
|
|
{description != null && <CardDescription>{description}</CardDescription>}
|
|
{action}
|
|
</CardHeader>
|
|
)}
|
|
<Table fullWidth>
|
|
<TableHeader>
|
|
<TableRow>
|
|
{columns.map((col, i) => (
|
|
<TableHead key={i} align={col.align} expand={col.expand}>
|
|
{col.header}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
{loading && rows.length === 0 ? (
|
|
<TableBody>
|
|
<TableRow>
|
|
<TableCell colSpan={columns.length}>
|
|
<div className="space-y-2 py-1">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-3.5 w-full" />
|
|
))}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
) : rows.length === 0 ? (
|
|
<TableEmpty className="py-6 text-secondary">{emptyMessage}</TableEmpty>
|
|
) : (
|
|
<TableBody>
|
|
{rows.map((row, i) => (
|
|
<TableRow
|
|
key={i}
|
|
className={onRowClick ? 'cursor-pointer hover:bg-fill-highlight-50' : undefined}
|
|
onClick={onRowClick ? () => onRowClick(i) : undefined}
|
|
// A bare <tr> isn't keyboard-operable — mirror the tracing tables'
|
|
// pattern so the drill-down opens on Enter/Space too.
|
|
role={onRowClick ? 'button' : undefined}
|
|
tabIndex={onRowClick ? 0 : undefined}
|
|
onKeyDown={
|
|
onRowClick
|
|
? (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
onRowClick(i)
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{columns.map((col, ci) => (
|
|
<TableCell key={ci} align={col.align} expand={col.expand}>
|
|
{col.render(row)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
)}
|
|
</Table>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }): JSX.Element {
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<h2 className="text-base font-semibold mb-0">{title}</h2>
|
|
{subtitle ? <span className="text-xs text-secondary">{subtitle}</span> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Coalesce latency gaps (NaN) to 0 so the sparkline draws a continuous line over the window.
|
|
function spark(values: number[]): number[] {
|
|
return values.map((v) => (Number.isFinite(v) ? v : 0))
|
|
}
|
|
|
|
function StatTiles({
|
|
summary,
|
|
loading,
|
|
daily,
|
|
theme,
|
|
dateRangeLabel,
|
|
interval,
|
|
incompleteTail,
|
|
}: {
|
|
summary: ToolSummary | null
|
|
loading: boolean
|
|
daily: DailyChartData
|
|
theme: ChartTheme
|
|
dateRangeLabel: string
|
|
interval: TimeInterval
|
|
// When true, the final bucket is the current in-progress interval — dash the sparkline from
|
|
// there so a partial period doesn't read as a decline. Required rather than optional: an
|
|
// omitted prop silently renders the partial bucket as settled data.
|
|
incompleteTail: boolean
|
|
}): JSX.Element {
|
|
const calls = summary?.calls ?? 0
|
|
const errors = summary?.errors ?? 0
|
|
const errorRate = calls ? (errors / calls) * 100 : 0
|
|
const errorRateDaily = daily.calls.map((c, i) => (c ? (daily.errors[i] / c) * 100 : 0))
|
|
const sparkLabels = daily.labels.map((label) => formatBucketLabel(label, interval))
|
|
|
|
const tiles: {
|
|
label: string
|
|
value: number
|
|
formatValue: (n: number) => string
|
|
data: number[]
|
|
color: string
|
|
goodDirection: 'up' | 'down'
|
|
}[] = [
|
|
{
|
|
label: 'Calls',
|
|
value: calls,
|
|
formatValue: humanFriendlyNumber,
|
|
data: spark(daily.calls),
|
|
color: theme.colors[0],
|
|
goodDirection: 'up',
|
|
},
|
|
{
|
|
label: 'Error rate',
|
|
value: errorRate,
|
|
formatValue: (n) => `${n.toFixed(1)}%`,
|
|
data: spark(errorRateDaily),
|
|
color: theme.colors[4],
|
|
goodDirection: 'down',
|
|
},
|
|
{
|
|
label: 'p50 latency',
|
|
value: summary?.p50_ms ?? 0,
|
|
formatValue: formatMs,
|
|
data: spark(daily.p50),
|
|
color: theme.colors[0],
|
|
goodDirection: 'down',
|
|
},
|
|
{
|
|
label: 'p95 latency',
|
|
value: summary?.p95_ms ?? 0,
|
|
formatValue: formatMs,
|
|
data: spark(daily.p95),
|
|
color: theme.colors[0],
|
|
goodDirection: 'down',
|
|
},
|
|
{
|
|
label: 'Users',
|
|
value: summary?.users ?? 0,
|
|
formatValue: humanFriendlyNumber,
|
|
data: spark(daily.users),
|
|
color: theme.colors[0],
|
|
goodDirection: 'up',
|
|
},
|
|
{
|
|
label: 'Sessions',
|
|
value: summary?.conversations ?? 0,
|
|
formatValue: humanFriendlyNumber,
|
|
data: spark(daily.sessions),
|
|
color: theme.colors[6],
|
|
goodDirection: 'up',
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6" data-quill>
|
|
{tiles.map((tile) => (
|
|
<MetricTile
|
|
key={tile.label}
|
|
{...tile}
|
|
loading={loading}
|
|
labels={sparkLabels}
|
|
theme={theme}
|
|
restingSubtitle={dateRangeLabel}
|
|
sparklineHeight={40}
|
|
sparklineDashedFromIndex={incompleteTail ? sparkLabels.length - 1 : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function IntentCoverageTag({
|
|
coverage,
|
|
loading,
|
|
}: {
|
|
coverage: IntentCoverage | null
|
|
loading: boolean
|
|
}): JSX.Element | null {
|
|
if (loading) {
|
|
return <Skeleton className="h-4 w-32" />
|
|
}
|
|
if (!coverage || !coverage.total) {
|
|
return null
|
|
}
|
|
const pct = Math.round((coverage.with_intent / coverage.total) * 100)
|
|
return (
|
|
<Tooltip title="Share of calls where $mcp_intent was captured. Inferred intents are server fallbacks; context_parameter intents come from the client.">
|
|
<span className="text-[11px] text-secondary">
|
|
{humanFriendlyNumber(coverage.with_intent)} of {humanFriendlyNumber(coverage.total)} calls captured
|
|
intent ({pct}%)
|
|
</span>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
function DescriptionBlock({
|
|
descriptions,
|
|
loading,
|
|
}: {
|
|
descriptions: { description: string; last_seen: string }[]
|
|
loading: boolean
|
|
}): JSX.Element | null {
|
|
if (loading) {
|
|
return <Skeleton className="h-8 w-2/3 mt-2" />
|
|
}
|
|
if (!descriptions.length) {
|
|
return null
|
|
}
|
|
const [latest, ...older] = descriptions
|
|
return (
|
|
<div className="flex flex-col gap-1 max-w-3xl">
|
|
<span className="text-[11px] uppercase tracking-wider text-secondary">Description</span>
|
|
<Markdown className="text-sm leading-snug line-clamp-3" lowKeyHeadings>
|
|
{latest.description}
|
|
</Markdown>
|
|
{older.length > 0 ? (
|
|
<Tooltip
|
|
title={
|
|
<div className="flex flex-col gap-2 max-w-md">
|
|
{older.map((d) => (
|
|
<div key={d.last_seen} className="text-xs">
|
|
<div className="text-secondary mb-0.5">
|
|
last seen <TZLabel time={d.last_seen} />
|
|
</div>
|
|
<Markdown lowKeyHeadings>{d.description}</Markdown>
|
|
</div>
|
|
))}
|
|
</div>
|
|
}
|
|
>
|
|
<span className="text-[11px] text-secondary underline decoration-dotted cursor-help w-fit">
|
|
+ {older.length} previous version{older.length === 1 ? '' : 's'}
|
|
</span>
|
|
</Tooltip>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function trendChartConfig(
|
|
timezone: string,
|
|
interval: TimeInterval,
|
|
yAxis?: TimeSeriesLineChartConfig['yAxis']
|
|
): TimeSeriesLineChartConfig {
|
|
return {
|
|
curve: 'monotone',
|
|
showAxisLines: true,
|
|
showTickMarks: true,
|
|
showCrosshair: true,
|
|
showGrid: true,
|
|
yAxis,
|
|
xAxis: { interval, timezone },
|
|
tooltip: { placement: 'cursor' },
|
|
}
|
|
}
|
|
|
|
type TrendSeriesKey = 'calls' | 'errors' | 'p50' | 'p95'
|
|
const SERIES_META: Record<TrendSeriesKey, { label: string; colorIndex: number }> = {
|
|
calls: { label: 'Calls', colorIndex: 0 },
|
|
errors: { label: 'Errors', colorIndex: 4 },
|
|
p50: { label: 'p50', colorIndex: 1 },
|
|
p95: { label: 'p95', colorIndex: 0 },
|
|
}
|
|
|
|
// `incompleteTail` is only true when the window has ≥2 buckets (lastBucketIsInProgress owns that
|
|
// rule), and every series is filled to the bucket count, so the final segment exists.
|
|
function seriesFor(data: DailyChartData, theme: ChartTheme, keys: TrendSeriesKey[], incompleteTail: boolean): Series[] {
|
|
const stroke = incompleteTail ? { partial: { fromIndex: data.labels.length - 1 } } : undefined
|
|
return keys.map((key) => ({
|
|
key,
|
|
label: SERIES_META[key].label,
|
|
color: theme.colors[SERIES_META[key].colorIndex],
|
|
data: data[key],
|
|
stroke,
|
|
}))
|
|
}
|
|
|
|
function TrendChart({
|
|
title,
|
|
series,
|
|
labels,
|
|
config,
|
|
theme,
|
|
loading,
|
|
dataAttr,
|
|
}: {
|
|
title: string
|
|
series: Series[]
|
|
labels: string[]
|
|
config: TimeSeriesLineChartConfig
|
|
theme: ChartTheme
|
|
loading: boolean
|
|
dataAttr: string
|
|
}): JSX.Element {
|
|
return (
|
|
<div className="flex flex-col rounded border bg-bg-light p-2" style={{ height: 240 }} data-quill>
|
|
<div className="mb-1 px-2 pt-1 text-xs font-medium uppercase text-secondary">{title}</div>
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
{loading && labels.length === 0 ? (
|
|
<Skeleton className="flex-1" />
|
|
) : labels.length === 0 ? (
|
|
<div className="flex flex-1 items-center justify-center text-[12px] text-secondary">
|
|
No data for the selected date range.
|
|
</div>
|
|
) : (
|
|
<TimeSeriesLineChart
|
|
series={series}
|
|
labels={labels}
|
|
config={config}
|
|
theme={theme}
|
|
dataAttr={dataAttr}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function MCPAnalyticsToolDetail({ toolName }: { toolName: string }): JSX.Element {
|
|
return (
|
|
<FeaturePreviewSceneGate config={mcpAnalyticsFeaturePreviewGate}>
|
|
<MCPAnalyticsToolDetailContent toolName={toolName} />
|
|
</FeaturePreviewSceneGate>
|
|
)
|
|
}
|
|
|
|
function MCPAnalyticsToolDetailContent({ toolName }: { toolName: string }): JSX.Element {
|
|
const {
|
|
summary,
|
|
summaryLoading,
|
|
descriptions,
|
|
descriptionsLoading,
|
|
dailyChartData,
|
|
dailyStatsLoading,
|
|
failureBuckets,
|
|
failureBucketsLoading,
|
|
sampleIntentRows,
|
|
sampleIntentRowsLoading,
|
|
intentCoverage,
|
|
intentCoverageLoading,
|
|
neighborsBeforeRows,
|
|
neighborsBeforeRowsLoading,
|
|
neighborsAfterRows,
|
|
neighborsAfterRowsLoading,
|
|
byHarnessRows,
|
|
byHarnessRowsLoading,
|
|
topUserRows,
|
|
topUserRowsLoading,
|
|
dateRangeLabel,
|
|
dateFilter,
|
|
interval,
|
|
incompleteTail,
|
|
} = useValues(mcpAnalyticsToolDetailLogic({ toolName }))
|
|
const { selectFailure } = useActions(mcpAnalyticsToolDetailLogic({ toolName }))
|
|
const { timezone } = useValues(teamLogic)
|
|
|
|
const theme = useChartTheme()
|
|
const callsSeries = useMemo<Series[]>(
|
|
() => seriesFor(dailyChartData, theme, ['calls', 'errors'], incompleteTail),
|
|
[dailyChartData, theme, incompleteTail]
|
|
)
|
|
const latencySeries = useMemo<Series[]>(
|
|
() => seriesFor(dailyChartData, theme, ['p50', 'p95'], incompleteTail),
|
|
[dailyChartData, theme, incompleteTail]
|
|
)
|
|
const countsConfig = useChartConfig(() => trendChartConfig(timezone, interval), [timezone, interval])
|
|
const latencyConfig = useChartConfig(
|
|
() => trendChartConfig(timezone, interval, { tickFormatter: formatMsAsSeconds }),
|
|
[timezone, interval]
|
|
)
|
|
|
|
return (
|
|
<SceneContent>
|
|
<SceneTitleSection
|
|
name={toolName}
|
|
description={null}
|
|
resourceType={{ type: 'mcp_analytics' }}
|
|
forceBackTo={{
|
|
name: 'Tool quality',
|
|
path: mcpToolQualityUrlWithDates(dateFilter),
|
|
key: 'mcp-analytics-tool-quality',
|
|
}}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
<DescriptionBlock descriptions={descriptions} loading={descriptionsLoading} />
|
|
<StatTiles
|
|
summary={summary}
|
|
loading={summaryLoading}
|
|
daily={dailyChartData}
|
|
theme={theme}
|
|
dateRangeLabel={dateRangeLabel}
|
|
interval={interval}
|
|
incompleteTail={incompleteTail}
|
|
/>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
<SectionHeader title="Reliability" subtitle={dateRangeLabel} />
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<TrendChart
|
|
title="Calls and errors"
|
|
series={callsSeries}
|
|
labels={dailyChartData.labels}
|
|
config={countsConfig}
|
|
theme={theme}
|
|
loading={dailyStatsLoading}
|
|
dataAttr="mcp-tool-detail-calls-chart"
|
|
/>
|
|
<TrendChart
|
|
title="Duration (p50 / p95)"
|
|
series={latencySeries}
|
|
labels={dailyChartData.labels}
|
|
config={latencyConfig}
|
|
theme={theme}
|
|
loading={dailyStatsLoading}
|
|
dataAttr="mcp-tool-detail-latency-chart"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
<SectionHeader title="Usage flow" />
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<ResultTable
|
|
title="Sample intents"
|
|
action={<IntentCoverageTag coverage={intentCoverage} loading={intentCoverageLoading} />}
|
|
rows={sampleIntentRows}
|
|
loading={sampleIntentRowsLoading}
|
|
emptyMessage="No intents captured for the selected date range."
|
|
columns={[
|
|
{ header: 'When', render: (r) => <TZLabel time={String(r[0])} /> },
|
|
{ header: 'Intent', expand: true, render: (r) => <span>{String(r[1] ?? '')}</span> },
|
|
{ header: 'Intent source', render: (r) => <span>{String(r[2] ?? '')}</span> },
|
|
{ header: 'Harness', render: (r) => <span>{String(r[3] ?? '')}</span> },
|
|
]}
|
|
/>
|
|
<div className="flex flex-col gap-4">
|
|
<ResultTable
|
|
title={
|
|
<span className="flex items-center gap-1">
|
|
<IconArrowLeft className="text-base" />
|
|
Often called before (same conversation)
|
|
</span>
|
|
}
|
|
rows={neighborsBeforeRows}
|
|
loading={neighborsBeforeRowsLoading}
|
|
emptyMessage="No tools commonly precede this one."
|
|
columns={neighborColumns}
|
|
/>
|
|
<ResultTable
|
|
title={
|
|
<span className="flex items-center gap-1">
|
|
<IconArrowRight className="text-base" />
|
|
Often called after (same conversation)
|
|
</span>
|
|
}
|
|
rows={neighborsAfterRows}
|
|
loading={neighborsAfterRowsLoading}
|
|
emptyMessage="No tools commonly follow this one."
|
|
columns={neighborColumns}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
<SectionHeader title="Intents served" subtitle="From the latest intent cluster snapshot" />
|
|
<ToolDetailIntentsSection toolName={toolName} />
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
<SectionHeader title="Who uses it" />
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<ResultTable
|
|
title="By harness"
|
|
rows={byHarnessRows}
|
|
loading={byHarnessRowsLoading}
|
|
columns={[
|
|
{
|
|
header: 'Harness',
|
|
expand: true,
|
|
render: (r) => {
|
|
const label = String(r[0] ?? '')
|
|
return label ? (
|
|
<HarnessPill category={label} title={label} />
|
|
) : (
|
|
<span className="text-muted">Unknown</span>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
header: 'Calls',
|
|
align: 'right',
|
|
render: (r) => humanFriendlyNumber(Number(r[1] ?? 0)),
|
|
},
|
|
{
|
|
header: 'Errors',
|
|
align: 'right',
|
|
render: (r) => humanFriendlyNumber(Number(r[2] ?? 0)),
|
|
},
|
|
{ header: 'Error rate', align: 'right', render: (r) => `${Number(r[3] ?? 0)}%` },
|
|
{
|
|
header: 'Sessions',
|
|
align: 'right',
|
|
render: (r) => humanFriendlyNumber(Number(r[4] ?? 0)),
|
|
},
|
|
]}
|
|
/>
|
|
<ResultTable
|
|
title="Top users"
|
|
rows={topUserRows}
|
|
loading={topUserRowsLoading}
|
|
columns={[
|
|
{ header: 'User', expand: true, render: (r) => renderPersonCell(r[0]) },
|
|
{
|
|
header: 'Calls',
|
|
align: 'right',
|
|
render: (r) => humanFriendlyNumber(Number(r[1] ?? 0)),
|
|
},
|
|
{
|
|
header: 'Errors',
|
|
align: 'right',
|
|
render: (r) => humanFriendlyNumber(Number(r[2] ?? 0)),
|
|
},
|
|
{ header: 'Error rate', align: 'right', render: (r) => `${Number(r[3] ?? 0)}%` },
|
|
{
|
|
header: 'Harnesses',
|
|
render: (r) => <HarnessLogos labels={(r[4] as string[]) ?? []} />,
|
|
},
|
|
{ header: 'Last seen', render: (r) => <TZLabel time={String(r[5])} /> },
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
<ResultTable
|
|
title="Failures"
|
|
description="Errored calls of this tool grouped by error type and HTTP status. Sourced from the $mcp_is_error flag on $mcp_tool_call events, the same source as the error rate above. Click a row to see individual occurrences."
|
|
rows={failureBuckets.map((b) => [b.message, b.occurrences, b.last_seen, b.harnesses])}
|
|
loading={failureBucketsLoading}
|
|
emptyMessage="No errored calls recorded for this tool in the selected date range."
|
|
onRowClick={(i) => selectFailure(failureBuckets[i] ?? null)}
|
|
columns={[
|
|
{
|
|
header: 'Error type',
|
|
expand: true,
|
|
render: (r) => <span className="font-mono text-xs">{String(r[0] ?? '')}</span>,
|
|
},
|
|
{
|
|
header: 'Occurrences',
|
|
align: 'right',
|
|
render: (r) => humanFriendlyNumber(Number(r[1] ?? 0)),
|
|
},
|
|
{ header: 'Last seen', render: (r) => <TZLabel time={String(r[2])} /> },
|
|
{
|
|
header: 'Harnesses',
|
|
render: (r) => <HarnessLogos labels={(r[3] as string[]) ?? []} />,
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<FailureOccurrencesModal toolName={toolName} />
|
|
</SceneContent>
|
|
)
|
|
}
|
|
|
|
// Drill-down into one failure bucket: the individual errored calls, each with a
|
|
// copyable, paste-ready context block for handing to a coding agent.
|
|
function FailureOccurrencesModal({ toolName }: { toolName: string }): JSX.Element {
|
|
const { selectedFailure, failureOccurrences, failureOccurrencesLoading } = useValues(
|
|
mcpAnalyticsToolDetailLogic({ toolName })
|
|
)
|
|
const { selectFailure } = useActions(mcpAnalyticsToolDetailLogic({ toolName }))
|
|
|
|
const occurrenceContext = (o: MCPToolFailureOccurrenceItem): MCPErrorContext => ({
|
|
toolName,
|
|
errorType: selectedFailure?.error_type ?? '',
|
|
errorStatus: o.error_status || selectedFailure?.error_status || undefined,
|
|
errorMessage: o.error_message || undefined,
|
|
timestamp: o.timestamp,
|
|
harness: o.harness,
|
|
intent: o.intent,
|
|
sessionId: o.session_id || undefined,
|
|
})
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={selectedFailure != null}
|
|
onClose={() => selectFailure(null)}
|
|
title={
|
|
<span>
|
|
Failures: <span className="font-mono">{selectedFailure?.message}</span>
|
|
</span>
|
|
}
|
|
description="Most recent errored calls in this bucket (up to 50, last 7 days). Copy an occurrence's context to hand it to a coding agent."
|
|
width={880}
|
|
>
|
|
<ResultTable
|
|
rows={failureOccurrences.map((o) => [o.timestamp, o.harness, o.error_message, o])}
|
|
loading={failureOccurrencesLoading}
|
|
emptyMessage="No occurrences found in the last 7 days."
|
|
columns={[
|
|
{ header: 'When', render: (r) => <TZLabel time={String(r[0])} /> },
|
|
{ header: 'Harness', render: (r) => <span>{String(r[1] ?? '') || '—'}</span> },
|
|
{
|
|
header: 'Error message',
|
|
expand: true,
|
|
render: (r) =>
|
|
r[2] ? (
|
|
<span className="font-mono text-xs whitespace-pre-wrap break-all line-clamp-4">
|
|
{String(r[2])}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted text-xs">
|
|
Not captured (event predates error message capture)
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
header: '',
|
|
render: (r) => {
|
|
const occurrence = r[3] as MCPToolFailureOccurrenceItem
|
|
return (
|
|
<div className="flex items-center gap-1 whitespace-nowrap">
|
|
<Button
|
|
size="xsmall"
|
|
icon={<IconCopy />}
|
|
tooltip="Copy error context for a coding agent"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void copyToClipboard(
|
|
formatErrorContext(occurrenceContext(occurrence)),
|
|
'error context'
|
|
)
|
|
}}
|
|
/>
|
|
<CreateFixTaskButton context={occurrenceContext(occurrence)} />
|
|
{occurrence.session_id ? (
|
|
<Button
|
|
size="xsmall"
|
|
to={mcpSessionUrl(occurrence.session_id)}
|
|
tooltip="View the session this call belongs to"
|
|
>
|
|
Session
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default MCPAnalyticsToolDetail
|