Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/commands/logs/log.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
release: Option<String>,
environment: Option<String>,
attributes: HashMap<String, AttributeValue>,
}

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<sentry::Envelope> {
// 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::<bool>() {
(Value::Bool(b), "boolean".to_owned())
} else if let Ok(i) = value_str.parse::<i64>() {
(
Value::Number(serde_json::Number::from(i)),
"integer".to_owned(),
)
} else if let Ok(f) = value_str.parse::<f64>() {
(
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<String, AttributeValue>) {
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())
}
10 changes: 10 additions & 0 deletions src/commands/logs/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -47,5 +56,6 @@ pub(super) fn execute(_: &ArgMatches) -> Result<()> {

match subcommand {
LogsSubcommand::List(args) => list::execute(args),
LogsSubcommand::Send(args) => send::execute(args),
}
}
148 changes: 148 additions & 0 deletions src/commands/logs/send.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

#[arg(
long = "release",
short = 'r',
value_name = "RELEASE",
help = "Optional release identifier. Defaults to auto-detected value."
)]
pub(super) release: Option<String>,

#[arg(
long = "env",
short = 'E',
value_name = "ENVIRONMENT",
help = "Optional environment name."
)]
pub(super) environment: Option<String>,

#[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<String>,
}

#[derive(Clone)]
pub(super) struct LogLevel(pub String);

impl FromStr for LogLevel {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
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<str> 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<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) attributes: Option<HashMap<String, AttributeValue>>,
}

#[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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(())
}
1 change: 1 addition & 0 deletions tests/integration/_cases/logs/logs-help.trycmd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading