Skip to main content

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"], &params).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"], &params).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"], &params).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"], &params).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}