|
| 1 | +use anyhow::{anyhow, Result}; |
| 2 | +use clap::Args; |
| 3 | +use serde::Serialize; |
| 4 | +use serde_json::{json, Value}; |
| 5 | +use std::collections::HashMap; |
| 6 | +use std::time::{SystemTime, UNIX_EPOCH}; |
| 7 | + |
| 8 | +use super::common_args::CommonLogsArgs; |
| 9 | +use crate::api::envelopes_api::EnvelopesApi; |
| 10 | +use crate::config::Config; |
| 11 | +use crate::utils::event::get_sdk_info; |
| 12 | +use crate::utils::releases::detect_release_name; |
| 13 | + |
| 14 | +#[derive(Args)] |
| 15 | +pub(super) struct SendLogsArgs { |
| 16 | + #[command(flatten)] |
| 17 | + common: CommonLogsArgs, |
| 18 | + |
| 19 | + #[arg(long = "level", value_parser = ["trace", "debug", "info", "warn", "error", "fatal"], default_value = "info", help = "Log severity level.")] |
| 20 | + level: String, |
| 21 | + |
| 22 | + #[arg(long = "message", help = "Log message body.")] |
| 23 | + message: String, |
| 24 | + |
| 25 | + #[arg( |
| 26 | + long = "trace-id", |
| 27 | + value_name = "TRACE_ID", |
| 28 | + required = false, |
| 29 | + help = "Optional 32-char hex trace id. If omitted, a random one is generated." |
| 30 | + )] |
| 31 | + trace_id: Option<String>, |
| 32 | + |
| 33 | + #[arg( |
| 34 | + long = "release", |
| 35 | + short = 'r', |
| 36 | + value_name = "RELEASE", |
| 37 | + help = "Optional release identifier. Defaults to auto-detected value." |
| 38 | + )] |
| 39 | + release: Option<String>, |
| 40 | + |
| 41 | + #[arg( |
| 42 | + long = "env", |
| 43 | + short = 'E', |
| 44 | + value_name = "ENVIRONMENT", |
| 45 | + help = "Optional environment name." |
| 46 | + )] |
| 47 | + environment: Option<String>, |
| 48 | + |
| 49 | + #[arg(long = "attr", short = 'a', value_name = "KEY:VALUE", action = clap::ArgAction::Append, help = "Add attributes to the log (key:value pairs). Can be used multiple times.")] |
| 50 | + attributes: Vec<String>, |
| 51 | +} |
| 52 | + |
| 53 | +#[derive(Serialize)] |
| 54 | +struct LogItem<'a> { |
| 55 | + timestamp: f64, |
| 56 | + #[serde(rename = "trace_id")] |
| 57 | + trace_id: &'a str, |
| 58 | + level: &'a str, |
| 59 | + #[serde(rename = "body")] |
| 60 | + body: &'a str, |
| 61 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 62 | + severity_number: Option<i32>, |
| 63 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 64 | + attributes: Option<HashMap<String, AttributeValue>>, |
| 65 | +} |
| 66 | + |
| 67 | +#[derive(Serialize)] |
| 68 | +struct AttributeValue { |
| 69 | + value: Value, |
| 70 | + #[serde(rename = "type")] |
| 71 | + attr_type: String, |
| 72 | +} |
| 73 | + |
| 74 | +fn level_to_severity_number(level: &str) -> i32 { |
| 75 | + match level { |
| 76 | + "trace" => 1, |
| 77 | + "debug" => 5, |
| 78 | + "info" => 9, |
| 79 | + "warn" => 13, |
| 80 | + "error" => 17, |
| 81 | + "fatal" => 21, |
| 82 | + _ => 9, |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +fn now_timestamp_seconds() -> f64 { |
| 87 | + let now = SystemTime::now() |
| 88 | + .duration_since(UNIX_EPOCH) |
| 89 | + .expect("Time went backwards"); |
| 90 | + now.as_secs() as f64 + (now.subsec_nanos() as f64) / 1_000_000_000.0 |
| 91 | +} |
| 92 | + |
| 93 | +fn generate_trace_id() -> String { |
| 94 | + // Generate 16 random bytes, hex-encoded to 32 chars. UUID v4 is 16 random bytes. |
| 95 | + let uuid = uuid::Uuid::new_v4(); |
| 96 | + data_encoding::HEXLOWER.encode(uuid.as_bytes()) |
| 97 | +} |
| 98 | + |
| 99 | +fn parse_attributes(attrs: &[String]) -> Result<HashMap<String, AttributeValue>> { |
| 100 | + let mut attributes = HashMap::new(); |
| 101 | + |
| 102 | + for attr in attrs { |
| 103 | + let parts: Vec<&str> = attr.splitn(2, ':').collect(); |
| 104 | + if parts.len() != 2 { |
| 105 | + return Err(anyhow!( |
| 106 | + "Invalid attribute format '{}'. Expected 'key:value'", |
| 107 | + attr |
| 108 | + )); |
| 109 | + } |
| 110 | + |
| 111 | + let key = parts[0].to_owned(); |
| 112 | + let value_str = parts[1]; |
| 113 | + |
| 114 | + // Try to parse as different types |
| 115 | + let (value, attr_type) = if let Ok(b) = value_str.parse::<bool>() { |
| 116 | + (Value::Bool(b), "boolean".to_owned()) |
| 117 | + } else if let Ok(i) = value_str.parse::<i64>() { |
| 118 | + ( |
| 119 | + Value::Number(serde_json::Number::from(i)), |
| 120 | + "integer".to_owned(), |
| 121 | + ) |
| 122 | + } else if let Ok(f) = value_str.parse::<f64>() { |
| 123 | + ( |
| 124 | + Value::Number(serde_json::Number::from_f64(f).expect("Failed to parse float")), |
| 125 | + "double".to_owned(), |
| 126 | + ) |
| 127 | + } else { |
| 128 | + (Value::String(value_str.to_owned()), "string".to_owned()) |
| 129 | + }; |
| 130 | + |
| 131 | + attributes.insert(key, AttributeValue { value, attr_type }); |
| 132 | + } |
| 133 | + |
| 134 | + Ok(attributes) |
| 135 | +} |
| 136 | + |
| 137 | +fn add_sdk_attributes(attributes: &mut HashMap<String, AttributeValue>) { |
| 138 | + let sdk_info = get_sdk_info(); |
| 139 | + |
| 140 | + attributes.insert( |
| 141 | + "sentry.sdk.name".to_owned(), |
| 142 | + AttributeValue { |
| 143 | + value: Value::String(sdk_info.name.to_owned()), |
| 144 | + attr_type: "string".to_owned(), |
| 145 | + }, |
| 146 | + ); |
| 147 | + |
| 148 | + attributes.insert( |
| 149 | + "sentry.sdk.version".to_owned(), |
| 150 | + AttributeValue { |
| 151 | + value: Value::String(sdk_info.version.to_owned()), |
| 152 | + attr_type: "string".to_owned(), |
| 153 | + }, |
| 154 | + ); |
| 155 | +} |
| 156 | + |
| 157 | +pub(super) fn execute(args: SendLogsArgs) -> Result<()> { |
| 158 | + // Get org and project from args or config defaults |
| 159 | + let config = Config::current(); |
| 160 | + let (default_org, default_project) = config.get_org_and_project_defaults(); |
| 161 | + |
| 162 | + let _org = args |
| 163 | + .common |
| 164 | + .org |
| 165 | + .as_ref() |
| 166 | + .or(default_org.as_ref()) |
| 167 | + .ok_or_else(|| { |
| 168 | + anyhow!( |
| 169 | + "No organization specified. Please specify an organization using the --org argument." |
| 170 | + ) |
| 171 | + })?; |
| 172 | + |
| 173 | + let _project = args |
| 174 | + .common |
| 175 | + .project |
| 176 | + .as_ref() |
| 177 | + .or(default_project.as_ref()) |
| 178 | + .ok_or_else(|| { |
| 179 | + anyhow!("No project specified. Use --project or set a default in config.") |
| 180 | + })?; |
| 181 | + |
| 182 | + // Note: The org and project values are currently not used for sending logs, |
| 183 | + // as the EnvelopesApi uses the DSN from config which already contains this information. |
| 184 | + // These arguments are kept for consistency with other commands and potential future use. |
| 185 | + |
| 186 | + // Validate trace id or generate a new one |
| 187 | + let trace_id_owned; |
| 188 | + let trace_id = if let Some(tid) = &args.trace_id { |
| 189 | + let is_valid = tid.len() == 32 && tid.chars().all(|c| c.is_ascii_hexdigit()); |
| 190 | + if !is_valid { |
| 191 | + return Err(anyhow!("trace-id must be a 32-character hex string")); |
| 192 | + } |
| 193 | + tid.as_str() |
| 194 | + } else { |
| 195 | + trace_id_owned = generate_trace_id(); |
| 196 | + &trace_id_owned |
| 197 | + }; |
| 198 | + |
| 199 | + let severity_number = level_to_severity_number(&args.level); |
| 200 | + |
| 201 | + // Parse and build attributes |
| 202 | + let mut attributes = parse_attributes(&args.attributes)?; |
| 203 | + |
| 204 | + // Add SDK attributes |
| 205 | + add_sdk_attributes(&mut attributes); |
| 206 | + |
| 207 | + // Add release if provided or detected |
| 208 | + let release = args.release.clone().or_else(|| detect_release_name().ok()); |
| 209 | + if let Some(rel) = &release { |
| 210 | + attributes.insert( |
| 211 | + "sentry.release".to_owned(), |
| 212 | + AttributeValue { |
| 213 | + value: Value::String(rel.clone()), |
| 214 | + attr_type: "string".to_owned(), |
| 215 | + }, |
| 216 | + ); |
| 217 | + } |
| 218 | + |
| 219 | + // Add environment if provided |
| 220 | + if let Some(env) = &args.environment { |
| 221 | + attributes.insert( |
| 222 | + "sentry.environment".to_owned(), |
| 223 | + AttributeValue { |
| 224 | + value: Value::String(env.clone()), |
| 225 | + attr_type: "string".to_owned(), |
| 226 | + }, |
| 227 | + ); |
| 228 | + } |
| 229 | + |
| 230 | + // Build a logs envelope as raw bytes according to the Logs spec |
| 231 | + let log_item = LogItem { |
| 232 | + timestamp: now_timestamp_seconds(), |
| 233 | + trace_id, |
| 234 | + level: &args.level, |
| 235 | + body: &args.message, |
| 236 | + severity_number: Some(severity_number), |
| 237 | + attributes: if attributes.is_empty() { |
| 238 | + None |
| 239 | + } else { |
| 240 | + Some(attributes) |
| 241 | + }, |
| 242 | + }; |
| 243 | + |
| 244 | + let payload = json!({ |
| 245 | + "items": [log_item] |
| 246 | + }); |
| 247 | + |
| 248 | + let payload_bytes = serde_json::to_vec(&payload)?; |
| 249 | + let header = json!({ |
| 250 | + "type": "log", |
| 251 | + "item_count": 1, |
| 252 | + "content_type": "application/vnd.sentry.items.log+json", |
| 253 | + "length": payload_bytes.len() |
| 254 | + }); |
| 255 | + let header_bytes = serde_json::to_vec(&header)?; |
| 256 | + |
| 257 | + // Construct raw envelope: metadata line (empty for logs), then header, then payload |
| 258 | + let mut buf = Vec::new(); |
| 259 | + // Empty envelope metadata with no event_id |
| 260 | + buf.extend_from_slice(b"{}\n"); |
| 261 | + buf.extend_from_slice(&header_bytes); |
| 262 | + buf.push(b'\n'); |
| 263 | + buf.extend_from_slice(&payload_bytes); |
| 264 | + |
| 265 | + let envelope = sentry::Envelope::from_bytes_raw(buf)?; |
| 266 | + EnvelopesApi::try_new()?.send_envelope(envelope)?; |
| 267 | + |
| 268 | + println!("Log sent."); |
| 269 | + Ok(()) |
| 270 | +} |
0 commit comments