tracing_layer_discord/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use serde::Serialize;
4use serde_json::Value;
5pub use tracing_layer_core::filters::EventFilters;
6pub use tracing_layer_core::layer::WebhookLayer;
7use tracing_layer_core::layer::WebhookLayerBuilder;
8pub use tracing_layer_core::BackgroundWorker;
9use tracing_layer_core::{Config, WebhookMessage, WebhookMessageFactory, WebhookMessageInputs};
10
11pub struct DiscordLayer;
12
13impl DiscordLayer {
14    pub fn builder(app_name: String, target_filters: EventFilters) -> WebhookLayerBuilder<DiscordConfig, Self> {
15        WebhookLayer::builder(app_name, target_filters)
16    }
17}
18
19impl WebhookMessageFactory for DiscordLayer {
20    fn create(inputs: WebhookMessageInputs) -> impl WebhookMessage {
21        let target = inputs.target;
22        let span = inputs.span;
23        let metadata = inputs.metadata;
24        let message = inputs.message;
25        let app_name = inputs.app_name;
26        let source_file = inputs.source_file;
27        let source_line = inputs.source_line;
28        let event_level = inputs.event_level;
29
30        #[cfg(feature = "embed")]
31        {
32            let event_level_emoji = match event_level {
33                tracing::Level::TRACE => ":mag:",
34                tracing::Level::DEBUG => ":bug:",
35                tracing::Level::INFO => ":information_source:",
36                tracing::Level::WARN => ":warning:",
37                tracing::Level::ERROR => ":x:",
38            };
39            let event_level_color = match event_level {
40                tracing::Level::TRACE => 1752220,
41                tracing::Level::DEBUG => 1752220,
42                tracing::Level::INFO => 5763719,
43                tracing::Level::WARN => 15105570,
44                tracing::Level::ERROR => 15548997,
45            };
46
47            // Maximum characters allowed for a Discord field value
48            const MAX_FIELD_VALUE_CHARS: usize = 1024 - 15;
49            const MAX_ERROR_MESSAGE_CHARS: usize = 2048 - 15;
50
51            // Truncate error_message if it exceeds the limit
52            let mut truncated_message = String::new();
53            if message.chars().count() > MAX_ERROR_MESSAGE_CHARS {
54                #[cfg(feature = "log-errors")]
55                eprintln!(
56                    "WARN: Truncating message to {} characters, original: {}",
57                    MAX_ERROR_MESSAGE_CHARS, message
58                );
59                let mut char_count = 0;
60                for c in message.chars() {
61                    char_count += 1;
62                    if char_count > MAX_ERROR_MESSAGE_CHARS {
63                        break;
64                    }
65                    truncated_message.push(c);
66                }
67            }
68            let message = if truncated_message.is_empty() {
69                message
70            } else {
71                truncated_message
72            };
73
74            let mut discord_embed = serde_json::json!({
75                "title": format!("{} - {} {}", app_name, event_level_emoji, event_level),
76                "description": format!("```rust\n{}\n```", message),
77                "fields": [
78                    {
79                        "name": "Target Span",
80                        "value": format!("`{}::{}`", target, span),
81                        "inline": true
82                    },
83                    {
84                        "name": "Source",
85                        "value": format!("`{}#L{}`", source_file, source_line),
86                        "inline": true
87                    },
88                ],
89                "footer": {
90                    "text": app_name
91                },
92                "color": event_level_color, // Hex value for "red"
93                "thumbnail": {
94                    "url": "https://example.com/error-thumbnail.png"
95                }
96            });
97
98            // Check if metadata exceeds the limit
99            if metadata.len() <= MAX_FIELD_VALUE_CHARS {
100                // Metadata fits within a single field
101                discord_embed["fields"].as_array_mut().unwrap().push(serde_json::json!({
102                    "name": "Metadata",
103                    "value": format!("```json\n{}\n```", metadata),
104                    "inline": false
105                }));
106            } else {
107                // Metadata exceeds the limit, split into multiple fields
108                let mut remaining_metadata = metadata;
109                let mut chunk_number = 1;
110                while !remaining_metadata.is_empty() {
111                    let chunk = remaining_metadata
112                        .chars()
113                        .take(MAX_FIELD_VALUE_CHARS)
114                        .collect::<String>();
115
116                    remaining_metadata = remaining_metadata.chars().skip(MAX_FIELD_VALUE_CHARS).collect();
117
118                    discord_embed["fields"].as_array_mut().unwrap().push(serde_json::json!({
119                        "name": format!("Metadata ({})", chunk_number),
120                        "value": format!("```json\n{}\n```", chunk),
121                        "inline": false
122                    }));
123
124                    chunk_number += 1;
125                }
126            }
127
128            DiscordMessagePayload {
129                content: None,
130                embeds: Some(vec![discord_embed]),
131                webhook_url: inputs.webhook_url,
132            }
133        }
134        #[cfg(not(feature = "embed"))]
135        {
136            let event_level = event.metadata().level().as_str();
137            let source_file = event.metadata().file().unwrap_or("Unknown");
138            let source_line = event.metadata().line().unwrap_or(0);
139            let payload = format!(
140                concat!(
141                    "*Trace from {}*\n",
142                    "*Event [{}]*: \"{}\"\n",
143                    "*Target*: _{}_\n",
144                    "*Span*: _{}_\n",
145                    "*Metadata*:\n",
146                    "```",
147                    "{}",
148                    "```\n",
149                    "*Source*: _{}#L{}_",
150                ),
151                app_name, event_level, message, span, target, metadata, source_file, source_line,
152            );
153            DiscordMessagePayload {
154                content: Some(payload),
155                embeds: None,
156                webhook_url: inputs.webhook_url,
157            }
158        }
159    }
160}
161
162/// Configuration describing how to forward tracing events to Discord.
163pub struct DiscordConfig {
164    pub(crate) webhook_url: String,
165}
166
167impl DiscordConfig {
168    pub fn new(webhook_url: String) -> Self {
169        Self { webhook_url }
170    }
171
172    /// Create a new config for forwarding messages to Discord using configuration
173    /// available in the environment.
174    ///
175    /// Required env vars:
176    ///   * DISCORD_WEBHOOK_URL
177    pub fn new_from_env() -> Self {
178        Self::new(std::env::var("DISCORD_WEBHOOK_URL").expect("discord webhook url in env"))
179    }
180}
181
182impl Default for DiscordConfig {
183    fn default() -> Self {
184        Self::new_from_env()
185    }
186}
187
188impl Config for DiscordConfig {
189    fn webhook_url(&self) -> &str {
190        &self.webhook_url
191    }
192
193    fn new_from_env() -> Self
194    where
195        Self: Sized,
196    {
197        Self::new_from_env()
198    }
199}
200
201/// The message sent to Discord. The logged record being "drained" will be
202/// converted into this format.
203#[derive(Debug, Clone, Serialize)]
204pub(crate) struct DiscordMessagePayload {
205    #[serde(skip_serializing_if = "Option::is_none")]
206    content: Option<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    embeds: Option<Vec<Value>>,
209    #[serde(skip_serializing)]
210    webhook_url: String,
211}
212
213impl WebhookMessage for DiscordMessagePayload {
214    fn webhook_url(&self) -> &str {
215        self.webhook_url.as_str()
216    }
217
218    fn serialize(&self) -> String {
219        serde_json::to_string(self).expect("failed to serialize discord message")
220    }
221}
222
223#[cfg(test)]
224mod tests {}