Skip to main content

exception_collector/
reporter.rs

1//! Reporter implementations: GitHub Issue reporting and custom platform reporting.
2//!
3//! Provides two [`ExceptionReporter`] implementations:
4//! - [`GitHubReporter`] — calls `gh` CLI for GitHub Issue creation
5//! - [`CustomPlatformReporter`] — POSTs to a configurable endpoint
6
7use async_trait::async_trait;
8use serde::Serialize;
9
10use crate::{CollectorError, CollectorResult, ExceptionBatch, ExceptionReporter, ReportResult};
11
12// ── GitHubReporter ──────────────────────────────────────────────────────────
13
14/// Reports exceptions by calling the `gh` CLI.
15///
16/// Requires `gh` to be installed and authenticated.
17/// Each new exception becomes a GitHub Issue; duplicates get a comment.
18#[derive(Debug)]
19pub struct GitHubReporter;
20
21impl GitHubReporter {
22    /// Create a new `GitHubReporter`.
23    #[must_use]
24    pub fn new() -> Self {
25        Self
26    }
27
28    /// Execute `gh issue create` and return the issue URL.
29    async fn create_issue(&self, repo: &str, title: &str, body: &str) -> CollectorResult<String> {
30        let output = tokio::process::Command::new("gh")
31            .args([
32                "issue", "create", "--repo", repo, "--title", title, "--body", body,
33            ])
34            .output()
35            .await
36            .map_err(|e| CollectorError::Reporter {
37                reason: format!("failed to run gh issue create: {e}"),
38            })?;
39
40        if !output.status.success() {
41            let stderr = String::from_utf8_lossy(&output.stderr);
42            return Err(CollectorError::Reporter {
43                reason: format!("gh issue create failed: {stderr}"),
44            });
45        }
46
47        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
48        Ok(stdout)
49    }
50
51    /// Execute `gh issue comment` on an existing issue.
52    async fn comment_on_issue(
53        &self,
54        repo: &str,
55        issue_number: &str,
56        body: &str,
57    ) -> CollectorResult<()> {
58        let issue_ref = format!("{repo}#{issue_number}");
59        let output = tokio::process::Command::new("gh")
60            .args(["issue", "comment", &issue_ref, "--body", body])
61            .output()
62            .await
63            .map_err(|e| CollectorError::Reporter {
64                reason: format!("failed to run gh issue comment: {e}"),
65            })?;
66
67        if !output.status.success() {
68            let stderr = String::from_utf8_lossy(&output.stderr);
69            return Err(CollectorError::Reporter {
70                reason: format!("gh issue comment failed: {stderr}"),
71            });
72        }
73
74        Ok(())
75    }
76}
77
78impl Default for GitHubReporter {
79    fn default() -> Self {
80        Self
81    }
82}
83
84#[async_trait]
85impl ExceptionReporter for GitHubReporter {
86    /// Check if `gh` CLI is installed and available in `PATH`.
87    async fn check_available(&self) -> bool {
88        which::which("gh").is_ok()
89    }
90
91    /// Report an exception batch by creating GitHub Issues.
92    ///
93    /// For each record in the batch, creates a new issue.
94    /// Records with `duplicate_of` set get a comment on the existing issue instead.
95    async fn report(&self, batch: &ExceptionBatch) -> CollectorResult<ReportResult> {
96        let repo = match &batch.target {
97            crate::ReportTarget::GitHub { repo, .. } => repo.clone(),
98            crate::ReportTarget::CustomPlatform { .. } => {
99                return Err(CollectorError::Reporter {
100                    reason: "GitHubReporter requires ReportTarget::GitHub".to_string(),
101                });
102            }
103        };
104
105        let mut reported_count = 0usize;
106        let mut errors: Vec<String> = Vec::new();
107
108        for record in &batch.records {
109            if let Some(dup_url) = &record.duplicate_of {
110                let issue_number = dup_url.rsplit('/').next().unwrap_or("unknown");
111
112                let comment_body = format!(
113                    "## 重复出现\n\n此异常仍在发生。\n\n- 组件: {}\n- 最近发生: {}\n",
114                    record.component,
115                    record.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
116                );
117
118                match self
119                    .comment_on_issue(&repo, issue_number, &comment_body)
120                    .await
121                {
122                    Ok(()) => reported_count += 1,
123                    Err(e) => errors.push(format!("comment failed for {dup_url}: {e}")),
124                }
125            } else {
126                let title = format!(
127                    "[{}] {}: {}",
128                    record.component,
129                    record.kind,
130                    truncate_for_title(&record.message, 80)
131                );
132                let body = build_issue_body(record);
133
134                match self.create_issue(&repo, &title, &body).await {
135                    Ok(_url) => reported_count += 1,
136                    Err(e) => errors.push(format!("issue create failed: {e}")),
137                }
138            }
139        }
140
141        let total = batch.records.len();
142        if reported_count == total {
143            Ok(ReportResult::Success)
144        } else if reported_count > 0 {
145            Ok(ReportResult::Partial { reported_count })
146        } else {
147            let reason = errors.join("; ");
148            let retry_at = chrono::Utc::now() + chrono::TimeDelta::minutes(5);
149            Ok(ReportResult::Failed { reason, retry_at })
150        }
151    }
152}
153
154// ── CustomPlatformReporter ─────────────────────────────────────────────────
155
156/// Reports exceptions by sending a `POST` request to a custom platform endpoint.
157///
158/// This is a placeholder implementation intended for integration
159/// with a self-hosted exception collection platform.
160#[derive(Debug, Clone)]
161pub struct CustomPlatformReporter {
162    /// The base URL of the custom platform API.
163    endpoint: String,
164}
165
166impl CustomPlatformReporter {
167    /// Create a new `CustomPlatformReporter` with the given endpoint URL.
168    #[must_use]
169    pub fn new(endpoint: impl Into<String>) -> Self {
170        Self {
171            endpoint: endpoint.into(),
172        }
173    }
174}
175
176/// Lightweight JSON payload for the custom platform API.
177#[derive(Debug, Serialize)]
178struct ExceptionPayload<'a> {
179    component: &'a str,
180    batch_id: &'a str,
181    records: &'a [crate::ExceptionRecord],
182}
183
184#[async_trait]
185impl ExceptionReporter for CustomPlatformReporter {
186    /// Check if the endpoint is configured (non-empty).
187    async fn check_available(&self) -> bool {
188        !self.endpoint.is_empty()
189    }
190
191    /// POST the batch to `{endpoint}/api/exceptions`.
192    async fn report(&self, batch: &ExceptionBatch) -> CollectorResult<ReportResult> {
193        let url = format!("{}/api/exceptions", self.endpoint);
194        let payload = ExceptionPayload {
195            component: &batch.component,
196            batch_id: &batch.id.to_string(),
197            records: &batch.records,
198        };
199        let body = serde_json::to_string(&payload).map_err(CollectorError::SerdeJson)?;
200
201        let result = tokio::task::spawn_blocking(
202            move || -> Result<ureq::Response, Box<dyn std::error::Error + Send + Sync>> {
203                ureq::post(&url)
204                    .set("Content-Type", "application/json")
205                    .send_string(&body)
206                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
207            },
208        )
209        .await
210        .map_err(|e| CollectorError::Reporter {
211            reason: format!("spawn_blocking join error: {e}"),
212        })?;
213
214        match result {
215            Ok(response) if response.status() < 400 => {
216                if batch.records.is_empty() {
217                    Ok(ReportResult::Success)
218                } else {
219                    Ok(ReportResult::Partial {
220                        reported_count: batch.records.len(),
221                    })
222                }
223            }
224            Ok(response) => {
225                let status = response.status();
226                Ok(ReportResult::Failed {
227                    reason: format!("HTTP {status}"),
228                    retry_at: chrono::Utc::now() + chrono::TimeDelta::minutes(5),
229                })
230            }
231            Err(e) => Ok(ReportResult::Failed {
232                reason: e.to_string(),
233                retry_at: chrono::Utc::now() + chrono::TimeDelta::minutes(5),
234            }),
235        }
236    }
237}
238
239// ── Helpers ─────────────────────────────────────────────────────────────────
240
241/// Truncate message for use in an Issue title.
242fn truncate_for_title(msg: &str, max_len: usize) -> &str {
243    if msg.len() <= max_len {
244        msg
245    } else {
246        &msg[..max_len]
247    }
248}
249
250/// Build an Issue body from an `ExceptionRecord`.
251fn build_issue_body(record: &crate::ExceptionRecord) -> String {
252    format!(
253        "## 异常摘要\n{message}\n\n## 堆栈\n```\n{stacktrace}\n```\n\n## 影响范围\n- 组件: \
254         {component}\n- 模块: {module}\n- 位置: {location}\n- 时间: {timestamp}\n\n---\n> 由 \
255         TokenFleet Exception Collector 自动生成",
256        message = record.message,
257        stacktrace = record.stacktrace,
258        component = record.component,
259        module = record.module_path.as_deref().unwrap_or("unknown"),
260        location = record.location.as_deref().unwrap_or("unknown"),
261        timestamp = record.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
262    )
263}
264
265// ── Tests ───────────────────────────────────────────────────────────────────
266
267#[cfg(test)]
268#[allow(
269    clippy::unwrap_used,
270    clippy::unwrap_in_result,
271    clippy::expect_used,
272    clippy::panic,
273    clippy::pedantic,
274    clippy::wildcard_enum_match_arm,
275    reason = "test module relaxes production lint strictness"
276)]
277mod tests {
278    use super::*;
279    use crate::{ExceptionKind, ExceptionRecord};
280
281    fn make_record(message: &str) -> ExceptionRecord {
282        ExceptionRecord::new(
283            "test-comp",
284            ExceptionKind::ErrorLog,
285            message,
286            "stack trace line",
287        )
288    }
289
290    // -- GitHubReporter --
291
292    #[test]
293    fn test_github_reporter_should_create() {
294        let reporter = GitHubReporter::new();
295        let _ = reporter;
296    }
297
298    #[test]
299    fn test_github_reporter_should_be_send_sync() {
300        fn assert_send_sync<T: Send + Sync>() {}
301        assert_send_sync::<GitHubReporter>();
302    }
303
304    // -- CustomPlatformReporter --
305
306    #[test]
307    fn test_custom_platform_reporter_should_create() {
308        let reporter = CustomPlatformReporter::new("https://example.com");
309        assert!(reporter.endpoint.contains("example.com"));
310    }
311
312    #[test]
313    fn test_custom_platform_reporter_should_be_cloneable() {
314        let reporter = CustomPlatformReporter::new("https://example.com");
315        let cloned = reporter.clone();
316        assert_eq!(cloned.endpoint, "https://example.com");
317    }
318
319    #[tokio::test]
320    async fn test_custom_platform_reporter_check_available_should_return_true() {
321        let reporter = CustomPlatformReporter::new("https://example.com");
322        assert!(reporter.check_available().await);
323    }
324
325    #[tokio::test]
326    async fn test_custom_platform_reporter_check_available_should_return_false_for_empty() {
327        let reporter = CustomPlatformReporter::new("");
328        assert!(!reporter.check_available().await);
329    }
330
331    // -- Helper functions --
332
333    #[test]
334    fn test_truncate_for_title_should_keep_short_messages() {
335        assert_eq!(truncate_for_title("short", 80), "short");
336    }
337
338    #[test]
339    fn test_truncate_for_title_should_truncate_long_messages() {
340        let long = "x".repeat(100);
341        let result = truncate_for_title(&long, 80);
342        assert_eq!(result.len(), 80);
343    }
344
345    #[test]
346    fn test_build_issue_body_should_include_component_and_message() {
347        let mut record = make_record("test error message");
348        record.module_path = Some("my::module".to_string());
349        record.location = Some("src/main.rs:42".to_string());
350
351        let body = build_issue_body(&record);
352        assert!(body.contains("test error message"));
353        assert!(body.contains("test-comp"));
354        assert!(body.contains("my::module"));
355        assert!(body.contains("src/main.rs:42"));
356    }
357}