Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ that may never merge. They are not releases and are not listed here.

## Unreleased

## 0.2.2 - 2026-09-15

### Changed

- `mapbox usage` is no longer described as a private preview: the Statistics
API it calls is generally available, so `mapbox auth login` now requests
`statistics:read` unconditionally instead of through a feature flag.
Nothing about who can run the command or what it prints changed — the flag
it used to go through was already on for everyone.

- The 403 a missing `statistics:read` scope gets back now leads with "run
`mapbox auth login` again", the fix that actually applies, before falling
back to "contact Mapbox support" for the rarer case of an account with no
access at all. It used to jump straight to support, which was written for
the old private-preview gate and never distinguished the two — the API
answers both with 403, not the 401/403 split the docs previously assumed.

## 0.2.1 - 2026-09-15

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mapbox-cli"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
description = "A command-line interface for Mapbox APIs, with commands generated at build time from OpenAPI specs."
repository = "https://github.com/mapbox/cli"
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ curl -fsSL https://cli.mapbox.com/install.sh | sh
irm https://cli.mapbox.com/install.ps1 | iex
```

That channel is not serving yet. Until it is, build from source above.

`scripts/install.sh` and `scripts/install.ps1` here are those installers'
sources; `scripts/test-install.sh` and `scripts/test-install.ps1` exercise
them end to end without touching the network.
Expand Down
18 changes: 10 additions & 8 deletions custom-openapi/statistics/openapi/statistics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
# generically.
openapi: "3.0.0"
info:
title: "Mapbox Statistics API (private preview)"
title: "Mapbox Statistics API"
description: >-
The one endpoint `mapbox usage` calls: usage per Mapbox product, by day,
for the whole account or for a single token. A private preview gated two
ways — the account has to be enabled for it by Mapbox support, and the
token needs the `statistics:read` scope. Written by hand rather than trimmed
from an upstream spec because openapi-specs publishes none for this API;
the parameter names are the ones the API answers to, as documented in
for the whole account or for a single token. Gated by the token's
`statistics:read` scope. Written by hand rather than trimmed from an
upstream spec because openapi-specs publishes none for this API; the
parameter names are the ones the API answers to, as documented in
`src/account_usage.rs`.
version: "0.0.0"
servers:
Expand Down Expand Up @@ -63,9 +62,12 @@ paths:
"200":
description: Usage per product, per day, over the period.
"401":
description: Unauthorized — the token does not carry the `statistics:read` scope.
description: Unauthorized — the token is missing or invalid.
"403":
description: Forbidden — the account is not enabled for the private preview.
description: >-
Forbidden — the token doesn't carry the `statistics:read` scope, or (less
commonly) it does and the account itself doesn't have access to the Statistics
API.
"422":
description: Unprocessable — the period is malformed, backwards, or longer than 31 days.
"429":
Expand Down
28 changes: 12 additions & 16 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -3158,17 +3158,11 @@ Removed /home/user/.local/bin/mapbox.
### `mapbox usage`

Usage per Mapbox product, by day, for the account or one token. Calls the
Statistics API (`GET /statistics/v1`), a private preview gated two ways
this CLI cannot get around: the account has to be enabled for it by Mapbox
support (a 403 here means it isn't), and the token needs the
`statistics:read` scope. `mapbox auth login` requests it by default now;
log in again if your stored token predates that. Also takes
`--token-id <id>` for one token's usage instead of the whole account —
see `mapbox accounts list-tokens` for ids.

Gated behind a feature flag, flipped on: an official release ships this
command the same as a build from source does. See `feature_flags` for what
the gate is for.
Statistics API (`GET /statistics/v1`), which needs the `statistics:read`
scope. `mapbox auth login` requests it by default now; log in again if
your stored token predates that. Also takes `--token-id <id>` for one
token's usage instead of the whole account — see `mapbox accounts
list-tokens` for ids.

#### Parameters

Expand All @@ -3193,8 +3187,7 @@ mapbox usage --product "Vector Tiles API" --daily

#### Outputs

Run live 2026-09-08 against an account enabled for the private preview.
Numbers below are made up — the real response carries actual traffic
Run live. Numbers below are made up — the real response carries actual traffic
figures, which do not belong in a page committed to the repo — but the
shape, including the sort order (busiest product first), the sparkline, and
every line `-o text` prints around the table, is exactly what came back.
Expand Down Expand Up @@ -3260,9 +3253,12 @@ without it, a sparkline's solid glyphs sitting flush against the next
row's read as cramped rather than dense, on an account with more than a
couple of products. Exact per-day numbers and the per-browser/country/host
breakdown are left to `-o json`; `--product` narrows the whole response,
both columns, to one product's row. A 403 answers `Statistics API feature
is not enabled for this account`; a 401 means the token's missing
`statistics:read` — a login from before the scope was added, most likely.
both columns, to one product's row. A 403 covers two different causes the
API doesn't otherwise distinguish: the token missing `statistics:read` — a
login from before the scope was added, most likely, and fixed by logging in
again — or, less commonly, an account with no access to the Statistics API
at all, which needs Mapbox support. A 401 means the token itself is missing
or invalid.

`--daily` swaps every product's sparkline row for its own day-by-day
listing — same total, same period, newest day first, no `DAILY TREND`
Expand Down
75 changes: 34 additions & 41 deletions src/account_usage.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
//! `mapbox usage` — account/token usage by product and day.
//!
//! Calls the Statistics API (`GET /statistics/v1`), a private preview:
//! the account needs Mapbox support to enable it (403 otherwise), and the
//! token needs the `statistics:read` scope. `mapbox auth login` requests it
//! by default now that [`crate::feature_flags::flags::ACCOUNT_USAGE`] is on
//! (see `auth::requested_scopes`); a token from before that flip won't
//! carry it until logged in again.
//!
//! Gated by [`crate::feature_flags::flags::ACCOUNT_USAGE`]; see that module.
//! The gate stays while the API itself is a preview: the switch is what lets
//! an official binary stop shipping the command if the preview is withdrawn.
//! Calls the Statistics API (`GET /statistics/v1`); the token needs the
//! `statistics:read` scope. `mapbox auth login` requests it by default; a
//! token from before that scope was added won't carry it until logged in
//! again.

use std::sync::OnceLock;
use std::time::Duration;
Expand Down Expand Up @@ -62,7 +56,7 @@ const DAILY_ARG: &str = "daily";
/// this crate, not a state a shipped binary can drift into; the pinning test
/// below calls this function, so CI fails first; and nothing else — not
/// `--help`, not `--schema`, not startup — calls it, so a broken spec can
/// only ever break `usage` itself, and only behind its feature flag.
/// only ever break `usage` itself.
fn operation() -> &'static Operation {
static PARSED: OnceLock<Operation> = OnceLock::new();
PARSED.get_or_init(|| {
Expand All @@ -79,13 +73,12 @@ fn operation() -> &'static Operation {

pub fn command() -> Command {
Command::new(COMMAND)
.about("Show account/token usage by product and day (Statistics API, private preview)")
.about("Show account/token usage by product and day (Statistics API)")
.long_about(format!(
"Show usage per Mapbox product, by day, for the account or one token.\n\n\
Calls the Statistics API, a private preview gated two ways: the account has to \
be enabled for it by Mapbox support first — a 403 here means it isn't — and the \
token needs the `statistics:read` scope. `mapbox auth login` requests it by \
default; log in again if your stored token predates that.\n\n\
Calls the Statistics API; the token needs the `statistics:read` scope. \
`mapbox auth login` requests it by default; log in again if your stored \
token predates that.\n\n\
See {REPO_URL}/issues."
))
.arg(
Expand Down Expand Up @@ -655,12 +648,13 @@ fn redacted_url(url: &str, query: &[(String, String)]) -> String {
fn remedy_for(status: u16) -> Remedy {
match status {
401 => Remedy::default().with_fix(
"Check the token has the `statistics:read` scope — run `mapbox auth login` again \
if it predates that scope, or pass one from account.mapbox.com with --token.",
"The token is missing or invalid — run `mapbox auth login` again, or pass one \
from account.mapbox.com with --token.",
),
403 => Remedy::default().with_fix(
"The Statistics API is a private preview: ask Mapbox support to enable it for \
this account before this can return anything.",
"Check the token has the `statistics:read` scope — run `mapbox auth login` again \
if it predates that scope. If it already has the scope, this account doesn't \
have access to the Statistics API; contact Mapbox support.",
),
422 => Remedy::default().with_fix(
"--period-start/--period-end take YYYY-MM-DD, the end can't be before the start, \
Expand Down Expand Up @@ -1285,11 +1279,16 @@ mod tests {
}
}

/// The API answers 403, not 401, when the token itself is fine but is
/// missing `statistics:read` — confirmed live against a real account. A
/// re-login fixes that case, so the fix has to lead with it rather than
/// jump straight to Mapbox support, which is only the answer once the
/// scope is already there.
#[test]
fn a_403_carries_the_private_preview_explanation() {
fn a_403_checks_the_scope_before_pointing_at_mapbox_support() {
let (server, base_url) = serve_once(
"403 Forbidden",
r#"{"message":"Statistics API feature is not enabled for this account"}"#,
r#"{"message":"This API requires a token with statistics:read scope."}"#,
);

let matches = command().get_matches_from(["usage"]);
Expand All @@ -1301,22 +1300,21 @@ mod tests {
.downcast::<CliError>()
.expect("an HTTP failure is a CliError");
assert_eq!(cli.status, Some(403));
assert_eq!(
cli.message,
"Statistics API feature is not enabled for this account"
);
let fix = cli.fix.as_deref().unwrap_or_default();
assert!(fix.contains("statistics:read"), "{fix:?}");
assert!(fix.contains("Mapbox support"), "{fix:?}");
assert!(
cli.fix
.as_deref()
.unwrap_or_default()
.contains("Mapbox support"),
"{:?}",
cli.fix
fix.find("statistics:read").unwrap() < fix.find("Mapbox support").unwrap(),
"the self-serviceable fix should come before the support fallback: {fix:?}"
);
}

/// A 401 here means the token itself is missing or invalid — not a scope
/// problem, which this endpoint answers with 403 instead. Naming the
/// scope on a 401 would send the reader chasing something a bad token
/// can't have anyway.
#[test]
fn a_401_points_at_the_scope_rather_than_just_login() {
fn a_401_says_the_token_is_invalid_rather_than_naming_a_scope() {
let (server, base_url) = serve_once(
"401 Unauthorized",
r#"{"message":"Not Authorized - Invalid Token"}"#,
Expand All @@ -1330,13 +1328,8 @@ mod tests {
let cli = err
.downcast::<CliError>()
.expect("an HTTP failure is a CliError");
assert!(
cli.fix
.as_deref()
.unwrap_or_default()
.contains("statistics:read"),
"{:?}",
cli.fix
);
let fix = cli.fix.as_deref().unwrap_or_default();
assert!(fix.contains("auth login"), "{fix:?}");
assert!(!fix.contains("statistics:read"), "{fix:?}");
}
}
Loading