Skip to content

Commit 0ccf8da

Browse files
add category to OTel logs (#1556)
derive a log category - FATAL, ERROR, WARN, INFO, DEBUG, TRACE for each ingested log records first map severity_number directory to a category fallback where severity_number is unset (0 / UNSPECIFIED), scan the log body to find category based on case-insensitive substring match defaults to UNSPECIFIED when neither rules yields a result
1 parent ee6ae52 commit 0ccf8da

4 files changed

Lines changed: 71 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/handlers/http/modal/query_server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ impl ParseableServer for QueryServer {
7777
.service(Server::get_alerts_webscope())
7878
.service(Server::get_targets_webscope())
7979
.service(Self::get_cluster_web_scope())
80-
.service(Server::get_demo_data_webscope())
80+
.service(Server::get_demo_data_webscope()),
8181
)
8282
.service(
8383
web::scope(&prism_base_path())

src/handlers/http/modal/server.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,14 @@ impl ParseableServer for Server {
100100
.service(Self::get_alerts_webscope())
101101
.service(Self::get_targets_webscope())
102102
.service(Self::get_metrics_webscope())
103-
.service(Self::get_demo_data_webscope())
103+
.service(Self::get_demo_data_webscope()),
104104
)
105105
.service(
106106
web::scope(&prism_base_path())
107107
.service(Server::get_prism_home())
108108
.service(Server::get_prism_logstream())
109109
.service(Server::get_prism_datasets())
110-
.service(Self::get_dataset_stats_webscope())
110+
.service(Self::get_dataset_stats_webscope()),
111111
)
112112
.service(Self::get_ingest_otel_factory().wrap(from_fn(
113113
resource_check::check_resource_utilization_middleware,

src/otel/logs.rs

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use opentelemetry_proto::tonic::logs::v1::SeverityNumber;
2828
use serde_json::Map;
2929
use serde_json::Value;
3030

31-
pub const OTEL_LOG_KNOWN_FIELD_LIST: [&str; 16] = [
31+
pub const OTEL_LOG_KNOWN_FIELD_LIST: [&str; 17] = [
3232
"scope_name",
3333
"scope_version",
3434
"scope_log_schema_url",
@@ -45,6 +45,7 @@ pub const OTEL_LOG_KNOWN_FIELD_LIST: [&str; 16] = [
4545
"span_id",
4646
"trace_id",
4747
"event_name",
48+
"p_log_category",
4849
];
4950
/// otel log event has severity number
5051
/// there is a mapping of severity number to severity text provided in proto
@@ -70,6 +71,48 @@ fn flatten_severity(severity_number: i32) -> Map<String, Value> {
7071
severity_json
7172
}
7273

74+
/// Maps OTel severity_number (0–24) to a log category.
75+
/// See https://opentelemetry.io/docs/specs/otel/logs/data-model/#severity-fields
76+
fn category_from_severity(severity_number: i32) -> Option<&'static str> {
77+
match severity_number {
78+
1..=4 => Some("TRACE"),
79+
5..=8 => Some("DEBUG"),
80+
9..=12 => Some("INFO"),
81+
13..=16 => Some("WARN"),
82+
17..=20 => Some("ERROR"),
83+
21..=24 => Some("FATAL"),
84+
_ => None, // 0 (Unspecified) or out of range
85+
}
86+
}
87+
88+
/// Fallback: case-insensitive partial match on the body string.
89+
/// Categories are ordered from most severe to least severe so the highest severity are checked first.
90+
const LOG_CATEGORIES: &[(&str, &str)] = &[
91+
("critical", "FATAL"),
92+
("fatal", "FATAL"),
93+
("error", "ERROR"),
94+
("warning", "WARN"),
95+
("warn", "WARN"),
96+
("info", "INFO"),
97+
("debug", "DEBUG"),
98+
("trace", "TRACE"),
99+
("verbose", "TRACE"),
100+
];
101+
102+
fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
103+
haystack
104+
.as_bytes()
105+
.windows(needle.len())
106+
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
107+
}
108+
109+
fn category_from_body(body_str: &str) -> &'static str {
110+
LOG_CATEGORIES
111+
.iter()
112+
.find(|(pattern, _)| contains_ignore_ascii_case(body_str, pattern))
113+
.map_or("UNSPECIFIED", |(_, label)| *label)
114+
}
115+
73116
/// this function flattens the `LogRecord` object
74117
/// and returns a `Map` of the flattened json
75118
/// this function is called recursively for each log record object in the otel logs
@@ -90,6 +133,9 @@ pub fn flatten_log_record(log_record: &LogRecord) -> Map<String, Value> {
90133

91134
log_record_json.extend(flatten_severity(log_record.severity_number));
92135

136+
// Primary: derive category from severity_number
137+
let mut log_category = category_from_severity(log_record.severity_number);
138+
93139
if log_record.body.is_some() {
94140
let body = &log_record.body;
95141
let body_json = collect_json_from_values(body, &"body".to_string());
@@ -113,8 +159,28 @@ pub fn flatten_log_record(log_record: &LogRecord) -> Map<String, Value> {
113159
}
114160
}
115161
}
162+
163+
// Fallback: scan body only when severity_number is unset
164+
if log_category.is_none() {
165+
let body_str: String = body_json
166+
.values()
167+
.map(|v| match v {
168+
Value::String(s) => s.clone(),
169+
other => other.to_string(),
170+
})
171+
.collect::<Vec<_>>()
172+
.join(" ");
173+
log_category = Some(category_from_body(&body_str));
174+
}
116175
}
176+
117177
insert_attributes(&mut log_record_json, &log_record.attributes);
178+
179+
// Insert after attributes so a client-sent "p_log_category" cannot override
180+
log_record_json.insert(
181+
"p_log_category".to_string(),
182+
Value::String(log_category.unwrap_or("UNSPECIFIED").to_string()),
183+
);
118184
log_record_json.insert(
119185
"log_record_dropped_attributes_count".to_string(),
120186
Value::Number(log_record.dropped_attributes_count.into()),

0 commit comments

Comments
 (0)