dinio_sdk/api/stats.rs
1//! `/api/v1/stats` (`../core/src/api/stats.ts`): Relay telemetry for the caller's zones
2//! and client identities. The platform views and the Relay filters (`relay_id`, `region`,
3//! grouping by them) are Admin only and not in the SDK.
4
5use std::fmt;
6use std::str::FromStr;
7
8use reqwest::Method;
9use serde::de::DeserializeOwned;
10use uuid::Uuid;
11
12use crate::client::Client;
13use crate::error::{Error, Result};
14use crate::models::{StatsSeriesResult, StatsTopList, ZoneNamesStats, ZoneNxStats};
15use crate::time::Timestamp;
16
17/// Step of a series. [`Auto`](Self::Auto) (Core's default) picks the smallest step with
18/// at most 300 points; a range may have at most 2000 points.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum StatsStep {
21 /// Core picks.
22 #[default]
23 Auto,
24 /// 10 minutes, one Relay window.
25 TenMinutes,
26 /// 30 minutes.
27 ThirtyMinutes,
28 /// 1 hour.
29 Hour,
30 /// 3 hours.
31 ThreeHours,
32 /// 6 hours.
33 SixHours,
34 /// 12 hours.
35 TwelveHours,
36 /// 1 day.
37 Day,
38 /// 7 days.
39 Week,
40}
41
42impl StatsStep {
43 /// The value Core takes (`auto`, `10m`, `1h`, `1d`, …).
44 pub fn as_wire(self) -> &'static str {
45 match self {
46 Self::Auto => "auto",
47 Self::TenMinutes => "10m",
48 Self::ThirtyMinutes => "30m",
49 Self::Hour => "1h",
50 Self::ThreeHours => "3h",
51 Self::SixHours => "6h",
52 Self::TwelveHours => "12h",
53 Self::Day => "1d",
54 Self::Week => "7d",
55 }
56 }
57}
58
59impl fmt::Display for StatsStep {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(self.as_wire())
62 }
63}
64
65impl FromStr for StatsStep {
66 type Err = Error;
67
68 /// `auto`, `10m`, `30m`, `1h`, `3h`, `6h`, `12h`, `1d`, `7d`.
69 fn from_str(text: &str) -> Result<Self> {
70 let all = [
71 Self::Auto,
72 Self::TenMinutes,
73 Self::ThirtyMinutes,
74 Self::Hour,
75 Self::ThreeHours,
76 Self::SixHours,
77 Self::TwelveHours,
78 Self::Day,
79 Self::Week,
80 ];
81 let text = text.trim().to_ascii_lowercase();
82 all.into_iter()
83 .find(|s| s.as_wire() == text)
84 .ok_or_else(|| {
85 Error::InvalidInput(format!(
86 "unknown step {text:?} (auto, 10m, 30m, 1h, 3h, 6h, 12h, 1d, 7d)"
87 ))
88 })
89 }
90}
91
92/// How series are split (`group_by`). Each route takes only some of them (see the
93/// method); Core answers [`ErrorCode::Validation`](crate::ErrorCode::Validation) for
94/// others.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum StatsGroup {
97 /// One series.
98 None,
99 /// Per rcode (`NOERROR`, `NXDOMAIN`, …).
100 Rcode,
101 /// Per query type.
102 Qtype,
103 /// Per client country.
104 Geo,
105 /// Per queried name.
106 Name,
107 /// Per client event type (`normal`, `sinkhole` = blocked, `negative`, …).
108 EventType,
109 /// Per client identity.
110 Client,
111}
112
113impl StatsGroup {
114 /// The value Core takes (`none`, `rcode`, `qtype`, `geo`, `name`, `event_type`,
115 /// `client`).
116 pub fn as_wire(self) -> &'static str {
117 match self {
118 Self::None => "none",
119 Self::Rcode => "rcode",
120 Self::Qtype => "qtype",
121 Self::Geo => "geo",
122 Self::Name => "name",
123 Self::EventType => "event_type",
124 Self::Client => "client",
125 }
126 }
127}
128
129impl fmt::Display for StatsGroup {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 f.write_str(self.as_wire())
132 }
133}
134
135impl FromStr for StatsGroup {
136 type Err = Error;
137
138 /// The wire value; `event-type` is accepted for `event_type`.
139 fn from_str(text: &str) -> Result<Self> {
140 match text.trim().to_ascii_lowercase().replace('-', "_").as_str() {
141 "none" => Ok(Self::None),
142 "rcode" => Ok(Self::Rcode),
143 "qtype" => Ok(Self::Qtype),
144 "geo" => Ok(Self::Geo),
145 "name" => Ok(Self::Name),
146 "event_type" => Ok(Self::EventType),
147 "client" => Ok(Self::Client),
148 _ => Err(Error::InvalidInput(format!(
149 "unknown grouping {text:?} (none, rcode, qtype, geo, name, event_type, client)"
150 ))),
151 }
152 }
153}
154
155/// What series point values are (`mode`).
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum StatsMode {
158 /// Count per step (Core's default).
159 Count,
160 /// Per second.
161 Rate,
162 /// Share of all series at that step (0–1).
163 Share,
164}
165
166impl StatsMode {
167 /// The value Core takes.
168 pub fn as_wire(self) -> &'static str {
169 match self {
170 Self::Count => "count",
171 Self::Rate => "rate",
172 Self::Share => "share",
173 }
174 }
175}
176
177/// Range and shape of a series query. Core aligns the range to 10 minutes; data is kept
178/// 90 days and a range may span at most 92 days.
179#[derive(Debug, Clone, Default)]
180pub struct StatsSeriesQuery {
181 /// Start. Core's default: 24 hours before `to`.
182 pub from: Option<Timestamp>,
183 /// End. Core's default: now.
184 pub to: Option<Timestamp>,
185 /// Step; Core's default `auto`.
186 pub step: Option<StatsStep>,
187 /// Also give totals of the preceding range of equal length (`previous_total`,
188 /// `change`).
189 pub compare: bool,
190 /// Point values; Core's default `count`.
191 pub mode: Option<StatsMode>,
192 /// `Some(false)`: leave out steps without data instead of `0` (Core's default
193 /// `true`).
194 pub fill: Option<bool>,
195 /// Series kept, 1–100 (Core's default 20); the rest is merged into `__other`.
196 pub limit: Option<u32>,
197}
198
199/// Range and size of a top-list query.
200#[derive(Debug, Clone, Default)]
201pub struct StatsTopQuery {
202 /// Start. Core's default: 24 hours before `to`.
203 pub from: Option<Timestamp>,
204 /// End. Core's default: now.
205 pub to: Option<Timestamp>,
206 /// Items, 1–100 (Core's default 20).
207 pub limit: Option<u32>,
208}
209
210/// Which client identities a client statistic covers.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
212pub enum ClientScope {
213 /// All of the caller's identities.
214 #[default]
215 Mine,
216 /// One identity (uuid): the caller's own, or any as an Admin.
217 Client(Uuid),
218 /// All identities of a user: the caller, or anyone as an Admin.
219 User(Uuid),
220}
221
222type Query = Vec<(&'static str, String)>;
223
224fn range_query(from: &Option<Timestamp>, to: &Option<Timestamp>) -> Result<Query> {
225 let mut query = Vec::new();
226 if let Some(from) = from {
227 query.push(("from", from.to_query()?));
228 }
229 if let Some(to) = to {
230 query.push(("to", to.to_query()?));
231 }
232 Ok(query)
233}
234
235fn series_query(q: &StatsSeriesQuery, group_by: Option<StatsGroup>) -> Result<Query> {
236 let mut query = range_query(&q.from, &q.to)?;
237 if let Some(step) = q.step {
238 query.push(("step", step.as_wire().into()));
239 }
240 if q.compare {
241 query.push(("compare", "previous".into()));
242 }
243 if let Some(mode) = q.mode {
244 query.push(("mode", mode.as_wire().into()));
245 }
246 if let Some(fill) = q.fill {
247 query.push(("fill", fill.to_string()));
248 }
249 if let Some(limit) = q.limit {
250 query.push(("limit", limit.to_string()));
251 }
252 if let Some(group) = group_by {
253 query.push(("group_by", group.as_wire().into()));
254 }
255 Ok(query)
256}
257
258fn top_query(q: &StatsTopQuery) -> Result<Query> {
259 let mut query = range_query(&q.from, &q.to)?;
260 if let Some(limit) = q.limit {
261 query.push(("limit", limit.to_string()));
262 }
263 Ok(query)
264}
265
266fn scope_query(query: &mut Query, scope: ClientScope) {
267 match scope {
268 ClientScope::Mine => {}
269 ClientScope::Client(id) => query.push(("client_id", id.to_string())),
270 ClientScope::User(id) => query.push(("user_id", id.to_string())),
271 }
272}
273
274/// Statistics routes, from [`Client::stats`].
275///
276/// Zone views need access to the zone (a zone key sees its zone); client views need a
277/// personal key (a zone key gets [`ErrorCode::Forbidden`](crate::ErrorCode::Forbidden)).
278/// Every time in a response is in milliseconds.
279///
280/// # Errors
281///
282/// Every method can fail with:
283///
284/// * [`ErrorCode::TelemetryDisabled`](crate::ErrorCode::TelemetryDisabled) if Core runs
285/// without telemetry.
286/// * [`ErrorCode::Validation`](crate::ErrorCode::Validation) for a bad range (`from` not
287/// before `to`, over 92 days, over 2000 points) or a grouping the route does not take.
288/// * [`Error::InvalidInput`] for a time before 1970.
289pub struct Stats<'a> {
290 client: &'a Client,
291}
292
293impl<'a> Stats<'a> {
294 pub(crate) fn new(client: &'a Client) -> Self {
295 Self { client }
296 }
297
298 /// Queries to the zone over time. `group_by`: `Rcode` (Core's default, NXDOMAIN
299 /// included), `Qtype`, `Geo` (country), `Name` or `None`; with `name`, only that name
300 /// (then without NXDOMAIN).
301 ///
302 /// `GET /api/v1/stats/zones/:ref/queries`
303 ///
304 /// # Errors
305 ///
306 /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
307 ///
308 /// # Examples
309 ///
310 /// ```no_run
311 /// use std::time::{Duration, SystemTime};
312 /// use dinio_sdk::api::{StatsGroup, StatsSeriesQuery, StatsStep};
313 ///
314 /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
315 /// let week = StatsSeriesQuery {
316 /// from: Some((SystemTime::now() - Duration::from_secs(7 * 86_400)).into()),
317 /// step: Some(StatsStep::Day),
318 /// ..Default::default()
319 /// };
320 /// let result = client
321 /// .stats()
322 /// .zone_queries("example.com", Some(StatsGroup::Qtype), None, &week)
323 /// .await?;
324 /// for series in &result.series {
325 /// println!("{} {}", series.label, series.total);
326 /// }
327 /// # Ok(())
328 /// # }
329 /// ```
330 pub async fn zone_queries(
331 &self,
332 zone: &str,
333 group_by: Option<StatsGroup>,
334 name: Option<&str>,
335 query: &StatsSeriesQuery,
336 ) -> Result<StatsSeriesResult> {
337 let mut params = series_query(query, group_by)?;
338 if let Some(name) = name {
339 params.push(("name", name.to_owned()));
340 }
341 self.get(&["zones", zone, "queries"], ¶ms).await
342 }
343
344 /// NXDOMAIN answers under the zone over time, and an estimate of how many distinct
345 /// non-existent names were asked.
346 ///
347 /// `GET /api/v1/stats/zones/:ref/nx`
348 ///
349 /// # Errors
350 ///
351 /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
352 pub async fn zone_nx(&self, zone: &str, query: &StatsSeriesQuery) -> Result<ZoneNxStats> {
353 self.get(&["zones", zone, "nx"], &series_query(query, None)?)
354 .await
355 }
356
357 /// The zone's busiest names, with their rcode and qtype breakdown.
358 ///
359 /// `GET /api/v1/stats/zones/:ref/names`
360 ///
361 /// # Errors
362 ///
363 /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
364 pub async fn zone_names(&self, zone: &str, query: &StatsTopQuery) -> Result<ZoneNamesStats> {
365 self.get(&["zones", zone, "names"], &top_query(query)?)
366 .await
367 }
368
369 /// The most asked non-existent names under the zone (typos, scanners, missing
370 /// records).
371 ///
372 /// `GET /api/v1/stats/zones/:ref/nx-names`
373 ///
374 /// # Errors
375 ///
376 /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
377 pub async fn zone_nx_names(&self, zone: &str, query: &StatsTopQuery) -> Result<StatsTopList> {
378 self.get(&["zones", zone, "nx-names"], &top_query(query)?)
379 .await
380 }
381
382 /// Client traffic by event type; the share of key `sinkhole` is the blocked share.
383 /// `group_by`: `EventType` (Core's default), `Client` (labelled with `identify`) or
384 /// `None`.
385 ///
386 /// `GET /api/v1/stats/clients/events`
387 ///
388 /// # Errors
389 ///
390 /// * [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if the identity does not
391 /// exist.
392 /// * [`ErrorCode::Forbidden`](crate::ErrorCode::Forbidden) for another user's identity
393 /// or user, or with a zone key.
394 ///
395 /// # Examples
396 ///
397 /// ```no_run
398 /// use dinio_sdk::api::{ClientScope, StatsSeriesQuery};
399 ///
400 /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
401 /// let phone = dinio_sdk::ops::find_client_identity(client, "phone").await?;
402 /// let events = client
403 /// .stats()
404 /// .client_events(ClientScope::Client(phone.uuid), None, &StatsSeriesQuery::default())
405 /// .await?;
406 /// let blocked = events.series.iter().find(|s| s.key == "sinkhole").map_or(0.0, |s| s.share);
407 /// # Ok(())
408 /// # }
409 /// ```
410 pub async fn client_events(
411 &self,
412 scope: ClientScope,
413 group_by: Option<StatsGroup>,
414 query: &StatsSeriesQuery,
415 ) -> Result<StatsSeriesResult> {
416 let mut params = series_query(query, group_by)?;
417 scope_query(&mut params, scope);
418 self.get(&["clients", "events"], ¶ms).await
419 }
420
421 /// Answers to clients by rcode. `group_by`: `Rcode` (Core's default), `Client` or
422 /// `None`.
423 ///
424 /// `GET /api/v1/stats/clients/rcodes`
425 ///
426 /// # Errors
427 ///
428 /// As [`client_events`](Self::client_events).
429 pub async fn client_rcodes(
430 &self,
431 scope: ClientScope,
432 group_by: Option<StatsGroup>,
433 query: &StatsSeriesQuery,
434 ) -> Result<StatsSeriesResult> {
435 let mut params = series_query(query, group_by)?;
436 scope_query(&mut params, scope);
437 self.get(&["clients", "rcodes"], ¶ms).await
438 }
439
440 /// The names most often blocked for clients (sinkhole hits).
441 ///
442 /// `GET /api/v1/stats/clients/blocked`
443 ///
444 /// # Errors
445 ///
446 /// As [`client_events`](Self::client_events).
447 pub async fn client_blocked(
448 &self,
449 scope: ClientScope,
450 query: &StatsTopQuery,
451 ) -> Result<StatsTopList> {
452 let mut params = top_query(query)?;
453 scope_query(&mut params, scope);
454 self.get(&["clients", "blocked"], ¶ms).await
455 }
456
457 async fn get<T: DeserializeOwned>(&self, path: &[&str], params: &Query) -> Result<T> {
458 let mut segments = vec!["stats"];
459 segments.extend_from_slice(path);
460 let request = self.client.request(Method::GET, &segments)?.query(params);
461 Ok(self.client.send(request).await?.0)
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn series_params() {
471 let query = StatsSeriesQuery {
472 from: Some("2026-10-20T00:00:00Z".into()),
473 step: Some(StatsStep::Hour),
474 compare: true,
475 ..Default::default()
476 };
477 let mut params = series_query(&query, Some(StatsGroup::EventType)).unwrap();
478 scope_query(&mut params, ClientScope::Client(Uuid::nil()));
479 let names: Vec<&str> = params.iter().map(|(n, _)| *n).collect();
480 assert_eq!(names, ["from", "step", "compare", "group_by", "client_id"]);
481 assert_eq!(params[2].1, "previous");
482 assert_eq!(params[3].1, "event_type");
483 }
484
485 #[test]
486 fn steps_and_groups_parse() {
487 assert_eq!("1D".parse::<StatsStep>().unwrap(), StatsStep::Day);
488 assert!("2h".parse::<StatsStep>().is_err());
489 assert_eq!(
490 "event-type".parse::<StatsGroup>().unwrap(),
491 StatsGroup::EventType
492 );
493 }
494}