From a68cfa5be3da67f0fe530998dc86c771099df763 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 13 Aug 2025 10:17:49 +0200 Subject: [PATCH 1/3] feat(logs): add command to send logs --- src/commands/logs/mod.rs | 9 + src/commands/logs/send.rs | 235 ++++++++++++++++++ .../integration/_cases/logs/logs-help.trycmd | 1 + .../_cases/logs/logs-send-help.trycmd | 52 ++++ .../_cases/logs/logs-send-with-attrs.trycmd | 7 + .../integration/_cases/logs/logs-send.trycmd | 7 + tests/integration/logs.rs | 19 ++ 7 files changed, 330 insertions(+) create mode 100644 src/commands/logs/send.rs create mode 100644 tests/integration/_cases/logs/logs-send-help.trycmd create mode 100644 tests/integration/_cases/logs/logs-send-with-attrs.trycmd create mode 100644 tests/integration/_cases/logs/logs-send.trycmd diff --git a/src/commands/logs/mod.rs b/src/commands/logs/mod.rs index ce0c2cdc4e..19da77defc 100644 --- a/src/commands/logs/mod.rs +++ b/src/commands/logs/mod.rs @@ -1,6 +1,8 @@ mod list; +mod send; use self::list::ListLogsArgs; +use self::send::SendLogsArgs; use super::derive_parser::{SentryCLI, SentryCLICommand}; use anyhow::Result; use clap::ArgMatches; @@ -10,6 +12,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 +35,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 +55,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..ff04f4235d --- /dev/null +++ b/src/commands/logs/send.rs @@ -0,0 +1,235 @@ +use anyhow::{anyhow, Result}; +use clap::Args; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::api::envelopes_api::EnvelopesApi; +use crate::utils::event::get_sdk_info; +use crate::utils::releases::detect_release_name; + +#[derive(Args)] +pub(super) struct SendLogsArgs { + #[arg(long = "level", value_parser = ["trace", "debug", "info", "warn", "error", "fatal"], default_value = "info", help = "Log severity level.")] + level: String, + + #[arg(long = "message", help = "Log message body.")] + 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." + )] + trace_id: Option, + + #[arg( + long = "release", + short = 'r', + value_name = "RELEASE", + help = "Optional release identifier. Defaults to auto-detected value." + )] + release: Option, + + #[arg( + long = "env", + short = 'E', + value_name = "ENVIRONMENT", + help = "Optional environment name." + )] + 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.")] + attributes: Vec, +} + +#[derive(Serialize)] +struct LogItem<'a> { + timestamp: f64, + #[serde(rename = "trace_id")] + trace_id: &'a str, + level: &'a str, + #[serde(rename = "body")] + body: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + severity_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + attributes: Option>, +} + +#[derive(Serialize)] +struct AttributeValue { + value: Value, + #[serde(rename = "type")] + attr_type: String, +} + +fn level_to_severity_number(level: &str) -> i32 { + match level { + "trace" => 1, + "debug" => 5, + "info" => 9, + "warn" => 13, + "error" => 17, + "fatal" => 21, + _ => 9, + } +} + +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()) +} + +fn parse_attributes(attrs: &[String]) -> Result> { + let mut attributes = HashMap::new(); + + for attr in attrs { + let parts: Vec<&str> = attr.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(anyhow!( + "Invalid attribute format '{}'. Expected 'key:value'", + attr + )); + } + + let key = parts[0].to_owned(); + let value_str = parts[1]; + + // 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()) + }; + + attributes.insert(key, AttributeValue { value, attr_type }); + } + + Ok(attributes) +} + +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(), + }, + ); +} + +pub(super) fn execute(args: SendLogsArgs) -> Result<()> { + // Note: The org and project values are not needed for sending logs, + // as the EnvelopesApi uses the DSN from config which already contains this information. + + // Validate trace id or generate a new one + let trace_id_owned; + let trace_id = 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")); + } + tid.as_str() + } else { + trace_id_owned = generate_trace_id(); + &trace_id_owned + }; + + let severity_number = level_to_severity_number(&args.level); + + let mut attributes = parse_attributes(&args.attributes)?; + + add_sdk_attributes(&mut attributes); + + let release = args.release.clone().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(), + }, + ); + } + + if let Some(env) = &args.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, + level: &args.level, + body: &args.message, + severity_number: Some(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); + + let envelope = sentry::Envelope::from_bytes_raw(buf)?; + 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..deaf9bdc53 --- /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 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..7d3dc86fbd --- /dev/null +++ b/tests/integration/_cases/logs/logs-send-with-attrs.trycmd @@ -0,0 +1,7 @@ +``` +$ sentry-cli 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..47b52045ee --- /dev/null +++ b/tests/integration/_cases/logs/logs-send.trycmd @@ -0,0 +1,7 @@ +``` +$ sentry-cli 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"); +} From 5ff72e148fe1fbb6731cc2c0931f6d7a9d69c64a Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 15 Sep 2025 15:41:28 +0200 Subject: [PATCH 2/3] fix tests for windows --- src/commands/logs/log.rs | 183 ++++++++++++++ src/commands/logs/mod.rs | 1 + src/commands/logs/send.rs | 239 ++++++------------ .../_cases/logs/logs-send-help.trycmd | 2 +- .../_cases/logs/logs-send-with-attrs.trycmd | 2 +- .../integration/_cases/logs/logs-send.trycmd | 2 +- 6 files changed, 263 insertions(+), 166 deletions(-) create mode 100644 src/commands/logs/log.rs diff --git a/src/commands/logs/log.rs b/src/commands/logs/log.rs new file mode 100644 index 0000000000..c7e1472578 --- /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; + +/// Builder for creating log entries with a fluent API similar to metrics. +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 19da77defc..58b3d57bcb 100644 --- a/src/commands/logs/mod.rs +++ b/src/commands/logs/mod.rs @@ -1,4 +1,5 @@ mod list; +mod log; mod send; use self::list::ListLogsArgs; diff --git a/src/commands/logs/send.rs b/src/commands/logs/send.rs index ff04f4235d..239ac63c02 100644 --- a/src/commands/logs/send.rs +++ b/src/commands/logs/send.rs @@ -1,21 +1,20 @@ use anyhow::{anyhow, Result}; use clap::Args; use serde::Serialize; -use serde_json::{json, Value}; +use serde_json::Value; use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::str::FromStr; +use super::log::Log; use crate::api::envelopes_api::EnvelopesApi; -use crate::utils::event::get_sdk_info; -use crate::utils::releases::detect_release_name; #[derive(Args)] pub(super) struct SendLogsArgs { #[arg(long = "level", value_parser = ["trace", "debug", "info", "warn", "error", "fatal"], default_value = "info", help = "Log severity level.")] - level: String, + pub(super) level: String, #[arg(long = "message", help = "Log message body.")] - message: String, + pub(super) message: String, #[arg( long = "trace-id", @@ -23,7 +22,7 @@ pub(super) struct SendLogsArgs { required = false, help = "Optional 32-char hex trace id. If omitted, a random one is generated." )] - trace_id: Option, + pub(super) trace_id: Option, #[arg( long = "release", @@ -31,7 +30,7 @@ pub(super) struct SendLogsArgs { value_name = "RELEASE", help = "Optional release identifier. Defaults to auto-detected value." )] - release: Option, + pub(super) release: Option, #[arg( long = "env", @@ -39,62 +38,82 @@ pub(super) struct SendLogsArgs { value_name = "ENVIRONMENT", help = "Optional environment name." )] - environment: Option, + 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.")] - attributes: Vec, + 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)] -struct LogItem<'a> { - timestamp: f64, +pub(super) struct LogItem<'a> { + pub(super) timestamp: f64, #[serde(rename = "trace_id")] - trace_id: &'a str, - level: &'a str, + pub(super) trace_id: &'a str, + pub(super) level: &'a str, #[serde(rename = "body")] - body: &'a str, + pub(super) body: &'a str, #[serde(skip_serializing_if = "Option::is_none")] - severity_number: Option, + pub(super) severity_number: Option, #[serde(skip_serializing_if = "Option::is_none")] - attributes: Option>, + pub(super) attributes: Option>, } #[derive(Serialize)] -struct AttributeValue { - value: Value, +pub(super) struct AttributeValue { + pub(super) value: Value, #[serde(rename = "type")] - attr_type: String, + pub(super) attr_type: String, } -fn level_to_severity_number(level: &str) -> i32 { - match level { - "trace" => 1, - "debug" => 5, - "info" => 9, - "warn" => 13, - "error" => 17, - "fatal" => 21, - _ => 9, +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, + } } } -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()) -} - -fn parse_attributes(attrs: &[String]) -> Result> { - let mut attributes = HashMap::new(); +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")); + } + } - for attr in attrs { + // 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!( @@ -102,132 +121,26 @@ fn parse_attributes(attrs: &[String]) -> Result> attr )); } - - let key = parts[0].to_owned(); - let value_str = parts[1]; - - // 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()) - }; - - attributes.insert(key, AttributeValue { value, attr_type }); + attr_pairs.push((parts[0].to_owned(), parts[1].to_owned())); } - Ok(attributes) -} + // Build log using the builder pattern + let mut log = Log::new(args.level.clone(), args.message.clone()).with_attributes(attr_pairs); -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(), - }, - ); -} - -pub(super) fn execute(args: SendLogsArgs) -> Result<()> { - // Note: The org and project values are not needed for sending logs, - // as the EnvelopesApi uses the DSN from config which already contains this information. + if let Some(trace_id) = args.trace_id { + log = log.with_trace_id(trace_id); + } - // Validate trace id or generate a new one - let trace_id_owned; - let trace_id = 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")); - } - tid.as_str() - } else { - trace_id_owned = generate_trace_id(); - &trace_id_owned - }; - - let severity_number = level_to_severity_number(&args.level); - - let mut attributes = parse_attributes(&args.attributes)?; - - add_sdk_attributes(&mut attributes); - - let release = args.release.clone().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(), - }, - ); + if let Some(release) = args.release { + log = log.with_release(release); } - if let Some(env) = &args.environment { - attributes.insert( - "sentry.environment".to_owned(), - AttributeValue { - value: Value::String(env.clone()), - attr_type: "string".to_owned(), - }, - ); + if let Some(environment) = args.environment { + log = log.with_environment(environment); } - let log_item = LogItem { - timestamp: now_timestamp_seconds(), - trace_id, - level: &args.level, - body: &args.message, - severity_number: Some(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); - - let envelope = sentry::Envelope::from_bytes_raw(buf)?; + // Convert to envelope and send + let envelope = log.into_envelope()?; EnvelopesApi::try_new()?.send_envelope(envelope)?; println!("Log sent."); diff --git a/tests/integration/_cases/logs/logs-send-help.trycmd b/tests/integration/_cases/logs/logs-send-help.trycmd index deaf9bdc53..832677f24c 100644 --- a/tests/integration/_cases/logs/logs-send-help.trycmd +++ b/tests/integration/_cases/logs/logs-send-help.trycmd @@ -6,7 +6,7 @@ Send a log entry to Sentry. Send a single log entry using the Sentry Logs envelo [BETA] The "logs" command is in beta. The command is subject to breaking changes, including removal, in any Sentry CLI release. -Usage: sentry-cli logs send [OPTIONS] --message +Usage: sentry-cli[EXE] logs send [OPTIONS] --message Options: --level diff --git a/tests/integration/_cases/logs/logs-send-with-attrs.trycmd b/tests/integration/_cases/logs/logs-send-with-attrs.trycmd index 7d3dc86fbd..d86bb019e0 100644 --- a/tests/integration/_cases/logs/logs-send-with-attrs.trycmd +++ b/tests/integration/_cases/logs/logs-send-with-attrs.trycmd @@ -1,5 +1,5 @@ ``` -$ sentry-cli logs send --message "User action" --level warn -a user_id:123 -a action:login --release 1.0.0 +$ 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. diff --git a/tests/integration/_cases/logs/logs-send.trycmd b/tests/integration/_cases/logs/logs-send.trycmd index 47b52045ee..9250e91a05 100644 --- a/tests/integration/_cases/logs/logs-send.trycmd +++ b/tests/integration/_cases/logs/logs-send.trycmd @@ -1,5 +1,5 @@ ``` -$ sentry-cli logs send --message "Hello from CLI" --level info +$ 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. From 5b2a10cc69ad051dffb91eccec8f42c4a5c46b53 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 17 Sep 2025 10:19:46 +0200 Subject: [PATCH 3/3] fix comment --- src/commands/logs/log.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/logs/log.rs b/src/commands/logs/log.rs index c7e1472578..335efba67f 100644 --- a/src/commands/logs/log.rs +++ b/src/commands/logs/log.rs @@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::utils::event::get_sdk_info; use crate::utils::releases::detect_release_name; -/// Builder for creating log entries with a fluent API similar to metrics. +/// Log entry struct. pub struct Log { level: LogLevel, message: String,