hotpath 0.30.2

One profiler for CPU, time, memory, SQL, and async code - quickly find and debug performance bottlenecks.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! The client contract of the hotpath.rs API: the types a client branches on,
//! shared by the `hotpath-cloud` upload client, the `hotpath cloud` CLI and
//! hotpath-backend, which serializes these types.
//!
//! Bodies a client only prints are owned by hotpath-backend and passed
//! through as received. A field moves here only when a client starts reading
//! it.

use std::fmt;
use std::sync::LazyLock;

use serde::{Deserialize, Serialize};

/// Base URL of the hotpath.rs API when nothing overrides it.
pub const DEFAULT_BASE_URL: &str = "https://hotpath.rs";

/// Base URL every client talks to, the upload included. `HOTPATH_API_URL`
/// overrides the default for staging or self-hosted backends.
pub static API_URL: LazyLock<String> =
    LazyLock::new(|| normalize_base_url(std::env::var("HOTPATH_API_URL").ok()));

/// Turns the raw value of `HOTPATH_API_URL` into a base URL: trimmed, without
/// trailing slashes, `DEFAULT_BASE_URL` when unset or blank.
pub fn normalize_base_url(raw: Option<String>) -> String {
    raw.map(|s| s.trim().trim_end_matches('/').to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
}

/// A benchmark name that breaks the rule every party enforces: 1-64 chars of
/// `[A-Za-z0-9._-]`, not `.` or `..`. Displays as the rule itself, so each
/// caller prefixes where the name came from (`HOTPATH_BENCHMARK`,
/// `--benchmark`). The server's copy (hotpath-backend
/// `models::benchmark::validate_name`) must stay byte for byte identical.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidBenchmarkName;

impl fmt::Display for InvalidBenchmarkName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("use 1-64 chars from [A-Za-z0-9._-], not \".\" or \"..\"")
    }
}

/// Checks a benchmark name before it names an upload series or goes into a
/// request path; a valid name needs no percent-encoding.
pub fn validate_benchmark_name(name: &str) -> Result<(), InvalidBenchmarkName> {
    let valid_chars = name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c));
    if name.is_empty() || name.len() > 64 || !valid_chars || name == "." || name == ".." {
        return Err(InvalidBenchmarkName);
    }
    Ok(())
}

/// Why a request was refused, as clients branch on it. Never on the message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiErrorCode {
    /// No `Authorization: Bearer` credential at all (401).
    MissingToken,
    /// Unknown, expired or revoked token: one code, so a probe learns nothing (401).
    InvalidToken,
    /// The user's GitHub authorization lapsed; log in at hotpath.rs once (401).
    GithubAuthorizationExpired,
    /// Malformed query or path value, or an oversized upload body (400, 413).
    BadRequest,
    /// The token's user may not act on this resource, for instance an upload
    /// to a repository the GitHub App is not installed on (403).
    Forbidden,
    /// Unknown path or resource, including anything the caller may not see (404).
    NotFound,
    MethodNotAllowed,
    /// 429; the `Retry-After` header says how many seconds to wait.
    RateLimited,
    /// A policy was refused (422), sent for validation or carried by an
    /// upload; the body is a `PolicyRejected` with every problem found.
    InvalidPolicy,
    /// An upload carried no policy (422): every report must carry the
    /// repository's policy file. A plain `ApiError`, there is no document to
    /// list problems for.
    PolicyRequired,
    Internal,
    /// A code this client does not know; printed like any other error. Also
    /// the default, so a body without `code` still parses.
    #[default]
    #[serde(other)]
    Unknown,
}

/// Body of every non-2xx answer of `/api/v1`. The request id is the
/// `x-request-id` response header, not part of the body.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
    /// What was wrong and what to do about it.
    pub error: String,
    #[serde(default)]
    pub code: ApiErrorCode,
}

/// What happened to the pull request comment. `url`: posted or updated;
/// `error`: failed; `skipped`: the benchmark chose not to comment; none:
/// nothing to post (e.g. a push upload).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommentOutcome {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skipped: Option<String>,
}

/// Body of a successful upload (201, or 200 when the server already had the
/// same run and returned the existing report).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UploadCreated {
    pub id: String,
    pub repository: String,
    pub benchmark: String,
    /// Report the upload is compared against (pull request uploads only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub baseline: Option<String>,
    /// Defaulted so a server that does not send it yet still parses.
    #[serde(default)]
    pub comment: CommentOutcome,
    /// Judgement under the policy at `policy_path`. Required: a body without
    /// a verdict must not read as "no regression".
    pub verdict: Verdict,
    /// Path of the policy file the report was judged under.
    pub policy_path: String,
    /// That file on GitHub, pinned to the measured commit.
    pub policy_url: String,
    /// The dashboard's page for the report against its baseline
    /// (`.../benchmarks/{benchmark}/reports/{id}/diff`).
    pub dashboard_url: String,
}

/// Largest policy document a report may carry and the server stores, in
/// bytes of UTF-8.
pub const POLICY_MAX_BYTES: usize = 65536;

/// Largest report the server accepts, in bytes of the decompressed JSON
/// body. Clients check it before sending and never truncate a report to fit.
pub const MAX_REPORT_BYTES: usize = 5 * 1024 * 1024;

/// Why a serialized report of `len` bytes cannot be uploaded, or `Ok` when
/// it fits [`MAX_REPORT_BYTES`].
pub fn check_report_size(len: usize) -> Result<(), String> {
    if len <= MAX_REPORT_BYTES {
        return Ok(());
    }
    Err(format!(
        "the report is {len} bytes, over the {} MiB ({MAX_REPORT_BYTES} bytes) limit of \
         hotpath.rs; measure fewer functions or split the benchmark",
        MAX_REPORT_BYTES / (1024 * 1024)
    ))
}

/// Body of `POST /api/v1/policy/validate`. The answer is a 200 when the
/// document is a valid policy, or `PolicyRejected` (422).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyValidation {
    /// The whole TOML document, as written. At most `POLICY_MAX_BYTES`; blank
    /// is refused.
    pub source: String,
}

/// Body of `GET /api/v1/policy/default` (200): a starting policy file listing
/// every metric family with the release's key defaults. A key a policy table
/// omits takes its value from here; an omitted family table is not judged.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DefaultPolicy {
    /// The TOML document as written, comments included: they document each
    /// key.
    pub source: String,
}

/// One thing wrong with a submitted policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyProblem {
    /// 1-based line of the submitted text the problem points at; `None` when
    /// it concerns the document as a whole (size, blank) or no line can be
    /// named.
    pub line: Option<u32>,
    /// What is wrong and, where the server can say, what is allowed
    /// (`functions.alloc.min_percent_change must be between 0 and 1000, got 5000`).
    pub message: String,
}

/// 422 body from `POST /api/v1/policy/validate` or an upload whose policy is
/// refused: an `ApiError` (`code` = `invalid_policy`) plus every problem
/// found, so a client that only knows `ApiError` still parses it.
///
/// A TOML syntax error stops parsing and is the only problem reported;
/// otherwise every structural and range problem is reported together. Never
/// assume a single item.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyRejected {
    /// One sentence summing up (`The policy has 3 problems.`).
    pub error: String,
    /// `InvalidPolicy`.
    pub code: ApiErrorCode,
    /// Never empty. Ordered by line, whole-document problems first.
    pub problems: Vec<PolicyProblem>,
}

/// The answer for a report: the judged families of its diff against the
/// baseline (when there is one) and the policy's budgets, together. The
/// top-level `verdict` of the diff body (`hotpath cloud diff` exits 0 for
/// `judged && !regressed`, 1 for everything else) and of `UploadCreated`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
    /// Whether anything was judged: the diff was compared, or the policy has
    /// budget rules and head could be read. `false` for a head that does not
    /// parse, and for a report with no comparable baseline under a policy
    /// without budgets.
    pub judged: bool,
    /// A judged family regressed or a budget is broken:
    /// `regressions > 0 || budgets_broken > 0`.
    pub regressed: bool,
    /// Regressions of judged families; 0 when nothing was compared.
    pub regressions: u64,
    /// Improvements of judged families; 0 when nothing was compared.
    pub improvements: u64,
    /// Broken budget checks; 0 when head was not read.
    pub budgets_broken: u64,
}

#[cfg(test)]
mod tests {
    #[test]
    fn report_size_limit_is_inclusive() {
        use crate::json::cloud_api::{check_report_size, MAX_REPORT_BYTES};
        assert!(check_report_size(MAX_REPORT_BYTES).is_ok());
        let err = check_report_size(MAX_REPORT_BYTES + 1).unwrap_err();
        assert!(err.contains("5242881 bytes"), "{err}");
        assert!(err.contains("split the benchmark"), "{err}");
    }

    use crate::json::cloud_api::{
        normalize_base_url, validate_benchmark_name, ApiError, ApiErrorCode, CommentOutcome,
        PolicyProblem, PolicyRejected, PolicyValidation, UploadCreated, DEFAULT_BASE_URL,
    };

    #[test]
    fn validate_benchmark_name_rule() {
        for ok in [
            "default",
            "ci",
            "timing-linux",
            "api_latency",
            "v0.25",
            "timing.linux",
        ] {
            assert!(
                validate_benchmark_name(ok).is_ok(),
                "{ok:?} should be valid"
            );
        }
        for bad in ["a/b", "a b", "..", ".", "x?y", "ünïcode", ""] {
            assert!(
                validate_benchmark_name(bad).is_err(),
                "{bad:?} should be invalid"
            );
        }
        assert!(validate_benchmark_name(&"a".repeat(64)).is_ok());
        assert!(validate_benchmark_name(&"a".repeat(65)).is_err());

        let err = validate_benchmark_name("a/b").unwrap_err().to_string();
        assert!(
            err.contains("[A-Za-z0-9._-]"),
            "message names the rule: {err}"
        );
    }

    #[test]
    fn normalize_base_url_rules() {
        assert_eq!(normalize_base_url(None), DEFAULT_BASE_URL);
        assert_eq!(normalize_base_url(Some("   ".into())), DEFAULT_BASE_URL);
        assert_eq!(
            normalize_base_url(Some(" http://localhost:3000/// ".into())),
            "http://localhost:3000"
        );
        assert_eq!(
            normalize_base_url(Some("https://staging.hotpath.rs".into())),
            "https://staging.hotpath.rs"
        );
    }

    #[test]
    fn api_error_parses_known_unknown_and_missing_codes() {
        let err: ApiError =
            serde_json::from_str(r#"{"error":"nope","code":"invalid_token","later":1}"#).unwrap();
        assert_eq!(err.error, "nope");
        assert_eq!(err.code, ApiErrorCode::InvalidToken);

        let newer: ApiError =
            serde_json::from_str(r#"{"error":"nope","code":"quota_exceeded"}"#).unwrap();
        assert_eq!(newer.code, ApiErrorCode::Unknown);

        let bare: ApiError = serde_json::from_str(r#"{"error":"boom"}"#).unwrap();
        assert_eq!(bare.code, ApiErrorCode::Unknown);

        assert_eq!(
            serde_json::to_string(&ApiError {
                error: "Benchmark ci not found.".into(),
                code: ApiErrorCode::NotFound,
            })
            .unwrap(),
            r#"{"error":"Benchmark ci not found.","code":"not_found"}"#
        );
    }

    const UPLOAD_DASHBOARD_URL: &str =
        "https://hotpath.rs/app/repos/a/b/benchmarks/meta/reports/r1/diff";
    const POLICY_URL: &str =
        "https://github.com/a/b/blob/3f1c000000000000000000000000000000000000/hotpath/policy.toml";

    #[test]
    fn upload_created_parses_with_and_without_comment() {
        let full: UploadCreated = serde_json::from_str(&format!(
            r#"{{"id":"r1","repository":"a/b","benchmark":"meta","baseline":"r0",
                "comment":{{"url":"https://github.com/c/1","error":"the report does not parse"}},
                "verdict":{{"judged":true,"regressed":false,"regressions":0,"improvements":2,"budgets_broken":0}},
                "policy_path":"hotpath/policy.toml","policy_url":"{POLICY_URL}",
                "dashboard_url":"{UPLOAD_DASHBOARD_URL}","later":true}}"#
        ))
        .unwrap();
        assert_eq!(full.baseline.as_deref(), Some("r0"));
        assert_eq!(full.comment.url.as_deref(), Some("https://github.com/c/1"));
        assert_eq!(
            full.comment.error.as_deref(),
            Some("the report does not parse")
        );
        assert_eq!(full.verdict.improvements, 2);

        let bare: UploadCreated = serde_json::from_str(&format!(
            r#"{{"id":"r1","repository":"a/b","benchmark":"meta",
                "verdict":{{"judged":false,"regressed":false,"regressions":0,"improvements":0,"budgets_broken":0}},
                "policy_path":"hotpath/policy.toml","policy_url":"{POLICY_URL}",
                "dashboard_url":"{UPLOAD_DASHBOARD_URL}"}}"#
        ))
        .unwrap();
        assert_eq!(bare.baseline, None);
        assert_eq!(bare.comment, CommentOutcome::default());
    }

    #[test]
    fn upload_created_names_the_policy_that_judged() {
        let body = format!(
            r#"{{"id":"r1","repository":"a/b","benchmark":"meta","comment":{{}},"verdict":{{"judged":true,"regressed":false,"regressions":0,"improvements":0,"budgets_broken":0}},"policy_path":"hotpath/policy.toml","policy_url":"{POLICY_URL}","dashboard_url":"{UPLOAD_DASHBOARD_URL}"}}"#
        );
        let created: UploadCreated = serde_json::from_str(&body).unwrap();
        assert_eq!(created.policy_path, "hotpath/policy.toml");
        assert_eq!(created.policy_url, POLICY_URL);
        assert_eq!(serde_json::to_string(&created).unwrap(), body);
    }

    #[test]
    fn upload_created_requires_the_verdict() {
        let no_verdict = format!(
            r#"{{"id":"r1","repository":"a/b","benchmark":"meta","dashboard_url":"{UPLOAD_DASHBOARD_URL}"}}"#
        );
        assert!(serde_json::from_str::<UploadCreated>(&no_verdict).is_err());
        let no_url = r#"{"id":"r1","repository":"a/b","benchmark":"meta","verdict":{"judged":true,"regressed":false,"regressions":0,"improvements":0,"budgets_broken":0}}"#;
        assert!(serde_json::from_str::<UploadCreated>(no_url).is_err());
    }

    #[test]
    fn policy_validation_round_trips() {
        let body = r#"{"source":"[functions]\n"}"#;
        let request: PolicyValidation = serde_json::from_str(body).unwrap();
        assert_eq!(
            request,
            PolicyValidation {
                source: "[functions]\n".into(),
            }
        );
        assert_eq!(serde_json::to_string(&request).unwrap(), body);
    }

    #[test]
    fn policy_rejected_round_trips_and_parses_as_api_error() {
        let body = r#"{"error":"The policy has 3 problems.","code":"invalid_policy","problems":[{"line":null,"message":"the policy is larger than 65536 bytes"},{"line":3,"message":"unknown key `functions.timing.min_percent`"},{"line":7,"message":"functions.alloc.min_percent_change must be between 0 and 1000, got 5000"}]}"#;
        let rejected: PolicyRejected = serde_json::from_str(body).unwrap();
        assert_eq!(
            rejected,
            PolicyRejected {
                error: "The policy has 3 problems.".into(),
                code: ApiErrorCode::InvalidPolicy,
                problems: vec![
                    PolicyProblem {
                        line: None,
                        message: "the policy is larger than 65536 bytes".into(),
                    },
                    PolicyProblem {
                        line: Some(3),
                        message: "unknown key `functions.timing.min_percent`".into(),
                    },
                    PolicyProblem {
                        line: Some(7),
                        message:
                            "functions.alloc.min_percent_change must be between 0 and 1000, got 5000"
                                .into(),
                    },
                ],
            }
        );
        assert_eq!(serde_json::to_string(&rejected).unwrap(), body);

        let plain: ApiError = serde_json::from_str(body).unwrap();
        assert_eq!(plain.code, ApiErrorCode::InvalidPolicy);
        assert_eq!(plain.error, "The policy has 3 problems.");
    }
}