feat(mcp-analytics): add view toggles to tool quality charts (#122615)

Co-authored-by: tests-posthog[bot] <250237707+tests-posthog[bot]@users.noreply.github.com>
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-23 21:56:14 +00:00
1 parent f1c6477271
commit 999a686b22
20 files changed
+682 -119

No files matched your search

+14 -6
View File
@@ -13689,9 +13689,9 @@ snapshots:
scenes-app-mcp-analytics--sessions--light:
hash: v1.k794b7964.4e4bc9c4c3e77f2bf6071c4bf43c485d8c3c3281dfc4bfd7d8c5b8b892698c2b.2_jSggYstDMkIwzqbCdU5r3V6nRf79q5gbIF07BqoFI
scenes-app-mcp-analytics--tool-quality--dark:
hash: v1.k794b7964.642dca04a5c6cb07edc0f125e9c7e0426a5a5dba0f2d121dd7b9bad9fd2709c9.a8J-gQWdL8aNFNlZuBxwf86m16C4h99rTH0vTEJE4Tc
hash: v1.k794b7964.cb825fa2048314329b2d1ddde235effc76bffc9bad07e12e859f6be5bb8a9088.NnX0R8CTRpFazdPiMOozvm7hRI5VmhIMwtwBdLW5kvo
scenes-app-mcp-analytics--tool-quality--light:
hash: v1.k794b7964.8dbab167c718fb85171e2970e2f1bca8d7fd33ef1a5f99f31f45d9c87fa8bcfd.P8hWCPXmKWCxoka2Uebtt_is6LzR79LQXW7CAVDpAz0
hash: v1.k794b7964.4c76a162f0aab444aad0e385b42301f42dbff493048d89f9a2a6371cf5b26a97.Zg4iXH7MQw-cSB8sf0Nx0uQoTtj4v5uwUkZJ-2vG-9o
scenes-app-mcp-analytics-dashboard-cards--daily-calls-and-errors--dark:
hash: v1.k794b7964.7302f068e1b6a63086ac35ffb7e7e1b9d10b8608cb7c6bb7033d341638e2d695.51PNQHMCZinl1fgHrAFmryj0im81JG1jIjU-DQc0oPY
scenes-app-mcp-analytics-dashboard-cards--daily-calls-and-errors--light:
@@ -13824,14 +13824,22 @@ snapshots:
hash: v1.k794b7964.d16471442b9a4b1f9adf3f0df6152511ac5b59ad628a0496fc6a8f91d08c5a50._eeiIWQ37n85L6iOnx7TmmIxLps7n_lpWsS72ovBMS8
scenes-app-mcp-analytics-tool-detail--tool-detail--wide--light:
hash: v1.k794b7964.669a37c3a1c1bcb92622f42e91b6d462a06b60b68ea673f6bbcdef5f92c2bcc5.lT14awb41-asj-hbHh1XoO4UvXvBtO5dBLgM1iGYrcc
scenes-app-mcp-analytics-tool-quality-charts--alternate-views--dark:
hash: v1.k794b7964.e9499ed0791ce7c4cbeefa71baec102a4f10bc93b8cc0644cdfb42747defaaae.Ww4jYp5zMAaxTCuXE5Pih3Spu2gbPcjt3WUUSB1dj6A
scenes-app-mcp-analytics-tool-quality-charts--alternate-views--light:
hash: v1.k794b7964.a7c3079d815f1a293ce52c68a42d120470798ce63aa80fd42ad2640bfdbfb165.jQRMcjGW7-DWdkgTRYHea376rY1DNwgq2CCptgX081M
scenes-app-mcp-analytics-tool-quality-charts--in-progress-bucket--dark:
hash: v1.k794b7964.6dd3665d59b7590cdbba24e83fb952ffc45f74dc15e5811b5bfe004af314ead6.sGmDMl0-TraAucPmgpjftNcfxWrhDZXgFgxRNVUFp1Y
hash: v1.k794b7964.e81744e4b920cb4cd759be8d3d53865cfe47bcc9a08ada8b9b6dcb05fc21e928.oXwrNkVWTLqV4rNnoutT8D2YaqTzjXzb2GSZgWsL-VM
scenes-app-mcp-analytics-tool-quality-charts--in-progress-bucket--light:
hash: v1.k794b7964.e420c78dc1f17d25c0593523652744e5733863e7fea2c83e19189e7a4d344745.Vt8eEiLMgwPjVEe-jBx5AxTGjDGVKkQ86W3OBOQ8dM8
hash: v1.k794b7964.f096911f56400a0e248f31bd8e0ec1302d1be6bd1d0ffa222aba9c0158f357d9.XZdJmuLxDkWuYMi5OxjFrqTwADk9g7VUKNEsa_hIgxs
scenes-app-mcp-analytics-tool-quality-charts--settled-window--dark:
hash: v1.k794b7964.89373cf0427b202db6e6ceec7f39b7911511c2232e387590b30996202c61c7ae.r5KlyGE1Ptg8_EgItNlOeF9kLDjoDxyJtbGS3qjbUMI
hash: v1.k794b7964.ff8edd43f4a9606309f8056415f0d17715484ebcc6a87e812c137fd41f8277ce.I6_soPLSDVe5feeH8ozHKa-NwbCza8XKSeRjsElrcHE
scenes-app-mcp-analytics-tool-quality-charts--settled-window--light:
hash: v1.k794b7964.e228d0cb022434d103ef966ff2c045c3af007c1c13344744415c8aa4a15f3e69.NpzqD2GndzaOR7sUxRrnw178bloUN40owK1UJknnouw
hash: v1.k794b7964.9d1700097bba274a4db3868e46eae744f31a6bbdfd20b66983cb25822948feb6.ZodyNJKA5x6mTcWHjG5zhoFiFX_3d5_EG-VlSuSsmAo
scenes-app-mcp-analytics-tool-quality-charts--toggle-wraps-below-title--dark:
hash: v1.k794b7964.0ba2fe01eb8354405b42dff914b6bd93c46332158f2abc0a89debcd5e79063ac.LUiM4CJpec9ONjQnnDbNL8kNkaJPQK8xBB8bdPgu01Y
scenes-app-mcp-analytics-tool-quality-charts--toggle-wraps-below-title--light:
hash: v1.k794b7964.c260d1264a59afc8e03f867b2814ae9b713a4034979750b522939977e222033e.hS_jhj7c_-8UHf5BFTHcnfaewRvEJULaYwC0W8z1v6U
scenes-app-mcp-gateway-server--page--dark:
hash: v1.k794b7964.181ef8620fa10882b33a2277dd7729a466e6faa6dd5d47fbf5a8bb2d3d90428e.WgP3PJHt3L3hZCpRpi_ZUpa5I2vqj3yZ-pin7RGupNA
scenes-app-mcp-gateway-server--page--light:
+16
View File
@@ -33875,9 +33875,25 @@
"errors": {
"$ref": "#/definitions/integer"
},
"max": {
"type": ["number", "null"]
},
"mean": {
"type": ["number", "null"]
},
"min": {
"description": "Latency spread for the box plot view. Optional so responses cached before these fields existed still parse.",
"type": ["number", "null"]
},
"p25": {
"type": ["number", "null"]
},
"p50": {
"type": "number"
},
"p75": {
"type": ["number", "null"]
},
"p95": {
"type": "number"
},
@@ -3921,6 +3921,12 @@ export interface MCPToolQualityDailyStatItem {
p50: number
p95: number
p99: number
/** Latency spread for the box plot view. Optional so responses cached before these fields existed still parse. */
min?: number | null
p25?: number | null
mean?: number | null
p75?: number | null
max?: number | null
}
export interface MCPToolQualityDailyStatsQueryResponse extends AnalyticsQueryResponseBase {
@@ -2,7 +2,7 @@ import { waitFor } from '@testing-library/react'
import type { ChartTheme } from '../../core/types'
import { renderHogChart, waitForHogChartTooltip } from '../../testing'
import { BoxPlot, type BoxPlotClickData } from './BoxPlot'
import { BoxPlot, type BoxPlotClickData, type BoxPlotConfig } from './BoxPlot'
import type { BoxPlotDatum, BoxPlotSeries } from './types'
const THEME: ChartTheme = {
@@ -80,6 +80,20 @@ describe('BoxPlot', () => {
expect(maxTick).toBeLessThan(1000)
})
it.each([
{ name: 'above the upper whisker', override: { mean: 700, whiskerMax: 100 }, expectTick: 700 },
{ name: 'below the lower whisker', override: { mean: -50, whiskerMin: 10 }, expectTick: -50 },
])('keeps a mean $name inside the y-axis', ({ override, expectTick }) => {
const series: BoxPlotSeries[] = [{ key: 'a', label: 'A', data: [datum(override)] }]
const { chart } = renderHogChart(<BoxPlot series={series} labels={['Mon']} theme={THEME} />)
const nums = chart
.yTicks()
.map((t) => parseFloat(t.replace(/[^\d.-]/g, '')))
.filter((n) => Number.isFinite(n))
expect(Math.max(...nums)).toBeGreaterThanOrEqual(Math.max(expectTick, 0))
expect(Math.min(...nums)).toBeLessThanOrEqual(Math.min(expectTick, 0))
})
it('allocates left margin wide enough for the whisker-derived y ticks (not just medians)', () => {
// Regression: clipping of the y-tick column. `useChartMargins` reads
// `seriesValueRange(adaptedSeries)` to size the left margin, so the adapted series'
@@ -235,19 +249,47 @@ describe('BoxPlot', () => {
['Min', '1'],
],
},
])('$name', async ({ override, rows }) => {
const series: BoxPlotSeries[] = [{ key: 'a', label: 'A-label', data: [datum(override), datum(), datum()] }]
const { chart } = renderHogChart(<BoxPlot series={series} labels={LABELS} theme={THEME} />, {
nativeTooltip: true,
})
chart.hoverAtIndex(0)
const tooltipEl = await waitForHogChartTooltip()
const rendered = Array.from(tooltipEl.querySelectorAll('tr')).map((tr) => [
(tr.firstElementChild as HTMLElement).textContent!.trim(),
(tr.lastElementChild as HTMLElement).textContent!.trim(),
])
expect(rendered).toEqual(rows)
})
{
name: 'formats every row with the tooltip value formatter',
override: { min: 1, p25: 20, median: 30, mean: 40, p75: 50, max: 90, whiskerMax: 80 },
config: { tooltip: { valueFormatter: (v: number) => `${v}ms` } },
rows: [
['Max', '90ms'],
['Upper whisker', '80ms'],
['75th percentile', '50ms'],
['Median', '30ms'],
['Mean', '40ms'],
['25th percentile', '20ms'],
['Min', '1ms'],
],
},
])(
'$name',
async ({
override,
rows,
config,
}: {
override: Partial<BoxPlotDatum>
rows: string[][]
config?: BoxPlotConfig
}) => {
const series: BoxPlotSeries[] = [
{ key: 'a', label: 'A-label', data: [datum(override), datum(), datum()] },
]
const { chart } = renderHogChart(
<BoxPlot series={series} labels={LABELS} theme={THEME} config={config} />,
{ nativeTooltip: true }
)
chart.hoverAtIndex(0)
const tooltipEl = await waitForHogChartTooltip()
const rendered = Array.from(tooltipEl.querySelectorAll('tr')).map((tr) => [
(tr.firstElementChild as HTMLElement).textContent!.trim(),
(tr.lastElementChild as HTMLElement).textContent!.trim(),
])
expect(rendered).toEqual(rows)
}
)
it('renders the values in the canonical row order', async () => {
const { chart } = renderHogChart(<BoxPlot series={SINGLE_SERIES} labels={LABELS} theme={THEME} />, {
@@ -182,6 +182,11 @@ function BoxPlotInner<Meta = unknown>({
if (Number.isFinite(whiskerMax)) {
maxs.push(whiskerMax)
}
// A skewed bucket can put the mean outside its whiskers.
if (Number.isFinite(datum.mean)) {
mins.push(datum.mean)
maxs.push(datum.mean)
}
}
if (mins.length > 0) {
out.push({ key: `${s.key}__min`, label: s.label, data: mins })
@@ -369,9 +374,14 @@ function BoxPlotInner<Meta = unknown>({
const renderTooltip = useCallback(
(ctx: TooltipContext<BoxPlotAdaptedMeta<Meta>>) => (
<BoxPlotTooltip<Meta> ctx={ctx} userTooltip={tooltip} grouped={grouped} />
<BoxPlotTooltip<Meta>
ctx={ctx}
userTooltip={tooltip}
grouped={grouped}
valueFormatter={config?.tooltip?.valueFormatter}
/>
),
[tooltip, grouped]
[tooltip, grouped, config?.tooltip?.valueFormatter]
)
const onPointClick = useCallback(
@@ -35,22 +35,25 @@ export interface BoxPlotTooltipProps<Meta = unknown> {
/** Whether multiple series are being shown — drives whether each box's series label is
* printed in the header above its stats. */
grouped: boolean
/** Formats each statistic. Defaults to `toLocaleString`. */
valueFormatter?: (value: number, entry: TooltipContext['seriesData'][number]) => string
}
function defaultFormatValue(v: number): string {
return Number.isFinite(v) ? v.toLocaleString() : '—'
return v.toLocaleString()
}
export function BoxPlotTooltip<Meta = unknown>({
ctx,
userTooltip,
grouped,
valueFormatter = defaultFormatValue,
}: BoxPlotTooltipProps<Meta>): React.ReactElement | null {
if (userTooltip) {
return <>{userTooltip(ctx)}</>
}
const entries: { key: string; color: string; label: string; datum: BoxPlotDatum }[] = []
const entries: { datum: BoxPlotDatum; seriesEntry: TooltipContext['seriesData'][number] }[] = []
for (const seriesEntry of ctx.seriesData) {
if (seriesEntry.series.visibility?.tooltip === false) {
continue
@@ -59,12 +62,7 @@ export function BoxPlotTooltip<Meta = unknown>({
if (!datum) {
continue
}
entries.push({
key: seriesEntry.series.key,
color: seriesEntry.color,
label: seriesEntry.series.label,
datum,
})
entries.push({ datum, seriesEntry })
}
if (entries.length === 0) {
return null
@@ -74,11 +72,11 @@ export function BoxPlotTooltip<Meta = unknown>({
<TooltipSurface data-attr="hog-chart-boxplot-tooltip">
<div className="font-semibold mb-1">{ctx.label}</div>
{entries.map((entry, i) => (
<div key={entry.key} className={i > 0 ? 'mt-2' : undefined}>
<div key={entry.seriesEntry.series.key} className={i > 0 ? 'mt-2' : undefined}>
{grouped && (
<div className="flex items-center gap-2 mb-1">
<TooltipSwatch color={entry.color} />
<span className="font-semibold">{entry.label}</span>
<TooltipSwatch color={entry.seriesEntry.color} />
<span className="font-semibold">{entry.seriesEntry.series.label}</span>
</div>
)}
<table className="border-collapse">
@@ -86,7 +84,11 @@ export function BoxPlotTooltip<Meta = unknown>({
{tooltipRows(entry.datum).map((row) => (
<tr key={row.label}>
<td className="pr-3 opacity-70">{row.label}</td>
<td className="font-medium">{defaultFormatValue(row.value)}</td>
<td className="font-medium">
{Number.isFinite(row.value)
? valueFormatter(row.value, entry.seriesEntry)
: '—'}
</td>
</tr>
))}
</tbody>
@@ -102,6 +102,7 @@ Supports `config.legend` for grouped series and `onBoxClick`.
`axisOrientation: 'horizontal'` lists the labels down the y-axis and draws values along x; `yScaleType: 'log'` then applies to that x value axis, and `yTickFormatter` formats its ticks.
Whiskers are drawn at `min` and `max`. Set `whiskerMin` and `whiskerMax` on a datum to clip them (for example to 1.5 × IQR) while `min` and `max` stay the true extremes; the y-axis then spans the whiskers, and the default tooltip lists both.
The tooltip receives a `BoxPlotTooltipContext`; `BoxPlotTooltip` is the default.
`config.tooltip.valueFormatter` formats each statistic in the default tooltip, so it can match `yTickFormatter`.
Boxes have a light tint of the series color, a thick median line, and whiskers in the axis color. Set `config.boxCornerRadius: 0` for square corners.
## Heatmap
+1 -1
View File
@@ -139,7 +139,7 @@ Don't hand-roll `<p className="text-xs text-muted-foreground">` when `<Text size
| ------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Button | default, primary, secondary, outline, destructive, destructive-outline, link, link-muted | default, xs, sm, lg, icon, icon-xs, icon-sm, icon-lg, row, inline | `loading` overlays a centered spinner and disables the button (width stays stable). `row` is the full-width, regular-weight list row that menu, combobox and autocomplete items render. `inline` is a link inside a sentence: no height or padding, and it inherits the sentence's font size and line height. `elevated` adds heavy CTA chrome — see Elevated button. `tooltip` adds a hover and focus tooltip. `indicator="default"` puts a dot on the top-right corner for something waiting behind the button (`"warning"` or `"destructive"` for a problem); say what it means in the `aria-label`, and set `--quill-button-indicator-ring` when the button sits off the app background. `disabledReason` disables the button and shows the reason in that tooltip. A `render` that carries a link destination (`to`, `href`) or is a non-button element turns off `nativeButton` by itself; a disabled button drops that destination. |
| Badge | default, info, destructive, warning, success, completed | — | Semantic status |
| Toggle | default, outline | default, sm, lg, icon | |
| Toggle | default, outline | default, sm, lg, icon | `ToggleGroupItem` takes `disabledReason`, which disables the item and shows the reason in a tooltip on hover. Arrow keys in a `ToggleGroup` skip disabled items, so the reason is hover-only. |
| Chip | outline | sm | Use with ChipClose |
| Separator | — | — | orientation: horizontal/vertical |
| Spinner | — | — | SVG, accepts svg props |
@@ -101,7 +101,7 @@ export {
ThreadItemRepliesMeta,
} from './chat/thread-item'
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants } from './button-group'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card'
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } from './card'
export { CardGroup } from './card-group'
export { Checkbox, CheckboxIndicator } from './checkbox'
export { Chip, ChipClose, ChipGroup } from './chip'
@@ -1,3 +1,5 @@
import './toggle-group.css'
import { Toggle as TogglePrimitive } from '@base-ui/react/toggle'
import { ToggleGroup as ToggleGroupPrimitive } from '@base-ui/react/toggle-group'
import { type VariantProps } from 'class-variance-authority'
@@ -5,8 +7,8 @@ import * as React from 'react'
import { Button } from './button'
import { cn } from './lib/utils'
import './toggle-group.css'
import { toggleVariants } from './toggle'
import { hasTooltipContent } from './with-tooltip'
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
@@ -53,8 +55,15 @@ const ToggleGroup = React.forwardRef<
const ToggleGroupItem = React.forwardRef<
React.ComponentRef<typeof TogglePrimitive>,
TogglePrimitive.Props & VariantProps<typeof toggleVariants>
>(function ToggleGroupItem({ className, children, variant = 'default', size = 'default', ...props }, ref) {
TogglePrimitive.Props &
VariantProps<typeof toggleVariants> & {
/** Disables the item and says why in a tooltip on hover. */
disabledReason?: React.ReactNode
}
>(function ToggleGroupItem(
{ className, children, variant = 'default', size = 'default', disabled, disabledReason, ...props },
ref
) {
const context = React.useContext(ToggleGroupContext)
return (
@@ -73,7 +82,8 @@ const ToggleGroupItem = React.forwardRef<
className
)}
{...props}
render={(props) => <Button variant="outline" size={size} {...props} />}
disabled={disabled || hasTooltipContent(disabledReason)}
render={(props) => <Button variant="outline" size={size} disabledReason={disabledReason} {...props} />}
>
{children}
</TogglePrimitive>
+11
View File
@@ -6672,7 +6672,18 @@ class MCPToolQualityDailyStatItem(BaseModel):
calls: int
day: str
errors: int
max: float | None = None
mean: float | None = None
min: float | None = Field(
default=None,
description=(
"Latency spread for the box plot view. Optional so responses cached before"
" these fields existed still parse."
),
)
p25: float | None = None
p50: float
p75: float | None = None
p95: float
p99: float
@@ -58,9 +58,12 @@ if TYPE_CHECKING:
# The tab scopes to $mcp_tool_call events that carry a tool name. Category and tool are event-supplied
# and bound as constants (never interpolated).
_P50 = "round(quantile(0.5)(toFloat(properties.$mcp_duration_ms)))"
_P95 = "round(quantile(0.95)(toFloat(properties.$mcp_duration_ms)))"
_P99 = "round(quantile(0.99)(toFloat(properties.$mcp_duration_ms)))"
# One sample for every level keeps p25 <= p50 <= p75. quantiles() returns NaN, not NULL, for a bucket
# with no durations, so map NaN back to NULL.
_LATENCY_QUANTILES = (
"arrayMap(q -> if(isNaN(q), NULL, round(q)), "
"quantiles(0.25, 0.5, 0.75, 0.95, 0.99)(toFloat(properties.$mcp_duration_ms)))"
)
_IS_ERROR = "countIf(toBool(properties.$mcp_is_error))"
_TOOL_ROW_DEFAULT_LIMIT = 50
_TOOL_ROW_MAX_LIMIT = 100
@@ -397,25 +400,28 @@ class MCPToolQualityDailyStatsQueryRunner(AnalyticsQueryRunner[MCPToolQualityDai
interval = self.query.interval.value if self.query.interval else "day"
return parse_select(
"""
SELECT
toString(dateTrunc({interval}, timestamp)) AS day,
count() AS calls,
{_IS_ERROR} AS errors,
{_P50} AS p50,
{_P95} AS p95,
{_P99} AS p99
FROM events
WHERE {where}
GROUP BY day
SELECT day, calls, errors, latency[2] AS p50, latency[4] AS p95, latency[5] AS p99,
min, latency[1] AS p25, mean, latency[3] AS p75, max
FROM (
SELECT
toString(dateTrunc({interval}, timestamp)) AS day,
count() AS calls,
{_IS_ERROR} AS errors,
{_LATENCY_QUANTILES} AS latency,
min(toFloat(properties.$mcp_duration_ms)) AS min,
round(avg(toFloat(properties.$mcp_duration_ms))) AS mean,
max(toFloat(properties.$mcp_duration_ms)) AS max
FROM events
WHERE {where}
GROUP BY day
)
ORDER BY day
LIMIT 10000
""",
placeholders={
"interval": ast.Constant(value=interval),
"_IS_ERROR": parse_expr(_IS_ERROR),
"_P50": parse_expr(_P50),
"_P95": parse_expr(_P95),
"_P99": parse_expr(_P99),
"_LATENCY_QUANTILES": parse_expr(_LATENCY_QUANTILES),
"where": _named_tool_where(
_within(self.query_date_range.date_from_as_hogql(), self.query_date_range.date_to_as_hogql()),
self.query.categories,
@@ -452,6 +458,13 @@ class MCPToolQualityDailyStatsQueryRunner(AnalyticsQueryRunner[MCPToolQualityDai
p50=float(row[3] or 0),
p95=float(row[4] or 0),
p99=float(row[5] or 0),
# None (not 0) when every duration in the bucket is missing, so the box plot
# skips the bucket instead of drawing a flat line at zero.
min=None if row[6] is None else float(row[6]),
p25=None if row[7] is None else float(row[7]),
mean=None if row[8] is None else float(row[8]),
p75=None if row[9] is None else float(row[9]),
max=None if row[10] is None else float(row[10]),
)
for row in (response.results or [])
]
@@ -380,6 +380,31 @@ class TestMCPToolQualityDailyStatsQueryRunner(_MCPAnalyticsTeamScopedTestMixin,
assert sum(r.calls for r in rows) == 2
def test_returns_the_latency_spread_for_box_plots(self) -> None:
for duration_ms in [100, 200, 300, 400, 500]:
_emit(self.team, duration_ms=duration_ms)
flush_persons_and_events()
runner = MCPToolQualityDailyStatsQueryRunner(
query=MCPToolQualityDailyStatsQuery(dateRange=DateRange(date_from="-7d")),
team=self.team,
)
[row] = runner.calculate().results
assert (row.min, row.p25, row.p50, row.mean, row.p75, row.max) == (100, 200, 300, 300, 400, 500)
def test_returns_none_for_latency_spread_when_durations_are_missing(self) -> None:
_emit(self.team, duration_ms=None)
flush_persons_and_events()
runner = MCPToolQualityDailyStatsQueryRunner(
query=MCPToolQualityDailyStatsQuery(dateRange=DateRange(date_from="-7d")),
team=self.team,
)
[row] = runner.calculate().results
assert (row.min, row.p25, row.mean, row.p75, row.max) == (None, None, None, None, None)
class TestMCPToolCategoryCountsQueryRunner(_MCPAnalyticsTeamScopedTestMixin, ClickhouseTestMixin, APIBaseTest):
def test_counts_per_category_including_uncategorized(self) -> None:
@@ -104,8 +104,10 @@ export function MCPAnalyticsToolQuality(): JSX.Element {
availableCategoriesLoading,
categoryCountsLoading,
toolRowsPageLoading,
chartViews,
latencyBoxes,
} = useValues(mcpAnalyticsToolQualityLogic)
const { setDateFilter, reloadAll } = useActions(mcpAnalyticsToolQualityLogic)
const { setDateFilter, reloadAll, setChartView } = useActions(mcpAnalyticsToolQualityLogic)
const { timezone } = useValues(teamLogic)
const theme = useChartTheme()
@@ -139,6 +141,9 @@ export function MCPAnalyticsToolQuality(): JSX.Element {
timezone={timezone}
interval={interval}
incompleteTail={incompleteTail}
views={chartViews}
latencyBoxes={latencyBoxes}
onViewChange={setChartView}
/>
<ToolQualityTable />
</div>
@@ -1,22 +1,26 @@
import { Card as QuillCard, CardContent, CardHeader, CardTitle } from '@posthog/quill-primitives'
import { Card as QuillCard, CardAction, CardContent, CardHeader, CardTitle } from '@posthog/quill-primitives'
export function Card({
children,
className,
flush = false,
title,
action,
}: {
children: React.ReactNode
className?: string
/** Let a full-bleed child (table, chart) reach the card's edges, with no header-to-content gap. */
flush?: boolean
title?: string
/** Control shown at the top right of the header. */
action?: React.ReactNode
}): JSX.Element {
return (
<QuillCard size="sm" flush={flush} className={className}>
{title ? (
<CardHeader>
<CardHeader className={action ? 'flex flex-wrap items-start justify-between gap-2' : undefined}>
<CardTitle>{title}</CardTitle>
{action ? <CardAction>{action}</CardAction> : null}
</CardHeader>
) : null}
{/* min-h-0 lets a height-constrained card's content shrink instead of overflowing the card's clip. */}
@@ -1,4 +1,5 @@
import { expectLogic } from 'kea-test-utils'
import posthog from 'posthog-js'
import api from 'lib/api'
@@ -7,7 +8,14 @@ import { initKeaTests } from '~/test/init'
import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types'
import { formatMsAsSeconds } from './dashboard/formatters'
import { type DailyToolStat, buildDailyChartData, mcpAnalyticsToolQualityLogic } from './mcpAnalyticsToolQualityLogic'
import {
type ChartViewChange,
DEFAULT_CHART_VIEWS,
type DailyToolStat,
buildDailyChartData,
maskIncompleteLatencyBox,
mcpAnalyticsToolQualityLogic,
} from './mcpAnalyticsToolQualityLogic'
jest.mock('lib/api')
jest.mock('~/queries/query', () => ({
@@ -39,6 +47,56 @@ describe('mcpAnalyticsToolQualityLogic', () => {
expect(data.successRate[0]).toBeCloseTo(90)
expect(data.successRate[1]).toBeNaN()
expect(data.p99).toEqual([2100, NaN, 1500])
expect(data.errorRate[0]).toBeCloseTo(10)
expect(data.errorRate[1]).toBeNaN()
expect(data.errorRate[2]).toEqual(0)
})
it.each([
['no errors', 10, 0, 0],
['half the calls errored', 10, 5, 50],
['the row has no calls', 0, 0, NaN],
])('gives the error rate as a percentage of calls when %s', (_label, calls, errors, expected) => {
const data = buildDailyChartData([dailyStat({ day: '2026-06-05', calls, errors })], ['2026-06-05 00:00:00'])
expect(data.errorRate).toEqual([expected])
})
it('builds a latency box per bucket with whiskers clipped at p99', () => {
const data = buildDailyChartData(
[
dailyStat({
day: '2026-06-05',
calls: 3,
min: 10,
p25: 50,
p50: 100,
mean: 120,
p75: 200,
p95: 900,
p99: 2100,
max: 60000,
}),
dailyStat({ day: '2026-06-06', calls: 1, p50: 100, p95: 100, p99: 100 }),
dailyStat({ day: '2026-06-07', calls: 2, min: null, p25: null, mean: null, p75: null, max: null }),
],
['2026-06-05 00:00:00', '2026-06-06 00:00:00', '2026-06-07 00:00:00']
)
expect(data.latencyBoxes).toEqual([
{
day: '2026-06-05 00:00:00',
min: 10,
p25: 50,
median: 100,
mean: 120,
p75: 200,
max: 60000,
whiskerMax: 2100,
},
// A row cached before the spread fields existed has no box to draw.
null,
// Neither does a bucket whose calls all lack a duration.
null,
])
})
it('returns empty series for empty bucket keys', () => {
@@ -75,6 +133,57 @@ describe('mcpAnalyticsToolQualityLogic', () => {
})
})
describe('maskIncompleteLatencyBox', () => {
const box = (day: string): ReturnType<typeof maskIncompleteLatencyBox>[number] => ({
day,
min: 10,
p25: 20,
median: 30,
mean: 35,
p75: 40,
max: 50,
whiskerMax: 45,
})
it.each([
{ name: 'drops the last bucket when it is still in progress', incompleteTail: true, last: true },
{ name: 'leaves every bucket alone when the tail is complete', incompleteTail: false, last: false },
])('$name', ({ incompleteTail, last }) => {
const boxes = [box('2026-06-05'), box('2026-06-06')]
expect(maskIncompleteLatencyBox(boxes, incompleteTail)).toEqual([
box('2026-06-05'),
last ? null : box('2026-06-06'),
])
})
})
describe('chart views', () => {
beforeEach(() => {
jest.clearAllMocks()
localStorage.clear()
initKeaTests()
jest.spyOn(mockApi, 'query').mockImplementation(async (query: any) =>
query.kind === NodeKind.MCPToolQualityRowsQuery ? emptyToolRowsResponse : { results: [] }
)
})
it.each<ChartViewChange>([
{ chart: 'calls', view: 'percentage' },
{ chart: 'successRate', view: 'zoom' },
{ chart: 'latency', view: 'boxPlot' },
])('switches the $chart chart to $view and reports it', async (change) => {
const logic = mcpAnalyticsToolQualityLogic()
logic.mount()
await expectLogic(logic, () => {
logic.actions.setChartView(change)
}).toMatchValues({
chartViews: { ...DEFAULT_CHART_VIEWS, [change.chart]: change.view },
})
expect(posthog.capture).toHaveBeenCalledWith('mcp analytics tool quality chart view changed', change)
})
})
describe('incompleteTail', () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -1,6 +1,9 @@
import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { actionToUrl, combineUrl, router, urlToAction } from 'kea-router'
import posthog from 'posthog-js'
import type { BoxPlotDatum } from '@posthog/quill-charts'
import api from 'lib/api'
import { dateFilterToText } from 'lib/utils/dateFilters'
@@ -112,18 +115,34 @@ export interface DailyToolStat {
p50: number
p95: number
p99: number
min?: number | null
p25?: number | null
mean?: number | null
p75?: number | null
max?: number | null
}
export interface DailyChartData {
labels: string[]
calls: number[]
errors: number[]
errorRate: number[]
successRate: number[]
p50: number[]
p95: number[]
p99: number[]
latencyBoxes: (BoxPlotDatum | null)[]
}
export type ChartViewChange =
| { chart: 'calls'; view: 'counts' | 'percentage' }
| { chart: 'successRate'; view: 'full' | 'zoom' }
| { chart: 'latency'; view: 'lines' | 'boxPlot' }
export type ChartViews = { [C in ChartViewChange as C['chart']]: C['view'] }
export const DEFAULT_CHART_VIEWS: ChartViews = { calls: 'counts', successRate: 'full', latency: 'lines' }
const DEFAULT_DATE_FILTER: DateFilter = { dateFrom: '-7d', dateTo: null }
const DEFAULT_SORT: SortState = { column: 'total_calls', direction: 'DESC' }
export const TOOL_QUALITY_PAGE_SIZE = 50
@@ -141,13 +160,36 @@ export function buildDailyChartData(dailyStats: DailyToolStat[], bucketKeys: str
labels: bucketKeys,
calls: rows.map((r) => r?.calls ?? 0),
errors: rows.map((r) => r?.errors ?? 0),
errorRate: rows.map((r) => (r && r.calls ? (r.errors / r.calls) * 100 : NaN)),
successRate: rows.map((r) => (r && r.calls ? ((r.calls - r.errors) / r.calls) * 100 : NaN)),
p50: rows.map((r) => (r ? r.p50 : NaN)),
p95: rows.map((r) => (r ? r.p95 : NaN)),
p99: rows.map((r) => (r ? r.p99 : NaN)),
latencyBoxes: rows.map((r, i) => (r ? latencyBox(r, bucketKeys[i]) : null)),
}
}
// Whiskers stop at p99 so one very slow call does not flatten every box.
function latencyBox(row: DailyToolStat, day: string): BoxPlotDatum | null {
const { min, p25, mean, p75, max } = row
if (min == null || p25 == null || mean == null || p75 == null || max == null) {
return null
}
return { day, min, p25, median: row.p50, mean, p75, max, whiskerMax: row.p99 }
}
// BoxPlotDatum has no partial style like the line series' `stroke.partial`, so a still-collecting
// bucket is dropped outright rather than drawn as a settled box.
export function maskIncompleteLatencyBox(
boxes: (BoxPlotDatum | null)[],
incompleteTail: boolean
): (BoxPlotDatum | null)[] {
if (!incompleteTail) {
return boxes
}
return boxes.map((box, i) => (i === boxes.length - 1 ? null : box))
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface mcpAnalyticsToolQualityLogicValues {
sharedQueryFilters: MCPSharedQueryFilters // mcpAnalyticsFiltersLogic
@@ -155,6 +197,7 @@ export interface mcpAnalyticsToolQualityLogicValues {
availableCategoriesLoading: boolean
categoryCounts: CategoryCount[]
categoryCountsLoading: boolean
chartViews: ChartViews
dailyChartData: DailyChartData
dailyStats: DailyToolStat[]
dailyStatsLoading: boolean
@@ -163,6 +206,7 @@ export interface mcpAnalyticsToolQualityLogicValues {
incompleteTail: boolean
interval: IntervalType
intervalOptions: IntervalOption[]
latencyBoxes: (BoxPlotDatum | null)[]
loadedToolQualityPageIndex: number
pinnedInterval: IntervalType | null
scopeShare: ScopeShare
@@ -257,6 +301,7 @@ export interface mcpAnalyticsToolQualityLogicActions {
reloadAll: () => {
value: true
}
setChartView: (change: ChartViewChange) => ChartViewChange
setDateFilter: (
dateFrom: string | null,
dateTo: string | null
@@ -307,6 +352,7 @@ export interface mcpAnalyticsToolQualityLogicMeta {
timezone: string
) => DailyChartData
incompleteTail: (dailyChartData: DailyChartData, interval: IntervalType, timezone: string) => boolean
latencyBoxes: (dailyChartData: DailyChartData, incompleteTail: boolean) => (BoxPlotDatum | null)[]
}
}
@@ -334,9 +380,17 @@ export const mcpAnalyticsToolQualityLogic = kea<mcpAnalyticsToolQualityLogicType
setSelectedTool: (tool: string | null) => ({ tool }),
setSearchTerm: (searchTerm: string) => ({ searchTerm }),
reloadAll: true,
setChartView: (change: ChartViewChange) => change,
}),
reducers({
chartViews: [
DEFAULT_CHART_VIEWS,
{ persist: true },
{
setChartView: (state, { chart, view }): ChartViews => ({ ...state, [chart]: view }),
},
],
toolQualitySort: [
DEFAULT_SORT,
{
@@ -458,6 +512,11 @@ export const mcpAnalyticsToolQualityLogic = kea<mcpAnalyticsToolQualityLogicType
p50: r.p50,
p95: r.p95,
p99: r.p99,
min: r.min,
p25: r.p25,
mean: r.mean,
p75: r.p75,
max: r.max,
}))
},
},
@@ -535,6 +594,11 @@ export const mcpAnalyticsToolQualityLogic = kea<mcpAnalyticsToolQualityLogicType
(dailyChartData: DailyChartData, interval: IntervalType, timezone: string): boolean =>
lastBucketIsInProgress(dailyChartData.labels, timezone, interval),
],
latencyBoxes: [
(s) => [s.dailyChartData, s.incompleteTail],
(dailyChartData: DailyChartData, incompleteTail: boolean): (BoxPlotDatum | null)[] =>
maskIncompleteLatencyBox(dailyChartData.latencyBoxes, incompleteTail),
],
}),
listeners(({ actions, values }) => {
@@ -554,6 +618,9 @@ export const mcpAnalyticsToolQualityLogic = kea<mcpAnalyticsToolQualityLogicType
}
return {
setChartView: (change) => {
posthog.capture('mcp analytics tool quality chart view changed', change)
},
setDateFilter: () => {
loadFirstToolPage()
loadUnscopedDailyStats()
@@ -4,7 +4,12 @@ import { type ChartTheme } from '@posthog/quill-charts'
import { buildTheme } from 'lib/charts/utils/theme'
import { type DailyChartData } from '../mcpAnalyticsToolQualityLogic'
import {
DEFAULT_CHART_VIEWS,
type DailyToolStat,
buildDailyChartData,
maskIncompleteLatencyBox,
} from '../mcpAnalyticsToolQualityLogic'
import { ToolQualityCharts } from './ToolQualityCharts'
const LABELS = [
@@ -17,41 +22,54 @@ const LABELS = [
'2026-06-07 00:00:00',
]
const DAILY: DailyChartData = {
labels: LABELS,
calls: [820, 910, 880, 940, 1010, 990, 1060],
errors: [24, 31, 27, 33, 30, 28, 35],
successRate: [97.1, 96.6, 96.9, 96.5, 97.0, 97.2, 96.7],
p50: [180, 176, 190, 184, 178, 182, 188],
p95: [900, 940, 980, 920, 960, 910, 970],
p99: [2100, 2200, 2350, 2180, 2260, 2140, 2300],
}
const SETTLED_ROWS: DailyToolStat[] = LABELS.map((day, i) => {
const calls = [820, 910, 880, 940, 1010, 990, 1060][i]
const p50 = [180, 176, 190, 184, 178, 182, 188][i]
return {
day,
calls,
errors: [24, 31, 27, 33, 30, 28, 35][i],
min: 12,
p25: p50 / 2,
p50,
mean: p50 * 1.6,
p75: p50 * 2.5,
p95: [900, 940, 980, 920, 960, 910, 970][i],
p99: [2100, 2200, 2350, 2180, 2260, 2140, 2300][i],
max: 30000,
}
})
const DAILY = buildDailyChartData(SETTLED_ROWS, LABELS)
// The shape the in-progress story exists for: the last bucket is a few hours into the day, so its
// counts sit far below its neighbours and its percentiles are computed off a thin sample.
const DAILY_PARTIAL_TAIL: DailyChartData = {
...DAILY,
calls: [820, 910, 880, 940, 1010, 990, 310],
errors: [24, 31, 27, 33, 30, 28, 6],
successRate: [97.1, 96.6, 96.9, 96.5, 97.0, 97.2, 98.1],
p50: [180, 176, 190, 184, 178, 182, 140],
p95: [900, 940, 980, 920, 960, 910, 620],
p99: [2100, 2200, 2350, 2180, 2260, 2140, 1180],
}
const DAILY_PARTIAL_TAIL = buildDailyChartData(
[
...SETTLED_ROWS.slice(0, -1),
{ ...SETTLED_ROWS[6], calls: 310, errors: 6, p50: 140, p95: 620, p99: 1180, max: 4000 },
],
LABELS
)
const meta: Meta = {
title: 'Scenes-App/MCP Analytics/Tool Quality Charts',
// The tab lays the three charts side by side from `lg` up, so at the default 1280 snapshot
// viewport each one is barely 400px wide and the dashed tail is hard to read. Snapshot wide.
// The tab lays the three charts side by side once its container is wide enough, and each one
// must be wide for the dashed tail to read. Snapshot wide.
parameters: { layout: 'padded', testOptions: { viewport: { width: 1920, height: 720 } } },
}
export default meta
type Story = StoryObj
function withTheme(render: (theme: ChartTheme) => JSX.Element): () => JSX.Element {
// The box plot has no intrinsic width, so an unsized root would shrink the snapshot to nothing.
function withTheme(render: (theme: ChartTheme) => JSX.Element, width = 'w-[1600px]'): () => JSX.Element {
return function Render() {
return <div data-quill>{render(buildTheme())}</div>
return (
<div data-quill className={width}>
{render(buildTheme())}
</div>
)
}
}
@@ -64,6 +82,9 @@ export const SettledWindow: Story = {
timezone="UTC"
interval="day"
incompleteTail={false}
views={DEFAULT_CHART_VIEWS}
latencyBoxes={DAILY.latencyBoxes}
onViewChange={() => {}}
/>
)),
}
@@ -77,6 +98,44 @@ export const InProgressBucket: Story = {
timezone="UTC"
interval="day"
incompleteTail
views={DEFAULT_CHART_VIEWS}
latencyBoxes={maskIncompleteLatencyBox(DAILY_PARTIAL_TAIL.latencyBoxes, true)}
onViewChange={() => {}}
/>
)),
}
export const AlternateViews: Story = {
render: withTheme((theme) => (
<ToolQualityCharts
data={DAILY}
loading={false}
theme={theme}
timezone="UTC"
interval="day"
incompleteTail={false}
views={{ calls: 'percentage', successRate: 'zoom', latency: 'boxPlot' }}
latencyBoxes={DAILY.latencyBoxes}
onViewChange={() => {}}
/>
)),
}
export const ToggleWrapsBelowTitle: Story = {
render: withTheme(
(theme) => (
<ToolQualityCharts
data={DAILY}
loading={false}
theme={theme}
timezone="UTC"
interval="day"
incompleteTail={false}
views={DEFAULT_CHART_VIEWS}
latencyBoxes={DAILY.latencyBoxes}
onViewChange={() => {}}
/>
),
'w-[280px]'
),
}
@@ -2,21 +2,36 @@ import { useMemo } from 'react'
import { LemonSkeleton } from '@posthog/lemon-ui'
import {
BoxPlot,
type BoxPlotConfig,
type BoxPlotDatum,
type BoxPlotSeries,
ChartLegend,
type ChartTheme,
type Series,
type TimeInterval,
TimeSeriesLineChart,
type TimeSeriesLineChartConfig,
createXAxisTickCallback,
legendItemsFromSeries,
} from '@posthog/quill-charts'
import { Button, Toggle, ToggleGroup, ToggleGroupItem } from '@posthog/quill-primitives'
import { useChartConfig } from 'lib/charts/hooks'
import { formatPercentage } from 'lib/utils/numbers'
import { Card, CardState } from '../dashboard/Card'
import { formatMsAsSeconds } from '../dashboard/formatters'
import { type DailyChartData } from '../mcpAnalyticsToolQualityLogic'
import { type ChartViewChange, type ChartViews, type DailyChartData } from '../mcpAnalyticsToolQualityLogic'
function formatCompactPercentage(value: number): string {
return formatPercentage(value, { compact: true })
}
// A zoomed axis can span less than one percent, so whole-number ticks would repeat.
function formatPrecisePercentage(value: number): string {
return formatPercentage(value, { precise: true, compact: true })
}
function buildConfig(
timezone: string,
@@ -35,19 +50,64 @@ function buildConfig(
}
}
type ToggleGroupChart = Exclude<ChartViewChange['chart'], 'successRate'>
const CHART_VIEW_ARIA_LABELS: Record<ToggleGroupChart, string> = {
calls: 'Calls and errors chart view',
latency: 'Latency chart view',
}
function ChartViewToggle<C extends Extract<ChartViewChange, { chart: ToggleGroupChart }>>({
chart,
view,
options,
onChange,
}: {
chart: C['chart']
view: C['view']
options: { value: C['view']; label: string }[]
onChange: (change: C) => void
}): JSX.Element {
return (
<ToggleGroup
value={[view]}
onValueChange={(next) => {
const picked = options.find(({ value }) => value === next[0])
if (picked) {
onChange({ chart, view: picked.value } as C)
}
}}
aria-label={CHART_VIEW_ARIA_LABELS[chart]}
>
{options.map(({ value, label }) => (
<ToggleGroupItem
key={value}
value={value}
size="sm"
data-attr={`mcp-tool-quality-${chart}-chart-view-${value}`}
>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
)
}
function ChartCard({
title,
action,
loading,
isEmpty,
children,
}: {
title: string
action: React.ReactNode
loading: boolean
isEmpty: boolean
children: React.ReactNode
}): JSX.Element {
return (
<Card title={title}>
<Card title={title} action={action}>
<CardState
loading={loading}
isEmpty={isEmpty}
@@ -74,6 +134,9 @@ export function ToolQualityCharts({
timezone,
interval,
incompleteTail,
views,
latencyBoxes,
onViewChange,
}: {
data: DailyChartData
loading: boolean
@@ -84,6 +147,9 @@ export function ToolQualityCharts({
// partial period doesn't read as a fall in calls or an improvement in latency. Required rather
// than optional: an omitted prop silently renders the partial bucket as settled data.
incompleteTail: boolean
views: ChartViews
latencyBoxes: (BoxPlotDatum | null)[]
onViewChange: (change: ChartViewChange) => void
}): JSX.Element {
const isEmpty = data.labels.length === 0
@@ -94,12 +160,30 @@ export function ToolQualityCharts({
[incompleteTail, data.labels.length]
)
const showErrorRate = views.calls === 'percentage'
const callsSeries = useMemo<Series[]>(
() => [
{ key: 'calls', label: 'Calls', color: theme.colors[0], data: data.calls, stroke: partialStroke },
{ key: 'errors', label: 'Errors', color: theme.colors[4], data: data.errors, stroke: partialStroke },
],
[data, theme, partialStroke]
() =>
showErrorRate
? [
{
key: 'errorRate',
label: 'Error rate',
color: theme.colors[4],
data: data.errorRate,
stroke: partialStroke,
},
]
: [
{ key: 'calls', label: 'Calls', color: theme.colors[0], data: data.calls, stroke: partialStroke },
{
key: 'errors',
label: 'Errors',
color: theme.colors[4],
data: data.errors,
stroke: partialStroke,
},
],
[data, theme, partialStroke, showErrorRate]
)
const successSeries = useMemo<Series[]>(
() => [
@@ -121,51 +205,136 @@ export function ToolQualityCharts({
],
[data, theme, partialStroke]
)
const latencyBoxSeries = useMemo<BoxPlotSeries[]>(
() => [{ key: 'latency', label: 'Latency', color: theme.colors[0], data: latencyBoxes }],
[latencyBoxes, theme]
)
const countsConfig = useChartConfig(() => buildConfig(timezone, interval), [timezone, interval])
const percentConfig = useChartConfig(
const errorRateConfig = useChartConfig(
() => buildConfig(timezone, interval, { tickFormatter: formatCompactPercentage }),
[timezone, interval]
)
const successRateConfig = useChartConfig(
() =>
buildConfig(timezone, interval, {
tickFormatter: (value: number) => formatPercentage(value, { compact: true }),
tickFormatter: formatPrecisePercentage,
startAtZero: views.successRate !== 'zoom',
}),
[timezone, interval]
[timezone, interval, views.successRate]
)
const latencyConfig = useChartConfig(
() => buildConfig(timezone, interval, { tickFormatter: formatMsAsSeconds }),
[timezone, interval]
)
const latencyBoxConfig = useChartConfig<BoxPlotConfig>(
() => ({
showGrid: true,
showAxisLines: true,
yTickFormatter: formatMsAsSeconds,
tooltip: { valueFormatter: formatMsAsSeconds },
xTickFormatter: createXAxisTickCallback({ allDays: data.labels, timezone, interval }),
}),
[data.labels, timezone, interval]
)
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ChartCard title="Calls and errors" loading={loading} isEmpty={isEmpty}>
<TimeSeriesLineChart
series={callsSeries}
labels={data.labels}
config={countsConfig}
theme={theme}
dataAttr="mcp-tool-quality-calls-chart"
/>
</ChartCard>
<ChartCard title="Success rate" loading={loading} isEmpty={isEmpty}>
<TimeSeriesLineChart
series={successSeries}
labels={data.labels}
config={percentConfig}
theme={theme}
dataAttr="mcp-tool-quality-success-rate-chart"
/>
</ChartCard>
<ChartCard title="Latency (p50 / p95 / p99)" loading={loading} isEmpty={isEmpty}>
<ChartLegend items={legendItemsFromSeries(latencySeries, theme)} position="top" align="end">
<div className="@container">
<div className="grid grid-cols-1 gap-4 @5xl:grid-cols-3">
<ChartCard
title={showErrorRate ? 'Error rate' : 'Calls and errors'}
action={
<ChartViewToggle
chart="calls"
view={views.calls}
options={[
{ value: 'counts', label: 'Counts' },
{ value: 'percentage', label: 'Percent' },
]}
onChange={onViewChange}
/>
}
loading={loading}
isEmpty={isEmpty}
>
<TimeSeriesLineChart
series={latencySeries}
series={callsSeries}
labels={data.labels}
config={latencyConfig}
config={showErrorRate ? errorRateConfig : countsConfig}
theme={theme}
dataAttr="mcp-tool-quality-latency-chart"
dataAttr="mcp-tool-quality-calls-chart"
/>
</ChartLegend>
</ChartCard>
</ChartCard>
<ChartCard
title="Success rate"
action={
<Toggle
size="sm"
pressed={views.successRate === 'zoom'}
onPressedChange={(pressed) =>
onViewChange({ chart: 'successRate', view: pressed ? 'zoom' : 'full' })
}
data-attr="mcp-tool-quality-successRate-chart-view-zoom"
render={(props) => (
<Button
variant="outline"
size="sm"
tooltip="Start the axis just below the lowest value instead of at zero, so small changes are easier to see."
{...props}
/>
)}
>
Zoom in
</Toggle>
}
loading={loading}
isEmpty={isEmpty}
>
<TimeSeriesLineChart
series={successSeries}
labels={data.labels}
config={successRateConfig}
theme={theme}
dataAttr="mcp-tool-quality-success-rate-chart"
/>
</ChartCard>
<ChartCard
title={views.latency === 'boxPlot' ? 'Latency' : 'Latency (p50 / p95 / p99)'}
action={
<ChartViewToggle
chart="latency"
view={views.latency}
options={[
{ value: 'lines', label: 'Trends' },
{ value: 'boxPlot', label: 'Box plot' },
]}
onChange={onViewChange}
/>
}
loading={loading}
isEmpty={isEmpty}
>
{views.latency === 'boxPlot' ? (
<BoxPlot
series={latencyBoxSeries}
labels={data.labels}
config={latencyBoxConfig}
theme={theme}
dataAttr="mcp-tool-quality-latency-box-plot"
/>
) : (
<ChartLegend items={legendItemsFromSeries(latencySeries, theme)} position="top" align="end">
<TimeSeriesLineChart
series={latencySeries}
labels={data.labels}
config={latencyConfig}
theme={theme}
dataAttr="mcp-tool-quality-latency-chart"
/>
</ChartLegend>
)}
</ChartCard>
</div>
</div>
)
}
+6
View File
@@ -61160,7 +61160,13 @@ export namespace Schemas {
calls: number;
day: string;
errors: number;
max?: number | null;
mean?: number | null;
/** Latency spread for the box plot view. Optional so responses cached before these fields existed still parse. */
min?: number | null;
p25?: number | null;
p50: number;
p75?: number | null;
p95: number;
p99: number;
}