Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6f46dcb
feat(telemetry): config, envelope, sink, and payload definitions
sirahd Jul 28, 2026
984be35
feat(telemetry): emit from session finalize, Drop, and heartbeats
sirahd Jul 28, 2026
e275ac0
feat(telemetry): simulation route, integration tests, and docs
sirahd Jul 29, 2026
a701369
docs(telemetry): keep the receiving service's storage layout out of t…
sirahd Jul 30, 2026
28f2f6d
fix(telemetry): downloads never reported; no caller finalized the ses…
sirahd Jul 30, 2026
a95b389
fix(telemetry): restore the wasm and Windows builds
sirahd Jul 30, 2026
0983538
docs(telemetry): drop the private repo reference from the api_changes…
sirahd Jul 31, 2026
ec6715b
docs(telemetry): describe the endpoint's failures without naming its …
sirahd Jul 31, 2026
06ac796
fix(deps): bump event-listener to 5.4.2 for RUSTSEC-2026-0221
sirahd Jul 31, 2026
9e02529
fix(telemetry): infer a download's Drop outcome from its progress
sirahd Aug 3, 2026
ac3adae
Merge main into sira/client-transfer-telemetry
sirahd Aug 3, 2026
d639ce6
fix(telemetry): make the in-flight cap process-wide
sirahd Aug 3, 2026
4cd858c
fix(telemetry): emit the envelope's user_agent in snake_case
sirahd Aug 5, 2026
aeec419
fix(telemetry): preserve HTTP status so downloads can report 429 and 5xx
sirahd Aug 5, 2026
72681cb
test(telemetry): drop the two redundant envelope casing tests
sirahd Aug 5, 2026
d1a9f2c
refactor(telemetry): match on StatusCode instead of a u16 conversion
sirahd Aug 5, 2026
6c824da
fix(telemetry): drop the dry_run metric, which could only ever be false
sirahd Aug 5, 2026
173d45a
fix(telemetry): abort rather than finish when a with block raises
sirahd Aug 6, 2026
218528b
docs(telemetry): correct what skipping finish() actually reports
sirahd Aug 6, 2026
a05ec97
docs(openapi): use snake_case for the telemetry fields in prose and e…
sirahd Aug 10, 2026
cc9a9c4
fix(telemetry): drain in-flight documents before the runtime shuts down
sirahd Aug 10, 2026
8af0427
fix(telemetry): a contended metrics snapshot skips one beat, not ever…
sirahd Aug 10, 2026
3d48e2d
chore: refresh the sub-workspace lockfiles
sirahd Aug 10, 2026
9cf0fbf
fix(telemetry): keep stream-group finish and abort responsive to signals
sirahd Aug 10, 2026
94e478c
test(telemetry): assert what finish and abort leave behind on a strea…
sirahd Aug 10, 2026
388238b
fix(telemetry): return a 500 rather than panic on a poisoned lock in …
sirahd Aug 10, 2026
b7ddd15
fix(telemetry): measure peak_concurrency from active permits, not the…
sirahd Aug 10, 2026
92fbc1f
fix(telemetry): log an accepted document at info!
sirahd Aug 10, 2026
e33f4fe
fix(telemetry): log a failed send at info! as well
sirahd Aug 10, 2026
cae4cef
refactor(telemetry): one telemetry_of, since Client is already Send +…
sirahd Aug 10, 2026
3f1119a
perf(telemetry): count items without snapshotting every one of them
sirahd Aug 10, 2026
6098134
style: apply nightly rustfmt
sirahd Aug 10, 2026
f039f8b
refactor(telemetry): drop the HF_HUB env-var opt-out
sirahd Aug 10, 2026
fea0f48
fix(telemetry): close the group on abort, as its callers already promise
sirahd Aug 10, 2026
edad220
fix(telemetry): report an upload commit's own failure, not just the s…
sirahd Aug 10, 2026
cdbd48d
refactor(telemetry): name the upload override finalize_with, matching…
sirahd Aug 10, 2026
2c45835
fix(telemetry): keep dedup metrics readable when finalize fails late
sirahd Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,31 @@ RUST_LOG=info # enable hf-xet logging
HF_XET_LOG_FILE=/tmp/xet.log # write logs to a file (defaults to stdout)
```

### Telemetry

hf-xet reports a small performance summary — byte counts, dedup effectiveness, throughput, and
wall time — to the CAS server at the end of each upload or download. It is best-effort: never
retried, and it cannot delay or fail a transfer. The payload contains **no file names, paths,
hashes, repository ids, or user ids**.

To turn it off, use any of:

```bash
HF_XET_TELEMETRY_ENABLED=0 # hf-xet specific

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove mentioning of HF_HUB env vars as disabling telemetry is done programmatically.

HF_HUB_DISABLE_TELEMETRY=1 # shared with the rest of the huggingface_hub stack
HF_HUB_OFFLINE=1 # implies the above
```

Tuning (rarely needed):

```bash
HF_XET_TELEMETRY_FINAL_FLUSH_TIMEOUT=2s # how long a transfer waits for its final report; 0 = don't wait
HF_XET_TELEMETRY_HEARTBEAT_AFTER=5m # long transfers report progress after this; 0 disables
HF_XET_TELEMETRY_HEARTBEAT_INTERVAL=5m # and then at this interval
HF_XET_TELEMETRY_REQUEST_TIMEOUT=5s # per-request budget
HF_XET_TELEMETRY_MAX_IN_FLIGHT=4 # concurrent reports before dropping
```

## Local Development

### Repo Organization
Expand Down
157 changes: 157 additions & 0 deletions api_changes/update_260728_client_transfer_telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Client transfer telemetry

**Date**: 2026-07-28
**Crates**: `xet-runtime` (new `telemetry` config group), `xet-client`
(`cas_client::telemetry`, `Client` trait), `xet-data` (`telemetry` module, session hooks)

## What changed

The client now reports one performance summary per transfer to `POST /v1/telemetry` on the CAS
server, where a companion change adds the endpoint. Reporting is best-effort: it is never retried,
never surfaces an error, and never blocks data movement.

### Wire format: the envelope is entirely snake_case

The body carries exactly five keys — `time`, `event`, `session_id`, `user_agent`, `metrics` — and
**every one is snake_case**, matching the naming convention across the whole document (envelope,
`metrics`, and the fields the server stamps itself).

`user_agent` was previously emitted as camelCase `userAgent`. The server accepts that spelling as a
serde alias for older clients, so both work, but a body carrying **both** is rejected as a
duplicate-field 400 — so exactly one must be sent, and it is now the snake_case one. The key set is
pinned by `test_envelope_has_exactly_the_five_contract_keys`.

`metrics` carries **40 keys on an upload document and 23 on a download**, pinned by
`UPLOAD_KEYS`/`DOWNLOAD_KEYS`. There is no `dry_run` key: a dry run never builds a
`TransferTelemetry` at all (`maybe_new` returns `None`), so the field could only ever serialize
`false` — a constant in every document, and key names are over half a document's bytes.

### New config group: `telemetry`

| Field | Env var | Default |
|---|---|---|
| `enabled` | `HF_XET_TELEMETRY_ENABLED` | `true` |
| `heartbeat_after` | `HF_XET_TELEMETRY_HEARTBEAT_AFTER` | `300s` |
| `heartbeat_interval` | `HF_XET_TELEMETRY_HEARTBEAT_INTERVAL` | `300s` |
| `request_timeout` | `HF_XET_TELEMETRY_REQUEST_TIMEOUT` | `5s` |
| `final_flush_timeout` | `HF_XET_TELEMETRY_FINAL_FLUSH_TIMEOUT` | `2s` |
| `max_in_flight` | `HF_XET_TELEMETRY_MAX_IN_FLIGHT` | `32` |

`max_in_flight` is a **process-wide** ceiling: one counter is shared by every sink, so the total
number of in-flight telemetry POSTs is bounded no matter how many transfers run at once. A per-sink
counter would have bounded each transfer separately and multiplied by the transfer count, which puts
the backpressure in the wrong place — a snapshot download fans out into many concurrent per-file
transfers, so the heaviest telemetry moment would have been the one with no aggregate limit. The
default is sized as a process-wide number accordingly: a transfer emits one terminal document and
heartbeats only begin after `heartbeat_after`, so the realistic burst is a set of concurrent
transfers finalizing together.

`HF_HUB_DISABLE_TELEMETRY` and `HF_HUB_OFFLINE` also force it off, and win over

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove mentioning of HF_HUB env vars as disabling telemetry is done programmatically.

`HF_XET_TELEMETRY_ENABLED=1`. These are applied at the end of `XetConfig::with_env_overrides`
rather than through `ENVIRONMENT_NAME_ALIASES`, because that table maps names with identical
polarity and these are inverted.

### `Client` trait gained a method — **with a default body**

```rust
#[cfg(not(target_family = "wasm"))]
fn transfer_telemetry(&self) -> Option<Arc<TransferTelemetry>> { None }
```

**No existing implementor needs to change.** Only `RemoteClient` overrides it; the local,
in-memory, and simulation clients inherit `None` and report nothing.

Note the failure mode this creates: an override with a mistyped signature compiles cleanly and is
silently never called. `xet_data/tests/test_transfer_telemetry.rs` exists to catch that and should
not be deleted.

There is a second, subtler failure mode: the emit machinery can work perfectly while no production
caller ever reaches it. Tests that drive `FileDownloadSession` directly cannot see that, because
they call `finalize()` themselves. `xet_pkg/tests/test_download_telemetry.rs` exists to close the
gap — it goes through `XetFileDownloadGroup::finish_blocking()` and asserts on what the server
received. Keep new coverage at that altitude; a test that calls `finalize()` itself re-opens it.

### Session behavior

- `FileUploadSession::finalize_impl` now delegates to a new private `finalize_inner` and reports on
both the success and error paths. Public signatures are unchanged.
- `FileDownloadSession::finalize` likewise, and reports a successful transfer. It keeps the debug
assertion that every item completed, so it must only be used on a clean-completion path.
- **New:** `FileDownloadSession::finalize_with(outcome, error_class)` finalizes while reporting an
explicit outcome, and makes no completeness claim. Use it for a session that ended badly, and for
one whose notion of "complete" belongs to the caller. `finalize` alone can only ever report `ok`,
so a download that failed has to go through this or the failure-rate signal is always zero.
- **Download groups now finalize their session.** `XetFileDownloadGroup::finish`/`finish_blocking`
and the legacy `data_client::download_async` finalize on both the success and error paths, the
latter classified via the new `XetError::telemetry_class()`. Previously nothing called
`FileDownloadSession::finalize`, so downloads through the Python bindings reported nothing at all.
- **`XetError` gained two variants: `RateLimited(String)` and `ServerError(String)`**, for HTTP 429
and 5xx respectively. Additive, and the enum is already `#[non_exhaustive]`.
- **Python-visible behavior is unchanged**: both map to `PyConnectionError`, exactly as
`XetError::Network` did before. Only the message prefix differs (`Rate limited:` /
`Server error:` instead of `Network error:`).
- They exist because HTTP status did not survive the flattening into `XetError`. Every HTTP failure
became `Network`, so `telemetry_class()` could never return `rate_limited` or `server_error`,
while the upload path — which classifies from `DataError` and inspects `reqwest::Error::status()`
— reported both. A 429 therefore meant two different things depending on direction, defeating the
point of a shared `error_class` vocabulary.
- One gap is deliberately left: a 404 arriving as a `reqwest` status still classifies as `network`,
not `not_found`. Routing it correctly would change the Python exception type callers catch, which
is a user-visible change rather than a telemetry fix. Pinned by a test so it stays a decision.
- **New:** `XetDownloadStreamGroup::finish`/`finish_blocking` (and `finish()` plus context-manager
support on the Python class). Streams are consumed independently, so the group cannot detect
completion itself, and `finish` is how a caller states it explicitly. **Purely additive** - a
group that is never finished behaves exactly as before and still reports, so no existing caller
has to change. Note that `finish` closes the group: streams already handed out stay usable, but
opening a new one afterwards is an error.
- **Both sessions gained a `Drop` impl**, emitting a terminal summary when the session was never
finalized — the safety net for callers that abandon a session. It is deliberately *not* gated on
an ambient tokio runtime: the send is spawned on the `XetRuntime`'s own stored handle, so
requiring `Handle::try_current()` only served to disable the path for embedders that release the
last `Arc` from a foreign thread, which is exactly what the Python bindings do.
- An abandoned **upload** reports `aborted`: the commit never happened, so nothing was durably
transferred.
- An abandoned **download** reports `ok` or `dropped` depending on what actually transferred, via
the new `GroupProgress::all_items_complete()`. Reaching `Drop` only means nobody called
`finalize()`, which for downloads is the common case rather than a failure — `finish` is new and
existing embedders have not adopted it. Reporting all of those as `dropped` would make the
outcome field mean "probably fine" and leave a failure-rate dashboard nothing to measure, so
`dropped` is reserved for a genuinely incomplete transfer.
- `all_items_complete()` requires every item to have a **finalized size** as well as all of its
bytes delivered, and requires at least one item. The size check is not redundant: for an
open-ended stream range the size is discovered incrementally, so `bytes_completed` can equal
`total_bytes` mid-transfer whenever the consumer catches up to the prefetch frontier. A byte
comparison alone would report an abandoned stream as a success.
- New public accessors: `FileDownloadSession::client()`, and
`TestEnvironment::telemetry_docs()` under the `simulation` feature.
- New public helpers: `xet_data::telemetry::{classify_error, outcome_for_class}`, for callers that
need to produce an `(outcome, error_class)` pair themselves.

### Simulation server

`POST /v1/telemetry` is now routed by the local test server, and received documents are readable
via `LocalServer::telemetry_docs()` / `LocalTestServer::telemetry_docs()`.

### New dependencies

`chrono` and `uuid` were added to `xet-client`, gated to non-wasm targets.

## Why

We had no client-side view of upload/download performance, so a throughput regression shipped in
an `hf-xet` release was undetectable. Server-side metrics cover CAS request latency but not
end-to-end client throughput, dedup effectiveness, or where a transfer's wall time goes.

## Notes for downstream agents

- **The metric key set is a contract.** `xet_data/src/telemetry/payload.rs` is the source of truth.
Adding a key is safe; changing an existing key's JSON type is not — consumers assign a field
type on first sight and cannot change it in place, so a type change breaks ingestion for every
document carrying it. `test_upload_key_set_is_exact` and `test_numeric_types_stable` enforce this.
- **All `f64` values must go through the finite guard** in that module. `serde_json` renders NaN
and infinity as `null`, and a single such document poisons the field's type for a consumer.
- **No PII.** The payload carries no file names, paths, hashes, repository ids, or user ids; the
server derives identity from the request's JWT.
- A follow-up PR adds the generated schema (`telemetry/metrics.schema.json`) and a CI
compatibility gate. Until then the const key lists in the tests are the only thing pinning the
contract.
2 changes: 2 additions & 0 deletions hf_xet/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions hf_xet/src/py_download_stream_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,42 @@ impl PyXetDownloadStreamGroup {
"XetDownloadStreamGroup()"
}

// ── Context manager ──────────────────────────────────────────────────────

fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
slf
}

fn __exit__(
&self,
py: Python<'_>,
_exc_type: Bound<'_, pyo3::PyAny>,
_exc_val: Bound<'_, pyo3::PyAny>,
_exc_tb: Bound<'_, pyo3::PyAny>,
) -> PyResult<bool> {
self.finish(py)?;
Ok(false)
}
Comment thread
cursor[bot] marked this conversation as resolved.

/// Mark the group as finished, reporting transfer telemetry.
///
/// Streams are consumed independently, so the group cannot tell on its own when the caller is
/// done; this says so.
///
/// **Optional.** A group that is never finished behaves exactly as before and still reports
/// when it is collected — as a dropped transfer rather than a clean one. Existing code needs
/// no change.
///
/// Open every stream you intend to open first: the group is **closed** afterwards, so
/// :meth:`download_stream` and :meth:`download_unordered_stream` raise once it has been
/// called. Streams already returned stay usable.
///
/// Called automatically when exiting a ``with`` block. Calling it twice is a no-op.
pub fn finish(&self, py: Python<'_>) -> PyResult<()> {
let group = self.inner.clone();
py.detach(|| group.finish_blocking().map_err(convert_xet_error))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use blocking_call_with_signal_check instead so it's responsive to signals.

}

// ── Stream constructors ──────────────────────────────────────────────────

/// Open an ordered byte stream for a file.
Expand Down
84 changes: 84 additions & 0 deletions openapi/cas.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,57 @@ paths:
description: Unauthorized — Missing/expired token
'403':
description: Forbidden — Token does not have required scope
/v1/telemetry:
post:
summary: Report Client Telemetry
description: |
Ingests a single client transfer-performance document. Fire-and-forget: the client never
retries and ignores the response, so a failure here has no effect on a transfer.

The server enriches each document with its own context (`serverTime`, `env`, `casVersion`,
`clientIp`) and the request token's claims (`repoId`, `userId`, ...), so the client sends
no repository or user identity of its own.
Comment thread
sirahd marked this conversation as resolved.
Outdated

The `metrics` object is a flat map of scalars whose keys are defined by the client. Their
vocabulary is intentionally not enumerated here — see
`xet_data/src/telemetry/payload.rs`, which is the source of truth.

Minimum token scope: `read`.
x-required-scope: read
operationId: postTelemetry
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TelemetryEnvelope'
examples:
uploadSummary:
summary: Terminal document for a completed upload
value:
time: '2026-07-28T12:00:00.000Z'
event: xet_upload_summary
session_id: 019813f1-0000-7000-8000-000000000000
userAgent: hf_xet/1.5.4
Comment thread
sirahd marked this conversation as resolved.
Outdated
metrics:
schema_version: 1
direction: upload
terminal: true
duration_ms: 4210
outcome: ok
responses:
'200':
description: Accepted — also returned when server-side telemetry is disabled
'400':
description: Bad Request — Missing key, wrong type, or non-ISO `time`
'401':
description: Unauthorized — Missing/expired token
'413':
description: Payload Too Large — Body exceeds 1 MiB
'429':
description: Too Many Requests — Ingestion saturated. Retryable, but the client does not retry.
'500':
description: Internal Server Error — Ingestion failed
components:
securitySchemes:
bearerAuth:
Expand Down Expand Up @@ -411,5 +462,38 @@ components:
0 = Shard already exists, 1 = SyncPerformed — the Shard was registered. Any 200 OK means success.
required: [result]
additionalProperties: false
TelemetryEnvelope:
type: object
description: |
Body of `POST /v1/telemetry`. Note the deliberate casing mix: `session_id` is snake_case
while `userAgent` is camelCase. The server also accepts `user_agent`, but a body carrying
both spellings is rejected as a duplicate field.
properties:
time:
type: string
format: date-time
description: ISO-8601 / RFC3339. Any offset is accepted; the server normalizes to UTC.
event:
type: string
description: |
`xet_upload_summary` and `xet_download_summary` are terminal, one per transfer.
`xet_transfer_heartbeat` is emitted periodically by transfers that run long enough.
session_id:
type: string
description: |
Groups activity within one client session. Not unique per transfer — an upload and a
download in the same session share it, so use `metrics.transfer_id` to identify a
single transfer.
userAgent:
type: string
metrics:
type: object
description: Flat map of scalar values. Never null, nested, or an array.
additionalProperties:
oneOf:
- type: string
- type: number
- type: boolean
required: [time, event, session_id, userAgent, metrics]


2 changes: 2 additions & 0 deletions wasm/hf_xet_thin_wasm/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions wasm/hf_xet_wasm/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading