Skip to content

feat(logs): add command to send logs #2708

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
9 changes: 9 additions & 0 deletions src/commands/logs/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -47,5 +55,6 @@ pub(super) fn execute(_: &ArgMatches) -> Result<()> {

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

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

#[arg(
long = "env",
short = 'E',
value_name = "ENVIRONMENT",
help = "Optional environment name."
)]
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.")]
attributes: Vec<String>,
}

#[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<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
attributes: Option<HashMap<String, AttributeValue>>,
}

#[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<HashMap<String, AttributeValue>> {
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::<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())
};

attributes.insert(key, AttributeValue { value, attr_type });
}

Ok(attributes)
}

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(),
},
);
}

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);

// Parse and build attributes
let mut attributes = parse_attributes(&args.attributes)?;

// Add SDK attributes
add_sdk_attributes(&mut attributes);

// Add release if provided or detected
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(),
},
);
}

// Add environment if provided
if let Some(env) = &args.environment {
attributes.insert(
"sentry.environment".to_owned(),
AttributeValue {
value: Value::String(env.clone()),
attr_type: "string".to_owned(),
},
);
}

// Build a logs envelope as raw bytes according to the Logs spec
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(())
}
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
52 changes: 52 additions & 0 deletions tests/integration/_cases/logs/logs-send-help.trycmd
Original file line number Diff line number Diff line change
@@ -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 <MESSAGE>

Options:
--level <LEVEL>
Log severity level.

[default: info]
[possible values: trace, debug, info, warn, error, fatal]

--header <KEY:VALUE>
Custom headers that should be attached to all requests
in key:value format.

--message <MESSAGE>
Log message body.

--auth-token <AUTH_TOKEN>
Use the given Sentry auth token.

--trace-id <TRACE_ID>
Optional 32-char hex trace id. If omitted, a random one is generated.

-r, --release <RELEASE>
Optional release identifier. Defaults to auto-detected value.

-E, --env <ENVIRONMENT>
Optional environment name.

--log-level <LOG_LEVEL>
Set the log output verbosity. [possible values: trace, debug, info, warn, error]

-a, --attr <KEY:VALUE>
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')

```
7 changes: 7 additions & 0 deletions tests/integration/_cases/logs/logs-send-with-attrs.trycmd
Original file line number Diff line number Diff line change
@@ -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.

```
7 changes: 7 additions & 0 deletions tests/integration/_cases/logs/logs-send.trycmd
Original file line number Diff line number Diff line change
@@ -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.

```
19 changes: 19 additions & 0 deletions tests/integration/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading