diff --git a/src/commands/logs/log.rs b/src/commands/logs/log.rs new file mode 100644 index 0000000000..335efba67f --- /dev/null +++ b/src/commands/logs/log.rs @@ -0,0 +1,183 @@ +use super::send::{AttributeValue, LogItem, LogLevel}; +use anyhow::Result; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::utils::event::get_sdk_info; +use crate::utils::releases::detect_release_name; + +/// Log entry struct. +pub struct Log { + level: LogLevel, + message: String, + trace_id: Option, + release: Option, + environment: Option, + attributes: HashMap, +} + +impl Log { + /// Create a new log entry with the specified level and message. + pub fn new(level: String, message: String) -> Self { + Self { + level: LogLevel(level), + message, + trace_id: None, + release: None, + environment: None, + attributes: HashMap::new(), + } + } + + /// Set the trace ID for this log entry. + pub fn with_trace_id(mut self, trace_id: String) -> Self { + self.trace_id = Some(trace_id); + self + } + + /// Set the release for this log entry. + pub fn with_release(mut self, release: String) -> Self { + self.release = Some(release); + self + } + + /// Set the environment for this log entry. + pub fn with_environment(mut self, environment: String) -> Self { + self.environment = Some(environment); + self + } + + /// Add multiple attributes from key-value pairs. + pub fn with_attributes(mut self, attrs: Vec<(String, String)>) -> Self { + for (key, value_str) in attrs { + let (value, attr_type) = parse_attribute_value(&value_str); + self.attributes + .insert(key, AttributeValue { value, attr_type }); + } + self + } + + /// Convert this log entry to a Sentry envelope. + pub fn into_envelope(mut self) -> Result { + // Generate trace ID if not provided + let trace_id = self.trace_id.take().unwrap_or_else(generate_trace_id); + + // Add SDK attributes + let mut attributes = self.attributes; + add_sdk_attributes(&mut attributes); + + // Add release if provided or auto-detected + let release = self.release.or_else(|| detect_release_name().ok()); + if let Some(rel) = &release { + attributes.insert( + "sentry.release".to_owned(), + AttributeValue { + value: Value::String(rel.clone()), + attr_type: "string".to_owned(), + }, + ); + } + + // Add environment if provided + if let Some(env) = &self.environment { + attributes.insert( + "sentry.environment".to_owned(), + AttributeValue { + value: Value::String(env.clone()), + attr_type: "string".to_owned(), + }, + ); + } + + let log_item = LogItem { + timestamp: now_timestamp_seconds(), + trace_id: &trace_id, + level: self.level.as_ref(), + body: &self.message, + severity_number: Some(self.level.to_severity_number()), + attributes: if attributes.is_empty() { + None + } else { + Some(attributes) + }, + }; + + let payload = json!({ + "items": [log_item] + }); + + let payload_bytes = serde_json::to_vec(&payload)?; + let header = json!({ + "type": "log", + "item_count": 1, + "content_type": "application/vnd.sentry.items.log+json", + "length": payload_bytes.len() + }); + let header_bytes = serde_json::to_vec(&header)?; + + // Construct raw envelope: metadata line (empty for logs), then header, then payload + let mut buf = Vec::new(); + // Empty envelope metadata with no event_id + buf.extend_from_slice(b"{}\n"); + buf.extend_from_slice(&header_bytes); + buf.push(b'\n'); + buf.extend_from_slice(&payload_bytes); + + Ok(sentry::Envelope::from_bytes_raw(buf)?) + } +} + +fn parse_attribute_value(value_str: &str) -> (Value, String) { + // Try to parse as different types + let (value, attr_type) = if let Ok(b) = value_str.parse::() { + (Value::Bool(b), "boolean".to_owned()) + } else if let Ok(i) = value_str.parse::() { + ( + Value::Number(serde_json::Number::from(i)), + "integer".to_owned(), + ) + } else if let Ok(f) = value_str.parse::() { + ( + Value::Number(serde_json::Number::from_f64(f).expect("Failed to parse float")), + "double".to_owned(), + ) + } else { + (Value::String(value_str.to_owned()), "string".to_owned()) + }; + + (value, attr_type) +} + +fn add_sdk_attributes(attributes: &mut HashMap) { + let sdk_info = get_sdk_info(); + + attributes.insert( + "sentry.sdk.name".to_owned(), + AttributeValue { + value: Value::String(sdk_info.name.to_owned()), + attr_type: "string".to_owned(), + }, + ); + + attributes.insert( + "sentry.sdk.version".to_owned(), + AttributeValue { + value: Value::String(sdk_info.version.to_owned()), + attr_type: "string".to_owned(), + }, + ); +} + +fn now_timestamp_seconds() -> f64 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + now.as_secs() as f64 + (now.subsec_nanos() as f64) / 1_000_000_000.0 +} + +fn generate_trace_id() -> String { + // Generate 16 random bytes, hex-encoded to 32 chars. UUID v4 is 16 random bytes. + let uuid = uuid::Uuid::new_v4(); + data_encoding::HEXLOWER.encode(uuid.as_bytes()) +} diff --git a/src/commands/logs/mod.rs b/src/commands/logs/mod.rs index ce0c2cdc4e..58b3d57bcb 100644 --- a/src/commands/logs/mod.rs +++ b/src/commands/logs/mod.rs @@ -1,6 +1,9 @@ mod list; +mod log; +mod send; use self::list::ListLogsArgs; +use self::send::SendLogsArgs; use super::derive_parser::{SentryCLI, SentryCLICommand}; use anyhow::Result; use clap::ArgMatches; @@ -10,6 +13,7 @@ const BETA_WARNING: &str = "[BETA] The \"logs\" command is in beta. The command to breaking changes, including removal, in any Sentry CLI release."; const LIST_ABOUT: &str = "List logs from your organization"; +const SEND_ABOUT: &str = "Send a log entry to Sentry"; #[derive(Args)] pub(super) struct LogsArgs { @@ -32,6 +36,11 @@ enum LogsSubcommand { {BETA_WARNING}") )] List(ListLogsArgs), + #[command(about = format!("[BETA] {SEND_ABOUT}"))] + #[command(long_about = format!("{SEND_ABOUT}. \ + Send a single log entry using the Sentry Logs envelope format.\n\n\ + {BETA_WARNING}"))] + Send(SendLogsArgs), } pub(super) fn make_command(command: Command) -> Command { @@ -47,5 +56,6 @@ pub(super) fn execute(_: &ArgMatches) -> Result<()> { match subcommand { LogsSubcommand::List(args) => list::execute(args), + LogsSubcommand::Send(args) => send::execute(args), } } diff --git a/src/commands/logs/send.rs b/src/commands/logs/send.rs new file mode 100644 index 0000000000..239ac63c02 --- /dev/null +++ b/src/commands/logs/send.rs @@ -0,0 +1,148 @@ +use anyhow::{anyhow, Result}; +use clap::Args; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; + +use super::log::Log; +use crate::api::envelopes_api::EnvelopesApi; + +#[derive(Args)] +pub(super) struct SendLogsArgs { + #[arg(long = "level", value_parser = ["trace", "debug", "info", "warn", "error", "fatal"], default_value = "info", help = "Log severity level.")] + pub(super) level: String, + + #[arg(long = "message", help = "Log message body.")] + pub(super) message: String, + + #[arg( + long = "trace-id", + value_name = "TRACE_ID", + required = false, + help = "Optional 32-char hex trace id. If omitted, a random one is generated." + )] + pub(super) trace_id: Option, + + #[arg( + long = "release", + short = 'r', + value_name = "RELEASE", + help = "Optional release identifier. Defaults to auto-detected value." + )] + pub(super) release: Option, + + #[arg( + long = "env", + short = 'E', + value_name = "ENVIRONMENT", + help = "Optional environment name." + )] + pub(super) environment: Option, + + #[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.")] + pub(super) attributes: Vec, +} + +#[derive(Clone)] +pub(super) struct LogLevel(pub String); + +impl FromStr for LogLevel { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "trace" | "debug" | "info" | "warn" | "error" | "fatal" => Ok(LogLevel(s.to_owned())), + _ => Err(anyhow!( + "Invalid log level '{}'. Must be one of: trace, debug, info, warn, error, fatal", + s + )), + } + } +} + +impl AsRef for LogLevel { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[derive(Serialize)] +pub(super) struct LogItem<'a> { + pub(super) timestamp: f64, + #[serde(rename = "trace_id")] + pub(super) trace_id: &'a str, + pub(super) level: &'a str, + #[serde(rename = "body")] + pub(super) body: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) severity_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) attributes: Option>, +} + +#[derive(Serialize)] +pub(super) struct AttributeValue { + pub(super) value: Value, + #[serde(rename = "type")] + pub(super) attr_type: String, +} + +impl LogLevel { + pub(super) fn to_severity_number(&self) -> i32 { + match self.0.as_str() { + "trace" => 1, + "debug" => 5, + "info" => 9, + "warn" => 13, + "error" => 17, + "fatal" => 21, + _ => 9, + } + } +} + +pub(super) fn execute(args: SendLogsArgs) -> Result<()> { + // Validate trace id if provided + if let Some(tid) = &args.trace_id { + let is_valid = tid.len() == 32 && tid.chars().all(|c| c.is_ascii_hexdigit()); + if !is_valid { + return Err(anyhow!("trace-id must be a 32-character hex string")); + } + } + + // Parse attributes from command line + let mut attr_pairs = Vec::new(); + for attr in &args.attributes { + let parts: Vec<&str> = attr.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(anyhow!( + "Invalid attribute format '{}'. Expected 'key:value'", + attr + )); + } + attr_pairs.push((parts[0].to_owned(), parts[1].to_owned())); + } + + // Build log using the builder pattern + let mut log = Log::new(args.level.clone(), args.message.clone()).with_attributes(attr_pairs); + + if let Some(trace_id) = args.trace_id { + log = log.with_trace_id(trace_id); + } + + if let Some(release) = args.release { + log = log.with_release(release); + } + + if let Some(environment) = args.environment { + log = log.with_environment(environment); + } + + // Convert to envelope and send + let envelope = log.into_envelope()?; + EnvelopesApi::try_new()?.send_envelope(envelope)?; + + println!("Log sent."); + Ok(()) +} diff --git a/tests/integration/_cases/logs/logs-help.trycmd b/tests/integration/_cases/logs/logs-help.trycmd index a356a4c742..3e68fabf28 100644 --- a/tests/integration/_cases/logs/logs-help.trycmd +++ b/tests/integration/_cases/logs/logs-help.trycmd @@ -10,6 +10,7 @@ Usage: sentry-cli[EXE] logs [OPTIONS] [COMMAND] Commands: list [BETA] List logs from your organization + send [BETA] Send a log entry to Sentry help Print this message or the help of the given subcommand(s) Options: diff --git a/tests/integration/_cases/logs/logs-send-help.trycmd b/tests/integration/_cases/logs/logs-send-help.trycmd new file mode 100644 index 0000000000..832677f24c --- /dev/null +++ b/tests/integration/_cases/logs/logs-send-help.trycmd @@ -0,0 +1,52 @@ +``` +$ sentry-cli logs send --help +? success +Send a log entry to Sentry. Send a single log entry using the Sentry Logs envelope format. + +[BETA] The "logs" command is in beta. The command is subject to breaking changes, including removal, +in any Sentry CLI release. + +Usage: sentry-cli[EXE] logs send [OPTIONS] --message + +Options: + --level + Log severity level. + + [default: info] + [possible values: trace, debug, info, warn, error, fatal] + + --header + Custom headers that should be attached to all requests + in key:value format. + + --message + Log message body. + + --auth-token + Use the given Sentry auth token. + + --trace-id + Optional 32-char hex trace id. If omitted, a random one is generated. + + -r, --release + Optional release identifier. Defaults to auto-detected value. + + -E, --env + Optional environment name. + + --log-level + Set the log output verbosity. [possible values: trace, debug, info, warn, error] + + -a, --attr + Add attributes to the log (key:value pairs). Can be used multiple times. + + --quiet + Do not print any output while preserving correct exit code. This flag is currently + implemented only for selected subcommands. + + [aliases: silent] + + -h, --help + Print help (see a summary with '-h') + +``` diff --git a/tests/integration/_cases/logs/logs-send-with-attrs.trycmd b/tests/integration/_cases/logs/logs-send-with-attrs.trycmd new file mode 100644 index 0000000000..d86bb019e0 --- /dev/null +++ b/tests/integration/_cases/logs/logs-send-with-attrs.trycmd @@ -0,0 +1,7 @@ +``` +$ sentry-cli[EXE] logs send --message "User action" --level warn -a user_id:123 -a action:login --release 1.0.0 +? success +[BETA] The "logs" command is in beta. The command is subject to breaking changes, including removal, in any Sentry CLI release. +Log sent. + +``` \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-send.trycmd b/tests/integration/_cases/logs/logs-send.trycmd new file mode 100644 index 0000000000..9250e91a05 --- /dev/null +++ b/tests/integration/_cases/logs/logs-send.trycmd @@ -0,0 +1,7 @@ +``` +$ sentry-cli[EXE] logs send --message "Hello from CLI" --level info +? success +[BETA] The "logs" command is in beta. The command is subject to breaking changes, including removal, in any Sentry CLI release. +Log sent. + +``` diff --git a/tests/integration/logs.rs b/tests/integration/logs.rs index b79fc9b01f..14d52fc990 100644 --- a/tests/integration/logs.rs +++ b/tests/integration/logs.rs @@ -70,3 +70,22 @@ fn command_logs_list_help() { fn command_logs_help() { TestManager::new().register_trycmd_test("logs/logs-help.trycmd"); } + +#[test] +fn command_logs_send() { + TestManager::new() + .mock_endpoint(MockEndpointBuilder::new("POST", "/api/1337/envelope/")) + .register_trycmd_test("logs/logs-send.trycmd"); +} + +#[test] +fn command_logs_send_help() { + TestManager::new().register_trycmd_test("logs/logs-send-help.trycmd"); +} + +#[test] +fn command_logs_send_with_attrs() { + TestManager::new() + .mock_endpoint(MockEndpointBuilder::new("POST", "/api/1337/envelope/")) + .register_trycmd_test("logs/logs-send-with-attrs.trycmd"); +}