rustypipe/
report.rs

1//! # Error reporting
2//!
3//! Due to the instability of the Innertube API, RustyPipe may not be able to parse
4//! every item from every YouTube response. To allow for easy debugging, RustyPipe
5//! can create and store error reports.
6//!
7//! These reports contain information about the RustyPipe client, the performed
8//! operation, the request sent to YouTube and the received response data.
9//!
10//! With the report data the error can be reproduced and RustyPipe can be patched to
11//! handle YouTube's changes to the response model.
12//!
13//! By default, RustyPipe stores the reports as JSON files
14//! (e.g `rustypipe_reports/2022-11-05_22-58-59_ERR`).
15//!
16//! By implementing the [`Reporter`] trait you can handle error reports in other ways
17//! (e.g. store them in a database, send them via mail, log to Sentry, etc).
18
19use std::{
20    collections::BTreeMap,
21    fs::File,
22    io::Error,
23    path::{Path, PathBuf},
24};
25
26use serde::{Deserialize, Serialize};
27use time::{macros::format_description, OffsetDateTime};
28use tracing::error;
29
30use crate::{deobfuscate::DeobfData, param::Language, util};
31
32pub(crate) const DEFAULT_REPORT_DIR: &str = "rustypipe_reports";
33
34const FILENAME_FORMAT: &[time::format_description::FormatItem] =
35    format_description!("[year]-[month]-[day]_[hour]-[minute]-[second]");
36
37/// RustyPipe error report
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[non_exhaustive]
40pub struct Report<'a> {
41    /// Information about the RustyPipe client
42    pub info: RustyPipeInfo<'a>,
43    /// Severity of the report
44    pub level: Level,
45    /// RustyPipe operation (e.g. `get_player`)
46    pub operation: &'a str,
47    /// Error (if occurred)
48    pub error: Option<String>,
49    /// Detailed error/warning messages
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub msgs: Vec<String>,
52    /// Deobfuscation data (only for player requests)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub deobf_data: Option<DeobfData>,
55    /// HTTP request data
56    pub http_request: HTTPRequest<'a>,
57}
58
59/// Information about the RustyPipe client
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[non_exhaustive]
62pub struct RustyPipeInfo<'a> {
63    /// Rust package name (`rustypipe`)
64    pub package: &'a str,
65    /// Package version (`0.1.0`)
66    pub version: &'a str,
67    /// Date/Time when the event occurred
68    #[serde(with = "time::serde::rfc3339")]
69    pub date: OffsetDateTime,
70    /// YouTube content language
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub language: Option<Language>,
73    /// RustyPipe Botguard version (`rustypipe-botguard 0.1.1`)
74    pub botguard_version: Option<&'a str>,
75}
76
77/// Reported HTTP request data
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[non_exhaustive]
80pub struct HTTPRequest<'a> {
81    /// Request URL
82    pub url: &'a str,
83    /// HTTP method
84    pub method: &'a str,
85    /// HTTP request header
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub req_header: Option<BTreeMap<&'a str, String>>,
88    /// HTTP request body
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub req_body: Option<String>,
91    /// HTTP response status code
92    pub status: u16,
93    /// HTTP response body
94    pub resp_body: String,
95}
96
97/// Severity of the report
98#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub enum Level {
100    /// **Debug**: Operation successful, report generation was forced by setting
101    /// ``.report(true)``
102    DBG,
103    /// **Warning**: Operation successful, but some parts could not be deserialized
104    WRN,
105    /// **Error**: Operation failed
106    ERR,
107}
108
109impl<'a> RustyPipeInfo<'a> {
110    pub(crate) fn new(language: Option<Language>, botguard_version: Option<&'a str>) -> Self {
111        Self {
112            package: env!("CARGO_PKG_NAME"),
113            version: crate::VERSION,
114            date: util::now_sec(),
115            language,
116            botguard_version,
117        }
118    }
119}
120
121/// Trait used to abstract the report storage behavior, so you can handle RustyPipe's
122/// error reports in your preferred way.
123pub trait Reporter: Sync + Send {
124    /// Store a RustyPipe error report
125    fn report(&self, report: &Report);
126}
127
128/// [`Reporter`] implementation that writes reports as JSON files to the given folder
129pub struct FileReporter {
130    path: PathBuf,
131}
132
133impl FileReporter {
134    /// Create a new reporter that stores error reports in the given folder
135    pub fn new<P: AsRef<Path>>(path: P) -> Self {
136        Self {
137            path: path.as_ref().to_path_buf(),
138        }
139    }
140
141    fn _report(&self, report: &Report) -> Result<(), String> {
142        let report_path = get_report_path(&self.path, report, "json").map_err(|e| e.to_string())?;
143        let file = File::create(&report_path).map_err(|e| e.to_string())?;
144        serde_json::to_writer_pretty(&file, &report).map_err(|e| e.to_string())?;
145        tracing::warn!(
146            "created report: {}",
147            report_path.to_str().unwrap_or_default()
148        );
149        Ok(())
150    }
151}
152
153impl Default for FileReporter {
154    fn default() -> Self {
155        Self {
156            path: Path::new(DEFAULT_REPORT_DIR).to_path_buf(),
157        }
158    }
159}
160
161impl Reporter for FileReporter {
162    fn report(&self, report: &Report) {
163        self._report(report)
164            .unwrap_or_else(|e| error!("Could not store report file. Err: {}", e));
165    }
166}
167
168fn get_report_path(root: &Path, report: &Report, ext: &str) -> Result<PathBuf, Error> {
169    if !root.is_dir() {
170        std::fs::create_dir_all(root)?;
171    }
172
173    let filename_prefix = format!(
174        "{}_{:?}",
175        report.info.date.format(FILENAME_FORMAT).unwrap_or_default(),
176        report.level
177    );
178
179    let mut report_path = root.to_path_buf();
180    report_path.push(format!("{filename_prefix}.{ext}"));
181
182    // ensure unique filename
183    for i in 1..u32::MAX {
184        if report_path.exists() {
185            report_path = root.to_path_buf();
186            report_path.push(format!("{filename_prefix}_{i}.{ext}"));
187        } else {
188            break;
189        }
190    }
191
192    Ok(report_path)
193}