Skip to content

Commit 10a0716

Browse files
committed
feat(logs): add command to send logs
1 parent c7e2731 commit 10a0716

File tree

9 files changed

+391
-9
lines changed

9 files changed

+391
-9
lines changed

src/commands/logs/common_args.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
use clap::Args;
2+
3+
/// Common arguments for all logs subcommands.
4+
#[derive(Args)]
5+
pub(super) struct CommonLogsArgs {
6+
#[arg(short = 'o', long = "org")]
7+
#[arg(help = "The organization ID or slug.")]
8+
pub(super) org: Option<String>,
9+
10+
#[arg(short = 'p', long = "project")]
11+
#[arg(help = "The project ID or slug.")]
12+
pub(super) project: Option<String>,
13+
}

src/commands/logs/list.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use std::borrow::Cow;
22

33
use anyhow::Result;
4-
use clap::Args;
4+
use clap::{command, Args};
55

6+
use super::common_args::CommonLogsArgs;
67
use crate::api::{Api, Dataset, FetchEventsOptions};
78
use crate::config::Config;
89
use crate::utils::formatting::Table;
@@ -39,13 +40,8 @@ const LOG_FIELDS: &[&str] = &[
3940
/// Arguments for listing logs
4041
#[derive(Args)]
4142
pub(super) struct ListLogsArgs {
42-
#[arg(short = 'o', long = "org")]
43-
#[arg(help = "The organization ID or slug.")]
44-
org: Option<String>,
45-
46-
#[arg(short = 'p', long = "project")]
47-
#[arg(help = "The project ID or slug.")]
48-
project: Option<String>,
43+
#[command(flatten)]
44+
common: CommonLogsArgs,
4945

5046
#[arg(long = "max-rows", default_value = "100")]
5147
#[arg(value_parser = validate_max_rows)]
@@ -61,13 +57,14 @@ pub(super) fn execute(args: ListLogsArgs) -> Result<()> {
6157
let config = Config::current();
6258
let (default_org, default_project) = config.get_org_and_project_defaults();
6359

64-
let org = args.org.as_ref().or(default_org.as_ref()).ok_or_else(|| {
60+
let org = args.common.org.as_ref().or(default_org.as_ref()).ok_or_else(|| {
6561
anyhow::anyhow!(
6662
"No organization specified. Please specify an organization using the --org argument."
6763
)
6864
})?;
6965

7066
let project = args
67+
.common
7168
.project
7269
.as_ref()
7370
.or(default_project.as_ref())

src/commands/logs/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
mod common_args;
12
mod list;
3+
mod send;
24

35
use self::list::ListLogsArgs;
6+
use self::send::SendLogsArgs;
47
use super::derive_parser::{SentryCLI, SentryCLICommand};
58
use anyhow::Result;
69
use clap::ArgMatches;
@@ -10,6 +13,7 @@ const BETA_WARNING: &str = "[BETA] The \"logs\" command is in beta. The command
1013
to breaking changes, including removal, in any Sentry CLI release.";
1114

1215
const LIST_ABOUT: &str = "List logs from your organization";
16+
const SEND_ABOUT: &str = "Send a log entry to Sentry";
1317

1418
#[derive(Args)]
1519
pub(super) struct LogsArgs {
@@ -32,6 +36,11 @@ enum LogsSubcommand {
3236
{BETA_WARNING}")
3337
)]
3438
List(ListLogsArgs),
39+
#[command(about = format!("[BETA] {SEND_ABOUT}"))]
40+
#[command(long_about = format!("{SEND_ABOUT}. \
41+
Send a single log entry using the Sentry Logs envelope format.\n\n\
42+
{BETA_WARNING}"))]
43+
Send(SendLogsArgs),
3544
}
3645

3746
pub(super) fn make_command(command: Command) -> Command {
@@ -47,5 +56,6 @@ pub(super) fn execute(_: &ArgMatches) -> Result<()> {
4756

4857
match subcommand {
4958
LogsSubcommand::List(args) => list::execute(args),
59+
LogsSubcommand::Send(args) => send::execute(args),
5060
}
5161
}

src/commands/logs/send.rs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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+
}

tests/integration/_cases/logs/logs-help.trycmd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Usage: sentry-cli[EXE] logs [OPTIONS] [COMMAND]
1010

1111
Commands:
1212
list [BETA] List logs from your organization
13+
send [BETA] Send a log entry to Sentry
1314
help Print this message or the help of the given subcommand(s)
1415

1516
Options:

0 commit comments

Comments
 (0)