Skip to main content

runnerd_core/api/
insights.rs

1//! Insights as clients read them: what got slower, what is flaky, what nears its memory cap, what memory pressure
2//! throttled and how long jobs wait, computed from the job history (ADR 0051); the daily digest; and what the
3//! daemon's notifications carry. Times are seconds since the Unix epoch, durations seconds, sizes bytes.
4
5use serde::{Deserialize, Serialize};
6
7use super::RunnerFailure;
8use super::jobs::JobSummary;
9
10mod shown;
11
12/// The window `GET /v1/insights` reads by default, in days.
13pub const INSIGHT_DAYS: u32 = 14;
14
15/// The longest window `GET /v1/insights` reads, in days.
16pub const INSIGHT_DAYS_MAX: u32 = 90;
17
18/// `GET /v1/insights` query parameters.
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub struct InsightsQuery {
21    /// Days of history to read; default [`INSIGHT_DAYS`], at most [`INSIGHT_DAYS_MAX`].
22    pub days: Option<u32>,
23}
24
25/// `GET /v1/insights`: what the history says about the window.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Insights {
28    /// The start of the window.
29    pub since: u64,
30    /// When it was read: the end of the window.
31    pub until: u64,
32    /// Warnings first, then by repository, workflow and job.
33    pub insights: Vec<Insight>,
34    /// Each repository's waits this week and the week before, by repository. Always the last 14 days, whatever the
35    /// window.
36    pub waits: Vec<RepositoryWaits>,
37}
38
39/// How much an insight matters.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum Severity {
43    /// Worth acting on: notifications send these.
44    Warning,
45    /// Worth knowing.
46    Notice,
47}
48
49/// One thing the history says, about one job or one repository.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Insight {
52    /// The same while it holds, such as `slower:halyard/api/CI/test`.
53    pub key: String,
54    /// How much it matters.
55    pub severity: Severity,
56    /// `owner/name`.
57    pub repository: String,
58    /// The workflow's name; `None` for a repository's waits, or when unknown.
59    pub workflow: Option<String>,
60    /// The job's name; `None` for a repository's waits.
61    pub job: Option<String>,
62    /// What it is, in one line.
63    pub sentence: String,
64    /// What it is, with its figures.
65    #[serde(flatten)]
66    pub kind: InsightKind,
67    /// The runs that show it, newest first, at most 5.
68    pub runs: Vec<RunLink>,
69}
70
71/// What an insight is, tagged by `kind`.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "kebab-case", tag = "kind")]
74pub enum InsightKind {
75    /// A job's successful runs on its busiest branch take longer than they did, and stayed so.
76    Slower {
77        /// The branch.
78        branch: Option<String>,
79        /// The median run time before, in seconds.
80        before: u64,
81        /// The median run time of the last runs, in seconds.
82        now: u64,
83        /// The first slow run after which no two runs in a row were fast.
84        since: Option<RunLink>,
85    },
86    /// A job failed and then passed on a re-run of the same run.
87    Flaky {
88        /// Runs in which it did.
89        flaky: u32,
90        /// The job's runs in the window.
91        total: u32,
92    },
93    /// A job's memory peaks near its profile's cap.
94    NearCap {
95        /// The profile.
96        profile: String,
97        /// The 95th percentile of its last runs' memory peaks.
98        peak: u64,
99        /// The profile's `memory`.
100        cap: u64,
101        /// What auto sizing does about it.
102        auto: AutoNote,
103    },
104    /// A job's runs that write to the build cache find almost nothing in it: its cache keys differ from run to run.
105    ColdCache {
106        /// The branch those runs are on.
107        branch: Option<String>,
108        /// Lookups that found an entry, over those runs.
109        hits: u64,
110        /// Lookups over those runs.
111        lookups: u64,
112    },
113    /// The kernel throttled a repository's jobs' downloads for memory pressure (`JobMetrics::sock_throttled`):
114    /// the host, or a job at its cap, was short of memory.
115    Throttled {
116        /// Runs it throttled.
117        throttled: u32,
118        /// The repository's runs in the window.
119        total: u32,
120    },
121    /// A repository's jobs wait longer this week than the week before.
122    Waits {
123        /// The 95th percentile of waits the week before, in seconds.
124        before: u64,
125        /// The 95th percentile of waits this week, in seconds.
126        now: u64,
127    },
128}
129
130/// What auto sizing does about a job near its cap.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "kebab-case")]
133pub enum AutoNote {
134    /// It raises the profile's `memory` when a run meets the cap.
135    Raises,
136    /// The profile is at its `max_memory`: it cannot.
137    AtMax,
138    /// It is off.
139    Off,
140}
141
142/// One run of a job, to open in the history or on GitHub.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct RunLink {
145    /// GitHub's job ID, the history's too.
146    pub job: u64,
147    /// The workflow run.
148    pub run_id: u64,
149    /// The run's attempt.
150    pub run_attempt: u32,
151    /// The branch.
152    pub branch: Option<String>,
153    /// The job's page on GitHub.
154    pub url: Option<String>,
155    /// When it finished.
156    pub at: u64,
157}
158
159/// A repository's waits this week against the week before.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct RepositoryWaits {
162    /// `owner/name`.
163    pub repository: String,
164    /// The jobs that started in the last 7 days.
165    pub now: WeekWaits,
166    /// The jobs that started in the 7 days before.
167    pub before: WeekWaits,
168}
169
170/// The waits of the jobs that started in a week, from queued to started.
171#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub struct WeekWaits {
173    /// Every job.
174    pub all: WaitSpread,
175    /// The jobs that found a runner already started: a warm runner (ADR 0050).
176    pub warm: WaitSpread,
177    /// The jobs whose runner started after they were queued.
178    pub cold: WaitSpread,
179}
180
181/// How long some jobs waited.
182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
183pub struct WaitSpread {
184    /// How many.
185    pub jobs: u32,
186    /// The median, in seconds; `None` without jobs.
187    pub p50: Option<u64>,
188    /// The 95th percentile by nearest rank, in seconds; `None` without jobs.
189    pub p95: Option<u64>,
190}
191
192/// `GET /v1/digest`: the last 24 hours.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct Digest {
195    /// 24 hours before `until`.
196    pub since: u64,
197    /// When it was read.
198    pub until: u64,
199    /// Jobs that finished: completed, or lost with their runner.
200    pub finished: u64,
201    /// Of those, the ones that failed, timed out or were lost.
202    pub failed: u64,
203    /// The insights that hold now and did not 24 hours ago.
204    pub new: Vec<Insight>,
205    /// The three longest waits of the jobs that started, longest first.
206    pub waits: Vec<LongWait>,
207    /// What the month's job minutes would have cost on GitHub's runners (ADR 0072); `None` when the history could
208    /// not say.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub savings: Option<super::SavedWindow>,
211}
212
213/// A job that waited.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct LongWait {
216    /// GitHub's job ID.
217    pub job: u64,
218    /// `owner/name`.
219    pub repository: String,
220    /// The workflow's name.
221    pub workflow: Option<String>,
222    /// The job's name.
223    pub name: String,
224    /// Seconds from queued to started.
225    pub waited: u64,
226    /// The job's page on GitHub.
227    pub url: Option<String>,
228}
229
230/// What a notification target is sent, and the events `[notify]` targets choose from.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
232#[serde(rename_all = "kebab-case")]
233pub enum NotifyEvent {
234    /// A job failed or timed out.
235    Failed,
236    /// A job's runner vanished before GitHub said the job completed.
237    Lost,
238    /// A runner could not be registered or started, and its repository is held back.
239    Runner,
240    /// A new warning insight.
241    Insights,
242    /// The daily digest.
243    Digest,
244    /// `runnerctl notify test`; not one a target chooses.
245    Test,
246}
247
248/// How urgent a notification is, as ntfy ranks it.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "kebab-case")]
251pub enum Priority {
252    /// ntfy's 2: no sound.
253    Low,
254    /// ntfy's 3.
255    Default,
256    /// ntfy's 4: a longer vibration.
257    High,
258}
259
260/// What a webhook target is posted, as JSON; an ntfy target gets its title, message, priority and link.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct Notification {
263    /// What it is about.
264    pub event: NotifyEvent,
265    /// One short line.
266    pub title: String,
267    /// A few lines.
268    pub message: String,
269    /// What to open: a job's page on GitHub, or nothing.
270    pub url: Option<String>,
271    /// How urgent.
272    pub priority: Priority,
273    /// The jobs it is about: failed or lost.
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    pub jobs: Vec<JobSummary>,
276    /// The runners that could not start.
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub runners: Vec<RunnerFailure>,
279    /// The insights it is about.
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub insights: Vec<Insight>,
282    /// The digest.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub digest: Option<Digest>,
285}
286
287/// `POST /v1/notify/test` query parameters.
288#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct NotifyTestQuery {
290    /// Only the target with this name; default every one.
291    pub target: Option<String>,
292}
293
294/// `POST /v1/notify/test`: what each target did with a test message.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct NotifyTest {
297    /// In the config's order.
298    pub targets: Vec<Delivery>,
299}
300
301/// What one target did with a message.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct Delivery {
304    /// The target's name.
305    pub target: String,
306    /// Why it was not delivered: an HTTP status or a connection error, never the URL. `None` when it was.
307    pub error: Option<String>,
308}