# Call Statistics

The `CallStats` panel that v1 shipped is no longer part of the SDK. The data behind it is still there - `useCallStatsReport()` emits a full report every two seconds - so a stats panel is now something you assemble yourself, which is what this recipe does.

Doing it by hand is also what lets the panel match your product: you decide which numbers deserve screen space, what counts as a bad value, and whether stats live in a drawer, a debug overlay, or a support-only screen.

![The stats panel this recipe builds](https://getstream.io/docs-assets/images/4a9905beb9bd.png)

## What to display

`useCallStatsReport()` returns aggregated publisher and subscriber stats rather than raw WebRTC dumps, so most of the panel is a direct read of the report. See the [Call Stats Report](https://getstream.io/video/docs/react/v2/advanced/stats/) guide for the full shape.

| Stat                        | Where it comes from                                     | What it tells the user                                      |
| --------------------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
| Region                      | `datacenter`                                            | The edge server the participant is connected to             |
| Latency                     | `publisherStats.averageRoundTripTimeInMs`               | Time to deliver data between the app and the server         |
| Receive jitter              | `subscriberStats.averageJitterInMs`                     | Variation in the delay of incoming packets                  |
| Publish jitter              | `publisherStats.averageJitterInMs`                      | Variation in the delay of outgoing packets                  |
| Publish resolution          | `publisherStats.highestFrame{Width,Height,sPerSecond}`  | The video resolution being sent                             |
| Publish quality drop reason | `publisherStats.qualityLimitationReasons`               | Why outgoing quality dropped - usually `cpu` or `bandwidth` |
| Receiving resolution        | `subscriberStats.highestFrame{Width,Height,sPerSecond}` | The video resolution being received                         |
| Publish bitrate             | derived from `publisherStats.totalBytesSent`            | Rate at which data leaves the app                           |
| Receiving bitrate           | derived from `subscriberStats.totalBytesReceived`       | Rate at which data arrives from the server                  |
| Codec                       | `publisherStats.codec`, `subscriberStats.codec`         | Useful when debugging, noise for everyone else              |

Latency, jitter and the quality drop reasons are the three that actually explain a bad call. The rest is supporting detail - a panel that shows only those three is a legitimate panel.

## Thresholds

Raw milliseconds mean nothing to most users, so color-code them. These are the bounds the v1 component used, and they are a sound starting point:

```ts
export const STATS_THRESHOLDS = {
  latency: { good: 75, poor: 400 },
  audioJitter: { good: 10, poor: 30 },
  videoJitter: { good: 20, poor: 50 },
} as const;

export type Grade = "good" | "ok" | "poor";

export const grade = (
  value: number | undefined,
  { good, poor }: { good: number; poor: number },
): Grade | undefined => {
  if (value === undefined) return undefined;
  if (value <= good) return "good";
  if (value >= poor) return "poor";
  return "ok";
};
```

## Deriving bitrate

Bitrate is not in the report - `totalBytesSent` and `totalBytesReceived` are cumulative counters. To turn them into a rate, keep the previous report and divide the byte delta by the time delta:

```ts
import type { CallStatsReport } from "@stream-io/video-react-sdk";

export const toBitrateKbps = (
  current: number | undefined,
  previous: number | undefined,
  elapsedMs: number,
): number | undefined => {
  if (current === undefined || previous === undefined || elapsedMs <= 0) {
    return undefined;
  }
  const deltaBytes = current - previous;
  // counters reset when the peer connection is re-established
  if (deltaBytes < 0) return undefined;
  return (deltaBytes * 8) / elapsedMs;
};
```

Bytes times 8 over milliseconds gives kilobits per second directly, so no extra scaling is needed.

## Keeping a history

A single report is a snapshot. Buffering the last N reports gives you a latency sparkline and smooths out the jumpiness of instantaneous values:

```tsx
import { useEffect, useRef, useState } from "react";
import {
  useCallStateHooks,
  type CallStatsReport,
} from "@stream-io/video-react-sdk";

const HISTORY_SIZE = 20; // 20 reports x 2s = the last ~40 seconds

export const useStatsHistory = () => {
  const { useCallStatsReport } = useCallStateHooks();
  const report = useCallStatsReport();
  const [history, setHistory] = useState<CallStatsReport[]>([]);

  useEffect(() => {
    if (!report) return;
    setHistory((previous) => [...previous, report].slice(-HISTORY_SIZE));
  }, [report]);

  return { report, history };
};
```

Reports are emitted on a timer, so this effect runs once every two seconds - cheap enough to keep in component state.

## The panel

Putting it together. `previous` is the report before the current one, which is what the bitrate calculation needs:

```tsx
export const CallStatsPanel = () => {
  const { report, history } = useStatsHistory();

  if (!report) return <p>Gathering call statistics...</p>;

  const previous = history[history.length - 2];
  const elapsedMs = previous ? report.timestamp - previous.timestamp : 0;

  const latency = report.publisherStats?.averageRoundTripTimeInMs;
  const publishJitter = report.publisherStats?.averageJitterInMs;
  const receiveJitter = report.subscriberStats?.averageJitterInMs;

  const publishBitrate = toBitrateKbps(
    report.publisherStats?.totalBytesSent,
    previous?.publisherStats?.totalBytesSent,
    elapsedMs,
  );
  const receiveBitrate = toBitrateKbps(
    report.subscriberStats?.totalBytesReceived,
    previous?.subscriberStats?.totalBytesReceived,
    elapsedMs,
  );

  return (
    <dl className="my-call-stats">
      <Stat label="Region" value={report.datacenter} />
      <Stat
        label="Latency"
        value={latency && `${Math.round(latency)} ms`}
        grade={grade(latency, STATS_THRESHOLDS.latency)}
      />
      <Stat
        label="Publish jitter"
        value={publishJitter && `${publishJitter.toFixed(1)} ms`}
        grade={grade(publishJitter, STATS_THRESHOLDS.videoJitter)}
      />
      <Stat
        label="Receive jitter"
        value={receiveJitter && `${receiveJitter.toFixed(1)} ms`}
        grade={grade(receiveJitter, STATS_THRESHOLDS.videoJitter)}
      />
      <Stat
        label="Publish resolution"
        value={formatResolution(report.publisherStats)}
      />
      <Stat
        label="Receiving resolution"
        value={formatResolution(report.subscriberStats)}
      />
      <Stat
        label="Publish bitrate"
        value={publishBitrate && `${publishBitrate.toFixed(0)} kbps`}
      />
      <Stat
        label="Receiving bitrate"
        value={receiveBitrate && `${receiveBitrate.toFixed(0)} kbps`}
      />
      <Stat
        label="Quality drop reason"
        value={report.publisherStats?.qualityLimitationReasons}
      />
    </dl>
  );
};
```

The two small helpers the panel leans on:

```tsx
const formatResolution = (stats?: {
  highestFrameWidth?: number;
  highestFrameHeight?: number;
  highestFramesPerSecond?: number;
}) => {
  if (!stats?.highestFrameWidth || !stats.highestFrameHeight) return undefined;
  const { highestFrameWidth, highestFrameHeight, highestFramesPerSecond } =
    stats;
  const resolution = `${highestFrameWidth}x${highestFrameHeight}`;
  return highestFramesPerSecond
    ? `${resolution}@${highestFramesPerSecond}`
    : resolution;
};

const Stat = ({
  label,
  value,
  grade,
}: {
  label: string;
  value?: string | number;
  grade?: Grade;
}) => (
  <div className={grade ? `my-call-stats__stat--${grade}` : undefined}>
    <dt>{label}</dt>
    <dd>{value ?? "-"}</dd>
  </div>
);
```

Every value is optional. A report arriving before the first frame is published has no publisher resolution, and `qualityLimitationReasons` is absent while nothing is limiting quality - which is the common case, and worth rendering as nothing rather than as an empty row.

## Where to put it

Stats are a diagnostic, not a feature. Two placements that work:

- **Behind a toggle in the call controls**, for calls where users are expected to troubleshoot their own connection. See [replacing call controls](https://getstream.io/video/docs/react/v2/ui-cookbook/replacing-call-controls/).
- **In a support-only overlay**, gated behind a query parameter or a staff flag, so your support team can read the numbers off a user's screen without the panel existing for everyone else.

The reporting interval is configurable, and stats gathering can be switched off entirely for builds that never show a panel:

```ts
// report every 3.5 seconds instead of the default 2
call.setStatsReportingIntervalInMs(3500);

// or turn stats gathering off
call.setStatsReportingIntervalInMs(0);
```

Keep the interval at two seconds or above - anything faster burns CPU for numbers that no one can read that quickly.

## Related

- [Call Stats Report](https://getstream.io/video/docs/react/v2/advanced/stats/) - the full report shape, and per-participant stats
- [Network quality indicator](https://getstream.io/video/docs/react/v2/ui-cookbook/network-quality-indicator/) - the per-participant version of the same idea
- [Pre-call self-test](https://getstream.io/video/docs/react/v2/ui-cookbook/pre-call-self-test/) - surfacing these numbers before the call starts

---

For the most recent version of this documentation, visit [https://getstream.io/video/docs/react/v2/ui-cookbook/call-statistics/](https://getstream.io/video/docs/react/v2/ui-cookbook/call-statistics/).