pipecat
blockmetricssource: ok · hit
Metrics
Metrics dashboard block with a Performance/Usage tab navigator: per-processor latency tiles and charts, plus running token/character/audio usage totals, on the stock shadcn chart (recharts).
npx shadcn add "https://ui.pipecat.ai/r/metrics.json"free
Where it goes
statskanbandashboard-shell
Interaction
animated-counter
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightrecharts@^3.8.0
Registry dependencies
charttabs@pipecat/metric@pipecat/use-pipecat-metrics
Files4 · source cached 10/9/2026, 12:21:50 AM
src/blocks/metrics/metrics.tsx
"use client";
import * as React from "react";
import {
MetricsPerformance,
MetricsPerformanceView,
type MetricsPerformanceProps,
type MetricsPerformanceViewProps,
} from "@/components/pipecat/metrics/performance";
import {
MetricsUsage,
MetricsUsageView,
type MetricsUsageProps,
type MetricsUsageViewProps,
} from "@/components/pipecat/metrics/usage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { MetricSeries, TokenTotals } from "@/hooks/use-pipecat-metrics";
import { cn } from "@/lib/utils";
export type MetricsTab = "performance" | "usage";
export interface MetricsViewProps {
/** Metric series for both sections (see use-pipecat-metrics). */
series: MetricSeries[];
/** Running token totals for the usage section. */
tokens?: TokenTotals;
/** Which tab is selected initially (default "performance"). */
defaultTab?: MetricsTab;
/** Overrides for the performance section. */
performanceProps?: Omit<MetricsPerformanceViewProps, "series">;
/** Overrides for the usage section. */
usageProps?: Omit<MetricsUsageViewProps, "series" | "tokens">;
className?: string;
}
function MetricsTabs({
defaultTab = "performance",
className,
performance,
usage,
}: {
defaultTab?: MetricsTab;
className?: string;
performance: React.ReactNode;
usage: React.ReactNode;
}) {
return (
<Tabs
data-slot="metrics"
defaultValue={defaultTab}
className={cn("flex flex-col gap-4", className)}
>
<TabsList>
<TabsTrigger value="performance">Performance</TabsTrigger>
<TabsTrigger value="usage">Usage</TabsTrigger>
</TabsList>
<TabsContent value="performance">{performance}</TabsContent>
<TabsContent value="usage">{usage}</TabsContent>
</Tabs>
);
}
/** Props-driven performance and usage tabs. */
export function MetricsView({
series,
tokens,
defaultTab,
performanceProps,
usageProps,
className,
}: MetricsViewProps) {
return (
<MetricsTabs
defaultTab={defaultTab}
className={className}
performance={
<MetricsPerformanceView series={series} {...performanceProps} />
}
usage={
<MetricsUsageView series={series} tokens={tokens} {...usageProps} />
}
/>
);
}
export interface MetricsProps {
/** Which tab is selected initially (default "performance"). */
defaultTab?: MetricsTab;
/** Overrides for the performance section. */
performanceProps?: MetricsPerformanceProps;
/** Overrides for the usage section. */
usageProps?: MetricsUsageProps;
className?: string;
}
/** Requires PipecatClientProvider. Keeps collection active while individual tabs are unmounted. */
export function Metrics({
defaultTab,
performanceProps,
usageProps,
className,
}: MetricsProps) {
return (
<MetricsTabs
defaultTab={defaultTab}
className={className}
performance={<MetricsPerformance {...performanceProps} />}
usage={<MetricsUsage {...usageProps} />}
/>
);
}
src/blocks/metrics/performance.tsx
"use client";
import * as React from "react";
import { Metric } from "@/components/pipecat/metric";
import {
METRIC_CATEGORY_TITLES,
METRIC_CATEGORY_UNITS,
MetricSeriesChart,
MetricsEmpty,
} from "@/components/pipecat/metrics/chart";
import {
usePipecatMetrics,
type MetricSeries,
} from "@/hooks/use-pipecat-metrics";
import { cn } from "@/lib/utils";
export type MetricsPerformanceCategory = "ttfb" | "ttfa" | "processing";
export interface MetricsPerformanceViewProps {
/** Metric series; non-performance categories are ignored. */
series: MetricSeries[];
/** Which categories get tiles and charts (default ["ttfb"]). */
categories?: MetricsPerformanceCategory[];
/** Hides the tile grid. Default false. */
noTiles?: boolean;
/** Hides the charts. Default false. */
noCharts?: boolean;
/** Processor names excluded from tiles and charts. */
ignoreProcessors?: string[];
/** Rendered when there is nothing to show yet. */
empty?: React.ReactNode;
className?: string;
}
/** Latency tiles and charts in milliseconds. ttfa requires Pipecat server ≥1.7. */
export function MetricsPerformanceView({
series,
categories = ["ttfb"],
noTiles = false,
noCharts = false,
ignoreProcessors,
empty,
className,
}: MetricsPerformanceViewProps) {
const visible = React.useMemo(() => {
const bySelection = series.filter((s) =>
(categories as string[]).includes(s.category),
);
return ignoreProcessors?.length
? bySelection.filter((s) => !ignoreProcessors.includes(s.processor))
: bySelection;
}, [series, categories, ignoreProcessors]);
if (visible.length === 0) {
return (
<MetricsEmpty slot="metrics-performance" className={className}>
{empty}
</MetricsEmpty>
);
}
return (
<div
data-slot="metrics-performance"
data-state="live"
className={cn("@container/metrics flex flex-col gap-6", className)}
>
{!noTiles && (
<div
data-slot="metrics-tiles"
className="grid grid-cols-2 gap-4 @md/metrics:grid-cols-3 @xl/metrics:grid-cols-4"
>
{visible.map((s) => (
<Metric
key={`${s.category}:${s.processor}`}
label={`${METRIC_CATEGORY_TITLES[s.category]} · ${s.processor}`}
value={s.latest * 1000}
unit="ms"
/>
))}
</div>
)}
{!noCharts &&
categories.map((category) => {
const categorySeries = visible.filter((s) => s.category === category);
if (categorySeries.length === 0) return null;
return (
<div
key={category}
data-slot="metrics-chart"
className="flex flex-col gap-2"
>
<h3 className="text-muted-foreground text-xs font-medium">
{METRIC_CATEGORY_TITLES[category]}
{METRIC_CATEGORY_UNITS[category]}
</h3>
<MetricSeriesChart series={categorySeries} scale={1000} />
</div>
);
})}
</div>
);
}
export type MetricsPerformanceProps = Omit<
MetricsPerformanceViewProps,
"series"
>;
/**
* Performance metrics wired to the shared RTVI metrics store — populates
* live and resets with each new session. Must be rendered inside a
* PipecatClientProvider.
*/
export function MetricsPerformance(props: MetricsPerformanceProps) {
const { series } = usePipecatMetrics();
return <MetricsPerformanceView series={series} {...props} />;
}
src/blocks/metrics/usage.tsx
"use client";
import * as React from "react";
import { Metric } from "@/components/pipecat/metric";
import {
METRIC_CATEGORY_TITLES,
METRIC_CATEGORY_UNITS,
MetricSeriesChart,
MetricsEmpty,
} from "@/components/pipecat/metrics/chart";
import {
usePipecatMetrics,
type MetricSeries,
type TokenTotals,
} from "@/hooks/use-pipecat-metrics";
import { cn } from "@/lib/utils";
export type MetricsUsageCategory = "characters" | "stt_usage";
const USAGE_UNITS: Record<MetricsUsageCategory, string | undefined> = {
characters: undefined,
stt_usage: "s",
};
export interface MetricsUsageViewProps {
/** Metric series; non-usage categories are ignored. */
series: MetricSeries[];
/** Running token totals; omit to hide the token tiles. */
tokens?: TokenTotals;
/** Which categories get charts (default both). */
categories?: MetricsUsageCategory[];
/** Hides the tile grid. Default false. */
noTiles?: boolean;
/** Hides the charts. Default false. */
noCharts?: boolean;
/** Processor names excluded from tiles and charts. */
ignoreProcessors?: string[];
/** Rendered when there is nothing to show yet. */
empty?: React.ReactNode;
className?: string;
}
/** Session usage totals. STT audio requires Pipecat server ≥1.7 with usage metrics enabled. */
export function MetricsUsageView({
series,
tokens,
categories = ["characters", "stt_usage"],
noTiles = false,
noCharts = false,
ignoreProcessors,
empty,
className,
}: MetricsUsageViewProps) {
const visible = React.useMemo(() => {
const bySelection = series.filter(
(s) => s.category === "characters" || s.category === "stt_usage",
);
return ignoreProcessors?.length
? bySelection.filter((s) => !ignoreProcessors.includes(s.processor))
: bySelection;
}, [series, ignoreProcessors]);
if (visible.length === 0 && !tokens) {
return (
<MetricsEmpty slot="metrics-usage" className={className}>
{empty}
</MetricsEmpty>
);
}
return (
<div
data-slot="metrics-usage"
data-state="live"
className={cn("@container/metrics flex flex-col gap-6", className)}
>
{!noTiles && (
<div
data-slot="metrics-tiles"
className="grid grid-cols-2 gap-4 @md/metrics:grid-cols-3 @xl/metrics:grid-cols-4"
>
{tokens ? (
<>
<Metric label="Prompt tokens" value={tokens.prompt} />
<Metric label="Completion tokens" value={tokens.completion} />
<Metric label="Total tokens" value={tokens.total} />
{tokens.cacheRead > 0 && (
<Metric label="Cache read tokens" value={tokens.cacheRead} />
)}
{tokens.reasoning > 0 && (
<Metric label="Reasoning tokens" value={tokens.reasoning} />
)}
</>
) : null}
{visible.map((s) => (
<Metric
key={`${s.category}:${s.processor}`}
label={`${METRIC_CATEGORY_TITLES[s.category]} · ${s.processor}`}
value={s.latest}
unit={USAGE_UNITS[s.category as MetricsUsageCategory]}
/>
))}
</div>
)}
{!noCharts &&
categories.map((category) => {
const categorySeries = visible.filter((s) => s.category === category);
if (categorySeries.length === 0) return null;
return (
<div
key={category}
data-slot="metrics-chart"
className="flex flex-col gap-2"
>
<h3 className="text-muted-foreground text-xs font-medium">
{METRIC_CATEGORY_TITLES[category]}
{METRIC_CATEGORY_UNITS[category]}
</h3>
<MetricSeriesChart series={categorySeries} />
</div>
);
})}
</div>
);
}
export type MetricsUsageProps = Omit<
MetricsUsageViewProps,
"series" | "tokens"
>;
/**
* Usage metrics wired to the shared RTVI metrics store — running totals
* populate live and reset with each new session. Must be rendered inside a
* PipecatClientProvider.
*/
export function MetricsUsage(props: MetricsUsageProps) {
const { series, tokens, hasTokens } = usePipecatMetrics();
return (
<MetricsUsageView
series={series}
tokens={hasTokens ? tokens : undefined}
{...props}
/>
);
}
src/blocks/metrics/chart.tsx
"use client";
import * as React from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import type { MetricCategory, MetricSeries } from "@/hooks/use-pipecat-metrics";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { cn } from "@/lib/utils";
/** Display titles shared by the metrics blocks. */
export const METRIC_CATEGORY_TITLES: Record<MetricCategory, string> = {
ttfb: "TTFB",
ttfa: "TTFA",
processing: "Processing time",
characters: "TTS characters",
stt_usage: "STT audio",
};
/** Chart heading unit suffix per category. */
export const METRIC_CATEGORY_UNITS: Record<MetricCategory, string> = {
ttfb: " (ms)",
ttfa: " (ms)",
processing: " (ms)",
characters: "",
stt_usage: " (s)",
};
/** Processor names become ChartConfig keys, which become CSS variable names. */
function sanitizeKey(processor: string): string {
return processor.replace(/[^a-zA-Z0-9_-]/g, "-");
}
function formatTime(time: number): string {
return new Date(time).toLocaleTimeString(undefined, { hour12: false });
}
/**
* Merges per-processor point series into recharts rows keyed by the metrics
* batch timestamp (one RTVI metrics event carries all processors at once, so
* timestamps align across series).
*/
function chartRows(
seriesList: MetricSeries[],
scale: number,
): Array<Record<string, number>> {
const rows = new Map<number, Record<string, number>>();
for (const series of seriesList) {
const key = sanitizeKey(series.processor);
for (const point of series.points) {
const row = rows.get(point.time) ?? { time: point.time };
row[key] = point.value * scale;
rows.set(point.time, row);
}
}
return [...rows.values()].sort((a, b) => a.time! - b.time!);
}
export interface MetricSeriesChartProps {
/** Series drawn as one line per processor. */
series: MetricSeries[];
/** Multiplier applied to raw values before plotting (e.g. 1000 for s → ms). */
scale?: number;
className?: string;
}
/** Linear interpolation preserves sparse per-turn samples; colors use the chart palette. */
export function MetricSeriesChart({
series,
scale = 1,
className,
}: MetricSeriesChartProps) {
const rows = chartRows(series, scale);
const config: ChartConfig = {};
series.forEach((s, index) => {
config[sanitizeKey(s.processor)] = {
label: s.processor,
color: `var(--chart-${(index % 5) + 1})`,
};
});
return (
<ChartContainer config={config} className={cn("h-48 w-full", className)}>
<LineChart data={rows} accessibilityLayer>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<YAxis width={40} tickLine={false} axisLine={false} tickMargin={4} />
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return typeof time === "number" ? formatTime(time) : "";
}}
/>
}
/>
{series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
{series.map((s) => {
const key = sanitizeKey(s.processor);
return (
<Line
key={key}
dataKey={key}
type="linear"
stroke={`var(--color-${key})`}
strokeWidth={2}
dot={{ r: 2 }}
isAnimationActive={false}
connectNulls
/>
);
})}
</LineChart>
</ChartContainer>
);
}
export interface MetricsEmptyProps {
children?: React.ReactNode;
/** data-slot for the empty container, e.g. "metrics-performance". */
slot: string;
className?: string;
}
/** Shared empty state for the metrics blocks. */
export function MetricsEmpty({ children, slot, className }: MetricsEmptyProps) {
return (
<div
data-slot={slot}
data-state="empty"
className={cn(
"text-muted-foreground flex h-full min-h-24 flex-col items-center justify-center gap-1 text-sm",
className,
)}
>
{children ?? (
<>
<span>No metrics yet</span>
<span className="text-xs">
Metrics stream in once an agent session is live.
</span>
</>
)}
</div>
);
}