# Traffic and bandwidth accounting

> How per-tenant ingress and egress bytes are collected, bucketed, and aggregated across every modality behind the Traffic and Bandwidth screen.

The **Traffic & Bandwidth** screen answers one question: for this org, how
many bytes moved, in which direction, over which transport, in the last
N minutes. It is backed by a single procedure, `traffic.overview`, which
reads two pre-aggregated ClickHouse rollups, unions them, and folds the
result into a per-minute series plus per-modality totals.

```ts
trpc.traffic.overview.useQuery({ orgId, windowMinutes })
```

| Input           | Type   | Notes                                       |
| --------------- | ------ | ------------------------------------------- |
| `orgId`         | string | Tenant scope. Every query is filtered to it. |
| `windowMinutes` | int    | Minimum 1, maximum 1440. Defaults to 30.    |

The console exposes five window presets — 15 min, 30 min, 1 hour,
6 hours, 24 hours — and mirrors the selection into the `w` query
parameter so a window is shareable by URL. The query refetches every
30 seconds, and the refresh button forces a refetch.

## Where the numbers come from

Two independent collectors feed this screen. They are unioned, not
merged: a row from either side becomes a `(modality, bucket)` row in the
result.

| Source | Rollup table | Modality key comes from | Byte counts | Packet counts | Sessions |
| ------ | ------------ | ----------------------- | ----------- | ------------- | -------- |
| Engine traffic meter (`telequick.metrics.traffic`) | `tenant_traffic_1m_q` | the meter's `modality` field | yes | yes | as reported by the meter |
| QUIC connection sampler (`telequick.fleet.quic_transport`) | `quic_cwnd_1m_q` | `subject_kind` | yes | **none** — forced to zero | `countDistinct(subject_id)` |

The engine meter is the instrumentation that TeleQuick modules call
when they move bytes. It already knows the modality, direction, and
packet counts, so its rows pass through nearly untouched.

The QUIC sampler is different in kind. It walks live QUIC connections
and samples transport counters per `(subject_kind, subject_id)`. Before
it lands in this view it is grouped down to `subject_kind` per bucket,
with `subject_id` collapsed into a distinct-connection count. That
grouping is where packet counts are lost and where "sessions" gets its
meaning.

After the union, rows are re-aggregated per `(modality, bucket)`:
`bytes_in`, `bytes_out`, `packets_in`, and `packets_out` are summed;
`sessions` takes the **maximum**, not the sum.

A row whose modality is empty is bucketed under `unknown`.

## Modality keys

The modality key is the raw string from the collector. The console maps
known keys to a display label and a stable colour; an unrecognised key
still renders, labelled with the raw key and coloured from a fallback
palette. A newly wired transport therefore appears on the chart before
anyone teaches the UI about it.

Keys sourced from the engine traffic meter:

| Key | Label |
| --- | ----- |
| `voice_rtp` | Voice · RTP |
| `sip` | SIP signaling |
| `webrtc` | WebRTC |
| `rtmp` | RTMP ingest |
| `srt` | SRT ingest |
| `tunnel` | Tunnel |
| `realtime` | Realtime (Pusher) |
| `kafka_egress` | Event stream |
| `http` | HTTP |
| `ws` | WebSocket |
| `vpn` | VPN |
| `media` | Agent media |
| `agent` | Agent transport |
| `streams` | Streams · HLS |
| `inference` | Inference gateway |
| `turn` | TURN relay |
| `mqtt` | MQTT |
| `wt_media` | Browser audio · WT |
| `whatsapp` | WhatsApp API |
| `compute` | Compute nodes |

Keys sourced from the QUIC sampler's `subject_kind`:

| Key | Label |
| --- | ----- |
| `robot` | Robotics · QUIC |
| `match_player` | Games · QUIC |
| `broadcast_viewer` | Broadcast · QUIC |
| `voice` | Voice · QUIC |
| `data` | Data · QUIC |
| `meet` | Meet · QUIC |
| `live_input` | Live input · QUIC |

`voice` and `voice_rtp` are distinct keys from distinct collectors and
are **not** deduplicated. A call whose media rides QUIC to the relay and
RTP to a carrier contributes to both rows. Read the breakdown table as
"bytes per transport", not "bytes per call".

Two historical gaps are worth knowing when you look at older windows.
The QUIC sampler rows were dark until 2026-09-10, because the sampler
only walked the native quiche listener while production served MoQT over
lsquic — none of the `subject_kind` modalities appeared before that
date. The `inference`, `turn`, `mqtt`, `wt_media`, `whatsapp`, and
`compute` keys were wired on the same date; those modules moved bytes
before then and reported none.

## Why some modalities report no packets

The union writes literal zeros for `packets_in` and `packets_out` on
every QUIC-sampler row. The sampler's grouping step discards per-subject
packet counters, so there is nothing to carry forward. Byte counts on
those rows are real; packet counts are simply unknown and are
represented as zero rather than as null.

Consequently `packetsIn` / `packetsOut` in the `byModality` payload are
only meaningful for engine-meter modalities. The console's breakdown
table does not render them at all, which avoids showing a QUIC modality
that moved gigabytes next to a packet count of `0`. If you consume the
procedure directly, apply the same caution: do not derive average packet
size or loss ratios for a QUIC-sampled modality.

## Bucketing, lag, and the query window

Both rollups are pre-bucketed to one minute. The procedure selects rows
where `bucket > now() - INTERVAL <windowMinutes> MINUTE`, so the window
is a rolling wallclock range, and the resolution of the series is always
one minute regardless of which preset you pick — a 24-hour window
returns 1-minute buckets, not coarser ones.

Because bucketing is on wallclock minute boundaries, a session that
spans a boundary is split across buckets. There is no attribution of a
whole session to its start minute.

New traffic appears once the minute bucket it belongs to has been rolled
up and becomes visible to the query. The console's empty state describes
this as *usually within about a minute*; the screen's own 30-second
refetch means you may need one or two polls after a session starts
before its first bucket is on the chart.

The procedure's input schema caps `windowMinutes` at 1440. For anything
longer-range than that, use the per-call records described in
[Telemetry](/platform/telemetry) rather than this view.

If ClickHouse is unreachable, or the rollup tables have not been
migrated on this deployment, the procedure catches the error and returns
the zero-valued empty payload. The screen shows the quiet empty state
rather than an error banner, matching the other observability
procedures. The `source` field is always `real`; there is no synthetic
or sampled mode behind this screen.

## How averages and peaks are computed

Everything is derived from the bucket series, in the procedure, not in
ClickHouse.

**Window length.** The denominator is the number of buckets actually
present in the result, times 60 seconds, with a floor of 60 seconds:

```
windowSec = max(60, distinctBucketsPresent * 60)
```

The nominal `windowMinutes` is deliberately not used. If you ask for 24
hours but the org only carried traffic for 3 minutes, the average is
computed over those 3 minutes. This keeps "Avg b/w" honest for bursty
tenants, but it also means the average is **not** comparable to a
`bytes_total / windowMinutes` figure you compute yourself, and widening
the window does not necessarily lower the average.

**Average bandwidth**, both the KPI and the per-modality column:

```
avgMbps = (bytesIn + bytesOut) * 8 / windowSec / 1e6
```

Note this is total throughput — ingress and egress summed — in decimal
megabits per second, rounded to three decimals for the KPI.

**Peak bandwidth** is the busiest single 1-minute bucket, summed across
all modalities, expressed as a rate over its own 60 seconds:

```
peakBucketBytes = max over buckets of (bytesIn + bytesOut)
peakMbps        = peakBucketBytes * 8 / 60 / 1e6
```

So "Peak b/w" is a 1-minute average at the busiest minute, not an
instantaneous line rate. Sub-minute bursts are invisible to it by
construction. There is no per-modality peak in the payload; peak is a
window-level KPI only.

**Units.** The two families of numbers use different multipliers, which
is easy to misread when comparing them. Byte totals are formatted with
binary multiples (1024) but conventional `KB` / `MB` / `GB` / `TB`
labels. Bandwidth is decimal megabits (`* 8 / 1e6`). The throughput
chart plots `bytes / 60 / 1024` and labels the axis KB/s, so that chart
is kibibytes per second while the KPI beside it is decimal megabits per
second.

## Sessions are peak concurrency, not volume

The `sessions` column is the most commonly misread number on the screen.

Within a modality, `sessions` is the **maximum** across the buckets in
the window, not the sum. On the QUIC side each bucket's value is a
count of distinct connection subjects active in that minute. So a
modality's session figure answers "what was the highest number of
concurrent connections seen in any single minute of this window", not
"how many sessions occurred".

The KPI card then sums those per-modality maxima to produce the
`sessions` figure shown under the Modalities card. That total is a sum
of peaks taken independently per modality, so the peaks need not have
occurred in the same minute.

Practical consequences:

- Widening the window can only raise a modality's session count, never
  lower it, because more buckets can only raise a maximum.
- Session counts do not add up over time and must not be used as a call
  volume, stream count, or connection-attempt count.
- For "how many calls happened", use Call Detail Records.

## Observed, not enforced

This subsystem measures. It does not gate. The procedure reads two
rollup tables and returns arithmetic over them; nothing in the path
rejects, shapes, throttles, or bills traffic, and no threshold value
exists in it to compare against. The screen states this directly in its
empty state: *bandwidth is observed, not enforced*.

Read a number here as "this much moved", never as "this much is allowed
to move". Any limit that applies to your account comes from somewhere
else and is not reflected in this payload.

## Reconciling traffic with call detail records

Both this screen and [CDRs](/platform/telemetry) count bytes, and they
will not agree. They are not measuring the same thing.

| | Traffic accounting | Call Detail Records |
| - | ------------------ | ------------------- |
| Grain | tenant, modality, 1-minute bucket | one row per call |
| Scope | every modality, including signaling, tunnels, ingest, HTTP, agent transport | the call leg |
| Time attribution | split across wallclock minute buckets | attributed to the call's lifetime |
| Written | continuously, while traffic flows | at `CHANNEL_HANGUP_COMPLETE` |

Specific reasons a sum over this screen exceeds a sum over CDRs for the
same period:

- **Non-call modalities are included.** `sip`, `http`, `ws`,
  `kafka_egress`, `tunnel`, `vpn`, `inference`, and the rest have no CDR
  at all.
- **The same call is counted per transport.** `voice_rtp`, `voice`
  (QUIC), `webrtc`, and `turn` are separate rows for what a CDR treats
  as one call.
- **Boundaries differ.** A call in flight at the edge of the window
  contributes only its overlapping buckets here, while its CDR lands
  whole, later, when the call ends.

Use the traffic screen to understand capacity and transport mix. Use
CDRs to attribute bytes to a specific `call_sid`. Neither is an invoice.

## Reading the payload directly

If you consume `traffic.overview` outside the console:

```ts
{
  kpi: {
    bytes_in, bytes_out, bytes_total,
    avg_mbps,        // 3dp, total throughput over present buckets
    peak_mbps,       // 3dp, busiest 1-min bucket
    sessions,        // sum of per-modality maxima
    modalities,      // count of distinct modality keys in the window
    window_minutes,  // echoes the requested window
  },
  byModality: [
    { modality, bytesIn, bytesOut, packetsIn, packetsOut, sessions, avgMbps },
    // sorted by bytesIn + bytesOut, descending
  ],
  series: [
    { bucket, bytesIn, bytesOut, byModality: { [key]: bytes } },
    // ascending by bucket; byModality values are in + out combined
  ],
  source: "real",
}
```

`series[].byModality` holds combined ingress + egress per modality, so it
cannot be split by direction per modality per bucket. Direction is
available window-wide (`kpi`), per modality (`byModality`), and per
bucket in aggregate (`series[].bytesIn` / `bytesOut`) — but not at the
intersection of modality and bucket.

`bucket` arrives as a ClickHouse `DateTime` string. Depending on the
server's `date_time_output_format`, it is either space-separated with no
zone (`2026-07-15 12:34:56`) or already ISO with a trailing `Z`. Normalise
the space to `T` and only append `Z` when no zone marker is present —
appending unconditionally produces an invalid date on the ISO form.

## Related

- [Telemetry](/platform/telemetry) — metrics, traces, CDRs, and SIP capture
- [Telephony Metrics](/glossary/metrics) — definitions for the call-quality numbers
