# 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).

A shadcn/ui block from the [@pipecat](https://ui.pipecat.ai) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/pipecat/metrics).

- Page, with a live preview: https://shadcn.exchange/r/pipecat/metrics
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/pipecat/metrics-light-318d52bf15d5aa21.png
- Registry item: https://ui.pipecat.ai/r/metrics.json
- Categories: voice, blocks
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.pipecat.ai/r/metrics.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `recharts`
- Registry items: `chart`, `tabs`, `@pipecat/metric`, `@pipecat/use-pipecat-metrics`

## Notes from the registry

Metrics reads the shared use-pipecat-metrics store inside a PipecatClientProvider; MetricsView takes fixtures. MetricsPerformance and MetricsUsage also export standalone. Chrome-free — wrap in your own card or panel.

## Files

### `src/blocks/metrics/metrics.tsx`

```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`

```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`

```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`

```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>
  );
}
```
