Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ that may never merge. They are not releases and are not listed here.

## Unreleased

- Each run records one `cli.command` telemetry event, appended to
`~/.mapbox/.telemetry/<date>.jsonl` and kept for 7 days. Nothing is sent
anywhere by default. It never touches stdout, the exit code or how long a
command takes, so a script sees no difference; a CI job does find a
`.mapbox/.telemetry` directory it didn't before. `MAPBOX_CLI_NO_TELEMETRY=1`
turns it off.

- `MAPBOX_CLI_EXTRA_QUERY` appends raw query parameters to every request, in
the same `k1=v1&k2=v2` shape as a URL's own query string — for an API
parameter this CLI's specs don't declare a flag for.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ Every request sends `User-Agent: mapbox-cli/<version>` and nothing else
about you or your machine. `MAPBOX_CLI_NO_TELEMETRY=1` keeps even future markers
out of that header.

Each run also appends one event to `~/.mapbox/.telemetry/<date>.jsonl`
(kept for 7 days): the command's name, its options (a value only when it
comes from a fixed list, otherwise just its length or size), how it ended,
and how long it took — never a token, a file path or free text you typed.
It stays on this machine. `MAPBOX_CLI_NO_TELEMETRY=1` turns it off.

### Agent skills

```sh
Expand Down
17 changes: 9 additions & 8 deletions src/account_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,8 @@ fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
era * 146097 + doe - 719468
}

/// The inverse of [`days_from_civil`].
fn civil_from_days(z: i64) -> (i64, u32, u32) {
/// The inverse of [`days_from_civil`]. Also [`crate::telemetry_event`]'s calendar.
pub(crate) fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097; // [0, 146096]
Expand Down Expand Up @@ -599,12 +599,13 @@ fn fetch(
eprintln!("[debug] GET {}", redacted_url(&url, &query));
}

let response = client
.get(&url)
.query(&query)
.timeout(http::budget(timeout, http::Payload::Bounded))
.send()
.map_err(|e| executor::transport_failure("Request failed", e))?;
let response = http::send(
client
.get(&url)
.query(&query)
.timeout(http::budget(timeout, http::Payload::Bounded)),
)
.map_err(|e| executor::transport_failure("Request failed", e))?;

let status = response.status();
// Before `text()` consumes the response: a 5xx here is worth escalating,
Expand Down
4 changes: 1 addition & 3 deletions src/agent_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,7 @@ fn fetch(base: &str, git_ref: &str, debug: bool) -> Result<Vec<u8>> {
eprintln!("[debug] GET {url}");
}

let response = http::client()?
.get(&url)
.send()
let response = http::send(http::client()?.get(&url))
.map_err(|e| executor::transport_failure("Could not reach GitHub", e))?;

let status = response.status();
Expand Down
57 changes: 32 additions & 25 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -743,10 +743,7 @@ fn refresh_credentials(creds: &mut Credentials, debug: bool) -> Result<()> {
eprintln!("[debug] POST {} (grant_type=refresh_token)", TOKEN_ENDPOINT);
}

let resp = client
.post(TOKEN_ENDPOINT)
.form(&params)
.send()
let resp = crate::http::send(client.post(TOKEN_ENDPOINT).form(&params))
.context("Token refresh request failed")?;

if !resp.status().is_success() {
Expand Down Expand Up @@ -781,8 +778,11 @@ pub fn force_refresh(debug: bool, profile: Option<&str>, mode: Mode) -> Result<(
let mut creds = load_credentials(profile)
.ok_or_else(|| anyhow!("Not currently logged in. Run `mapbox auth login` first."))?;

crate::telemetry_event::set_auth_step("refresh");
refresh_credentials(&mut creds, debug)?;
crate::telemetry_event::set_auth_step("save_credentials");
save_credentials(&creds, profile)?;
crate::telemetry_event::set_auth_step("done");

let expires_at = token_expires_at(&creds.access_token);
let text = match expires_at {
Expand Down Expand Up @@ -985,8 +985,10 @@ pub fn logout(profile: Option<&str>, mode: Mode) -> Result<()> {
let path = credentials_path(profile)?;
let had_credentials = path.exists();
if had_credentials {
crate::telemetry_event::set_auth_step("remove_credentials");
std::fs::remove_file(&path)?;
}
crate::telemetry_event::set_auth_step("done");

let text = if had_credentials {
"Logged out successfully."
Expand Down Expand Up @@ -1225,17 +1227,18 @@ fn verify_token(token: &str, debug: bool, timeout: Option<Duration>) -> Result<V
eprintln!("[debug] GET {VALIDATION_ENDPOINT}?access_token=<redacted>");
}

let response = crate::http::client()?
.get(VALIDATION_ENDPOINT)
.query(&[("access_token", token)])
// The one request `auth` makes to a Mapbox API rather than to the
// authorization server, so it is the one `--timeout` has to reach.
// The other three — refresh, registration, code exchange — carry a
// few hundred bytes each and keep the client's own budget.
.timeout(crate::http::budget(timeout, crate::http::Payload::Bounded))
.send()
// `reqwest::Error`'s `Display` appends the URL, and the token is in it.
.map_err(|e| crate::executor::transport_failure("Token check failed", e))?;
let response = crate::http::send(
crate::http::client()?
.get(VALIDATION_ENDPOINT)
.query(&[("access_token", token)])
// The one request `auth` makes to a Mapbox API rather than to the
// authorization server, so it is the one `--timeout` has to reach.
// The other three — refresh, registration, code exchange — carry a
// few hundred bytes each and keep the client's own budget.
.timeout(crate::http::budget(timeout, crate::http::Payload::Bounded)),
)
// `reqwest::Error`'s `Display` appends the URL, and the token is in it.
.map_err(|e| crate::executor::transport_failure("Token check failed", e))?;

let status = response.status();
// Before `text()` consumes the response — see `executor::request_id`.
Expand Down Expand Up @@ -1654,12 +1657,13 @@ fn register_client(redirect_uri: &str, debug: bool, scopes: &str) -> Result<Clie
eprintln!("[debug] body: {}", body);
}

let resp = client
.post(REGISTRATION_ENDPOINT)
.query(&[("scope", scopes)])
.json(&body)
.send()
.context("Failed to reach Mapbox OAuth registration endpoint")?;
let resp = crate::http::send(
client
.post(REGISTRATION_ENDPOINT)
.query(&[("scope", scopes)])
.json(&body),
)
.context("Failed to reach Mapbox OAuth registration endpoint")?;

if !resp.status().is_success() {
let status = resp.status();
Expand Down Expand Up @@ -1952,10 +1956,7 @@ fn exchange_code_for_token(
);
}

let resp = client
.post(TOKEN_ENDPOINT)
.form(&params)
.send()
let resp = crate::http::send(client.post(TOKEN_ENDPOINT).form(&params))
.context("Token exchange request failed")?;

if !resp.status().is_success() {
Expand Down Expand Up @@ -2113,6 +2114,7 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> {
// Computed once: register_client's ceiling and the authorize scope must agree.
let scopes = default_scopes();

crate::telemetry_event::set_auth_step("register_client");
output::progress("Registering OAuth client with Mapbox...");
let registration = register_client(&redirect_uri, debug, scopes)?;

Expand Down Expand Up @@ -2140,6 +2142,7 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> {
// Not discarded: with stderr redirected this is the only thing carrying
// the run, so its failure is the difference between refusing now and
// stalling for five minutes. See `login_can_be_completed`.
crate::telemetry_event::set_auth_step("open_browser");
let browser_opened = open::that(&auth_url).is_ok();
if !login_can_be_completed(std::io::stderr().is_terminal(), browser_opened) {
return Err(login_has_no_way_to_show_the_url());
Expand All @@ -2148,8 +2151,10 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> {
output::progress(&format!(
"Waiting for authorization (listening on port {port})..."
));
crate::telemetry_event::set_auth_step("wait_for_callback");
let code = wait_for_callback(port, &state, CALLBACK_TIMEOUT)?;

crate::telemetry_event::set_auth_step("exchange_code");
output::progress("Exchanging authorization code for access token...");
let mut creds = exchange_code_for_token(
&code,
Expand All @@ -2161,7 +2166,9 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> {
)?;
creds.client_id = Some(registration.client_id.clone());

crate::telemetry_event::set_auth_step("save_credentials");
save_credentials(&creds, profile)?;
crate::telemetry_event::set_auth_step("done");

let profile_note = match profile {
Some(name) if name != "default" => format!(" (profile: {name})"),
Expand Down
1 change: 1 addition & 0 deletions src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ pub fn run(app: &Command, matches: &ArgMatches) -> Result<()> {

// Straight to stdout rather than through `output::emit`: the script is
// the result, and there is no rendering of it that is not itself.
crate::telemetry_event::add_stdout_bytes(script.len());
let mut out = io::stdout().lock();
match out.write_all(&script).and_then(|()| out.flush()) {
// A reader that stopped reading is `head`'s ordinary behavior, not a
Expand Down
6 changes: 1 addition & 5 deletions src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,7 @@ impl ConnectivityReport {
/// of this crate uses `Option` for, and what the docs promise.
fn check(debug: bool, host: &str, timeout: Duration) -> Self {
let outcome = http::client().and_then(|client| {
client
.get(host)
.timeout(timeout)
.send()
.map_err(anyhow::Error::from)
http::send(client.get(host).timeout(timeout)).map_err(anyhow::Error::from)
});

match outcome {
Expand Down
21 changes: 12 additions & 9 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,7 @@ fn dispatch(
req = attach_body(req, source)?;
}

let response = req
.send()
.map_err(|e| transport_failure("Request failed", e))?;
let response = crate::http::send(req).map_err(|e| transport_failure("Request failed", e))?;
let status = response.status();
// Must read headers before `bytes()` consumes the response — anything
// not taken here is gone after. For a long time only `Content-Type`
Expand Down Expand Up @@ -348,6 +346,9 @@ fn dispatch(
.next_page
.as_deref()
.map(|next| NextPage::of(&op.query_params, next));
if next_page.is_some() {
crate::telemetry_event::set_more_pages();
}

match as_text {
Some(text) => match serde_json::from_str::<serde_json::Value>(&text) {
Expand Down Expand Up @@ -1392,6 +1393,7 @@ fn write_binary(body: &[u8], content_type: &str) -> Result<()> {
.into());
}

crate::telemetry_event::add_stdout_bytes(body.len());
stdout
.write_all(body)
.and_then(|()| stdout.flush())
Expand Down Expand Up @@ -1882,12 +1884,13 @@ mod tests {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let addr = listener.local_addr().expect("the bound address");

let failure = crate::http::client()
.expect("a client")
.get(format!("http://{addr}/"))
.timeout(std::time::Duration::from_millis(250))
.send()
.expect_err("a server that never answers cannot have answered");
let failure = crate::http::send(
crate::http::client()
.expect("a client")
.get(format!("http://{addr}/"))
.timeout(std::time::Duration::from_millis(250)),
)
.expect_err("a server that never answers cannot have answered");

let reported = super::transport_failure("Request failed", failure);
assert_eq!(reported.code, "request_timed_out");
Expand Down
45 changes: 44 additions & 1 deletion src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use std::time::Duration;
use anyhow::{Context, Result};
use clap::ArgMatches;

use crate::telemetry;
use crate::{executor, telemetry, telemetry_event};

/// The flag and the variable a caller moves the budget with.
pub const TIMEOUT_ARG: &str = "timeout";
Expand Down Expand Up @@ -240,6 +240,49 @@ fn build(timeout: Duration, command_group: Option<&str>) -> Result<reqwest::bloc
.context("Could not start an HTTP client")
}

/// Sends `request`, recording it for the run's telemetry event.
///
/// Every request goes through here rather than `RequestBuilder::send`,
/// because a `reqwest` client has no response hook: a request sent anywhere
/// else is one `network` does not count. `only_http_sends_requests` in
/// `tests/source_guards.rs` holds that.
///
/// The request id is kept only for a Mapbox host — it is what joins a row to
/// Mapbox's own logs, and another service's id joins to nothing we hold.
pub fn send(
request: reqwest::blocking::RequestBuilder,
) -> reqwest::Result<reqwest::blocking::Response> {
let (client, request) = request.build_split();
let request = request?;
let body_bytes = request
.body()
.and_then(|body| body.as_bytes())
.map(|bytes| bytes.len() as u64);
let mapbox = request.url().host_str().is_some_and(is_mapbox_host);

let started = std::time::Instant::now();
let result = client.execute(request);
let elapsed = started.elapsed();

match &result {
Ok(response) => telemetry_event::add_request(
Some(response.status().as_u16()),
response.content_length(),
body_bytes,
elapsed,
mapbox
.then(|| executor::request_id(response.headers()))
.flatten(),
),
Err(_) => telemetry_event::add_request(None, None, body_bytes, elapsed, None),
}
result
}

fn is_mapbox_host(host: &str) -> bool {
host == "mapbox.com" || host.ends_with(".mapbox.com")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading