mirror of
https://github.com/PostHog/posthog.git
synced 2026-10-26 09:51:43 +08:00
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:
20 files changed
+682
-119
No files matched your search
+14
-6
@@ -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:
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+11
@@ -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>
|
||||
)
|
||||
}
|
||||
Generated
+6
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user