Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and infrastructure/data-plane naming, using the supplied Cairn OBS logo package. Cosmetic: favicon/logo swap (also closes a stale license-audit finding -- the old favicon was SvelteKit's unreplaced scaffold logo), new centered welcome landing page, larger/legible sidebar logo, page titles, CLAUDE.md/README/docs prose. Code identifiers: Go module path github.com/sentry/sentry -> github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc regenerated); Rust crates sentry-agent/sentry-parser/sentry-search -> cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type, env vars); every session/auth cookie name; agent config paths and Windows service identity. Deliberately preserved: the gRPC wire protocol's protobuf packages (sentry.logs.v1, sentry.agent.v1) and their Go import directory (proto/sentry/...) -- renaming the wire-level package would break every currently-deployed agent binary (confirmed two real hosts, including mail.inbuxa.com, are actively streaming through this exact contract) until rebuilt and redeployed in lockstep with an ingest cutover. Only the Go module path wrapping the generated code changes. Infrastructure: every docker-compose container name (root and three component-level compose files); the Helm chart (directory, Chart.yaml, named-template helpers, all templates, values.yaml image repos); Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd package. Caught and fixed real path-coupling bugs along the way: the Helm chart's search/ingest volume mounts and the dev-only-credential detection constant vs. docker-compose.yml's literal values had to move together or a security warning would have silently stopped firing. Data plane: Postgres database sentry_metadata -> cairnobs_metadata and role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups. Source-level defaults, docker-compose.yml, and every migrate.sh/ provision script default updated together; already-applied migration files left untouched per this repo's immutable-migration convention. Verified at every layer: all 13 Go modules build/vet/test clean, both Rust workspaces (agent, search) build/clippy/test clean, npm run check/ build clean, docker compose config validates on all four compose files. Live-verified against a real docker stack multiple times through this work, including a final fresh-volume run confirming the actual renamed Postgres database/role, ClickHouse database, and Kafka topic all work end to end with a real login and query, zero console errors.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "cairnobs-parser"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Minimal RFC 5424 syslog parser with raw-passthrough fallback"
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Minimal RFC 5424 syslog parser with raw-passthrough fallback.
|
||||
//!
|
||||
//! This is intentionally not a complete RFC 5424 implementation (no BOM
|
||||
//! handling on MSG, "-" nil markers are kept as literal strings rather than
|
||||
//! mapped to `None`). It's the Phase 0 minimum: parse what's clearly
|
||||
//! structured syslog, and never fail a log line outright — anything that
|
||||
//! doesn't match the grammar becomes a raw passthrough record instead of
|
||||
//! being dropped.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedLine {
|
||||
/// Syslog facility (0-23), present only when RFC 5424 framing parsed.
|
||||
pub facility: Option<u8>,
|
||||
/// Syslog severity (0=emergency .. 7=debug), present only when RFC 5424
|
||||
/// framing parsed.
|
||||
pub severity: Option<u8>,
|
||||
/// Structured fields extracted from the PRI/HEADER/STRUCTURED-DATA
|
||||
/// portions. Empty when the raw-passthrough fallback fires.
|
||||
pub attributes: BTreeMap<String, String>,
|
||||
/// The MSG portion when RFC 5424 parsing succeeded, otherwise the
|
||||
/// original, unmodified line.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Parse a single log line. Never fails: falls back to a raw passthrough
|
||||
/// `ParsedLine` (no facility/severity, empty attributes, message = input)
|
||||
/// when the line doesn't match RFC 5424 framing.
|
||||
pub fn parse(line: &str) -> ParsedLine {
|
||||
parse_rfc5424(line).unwrap_or_else(|| ParsedLine {
|
||||
facility: None,
|
||||
severity: None,
|
||||
attributes: BTreeMap::new(),
|
||||
message: line.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
struct Scanner<'a> {
|
||||
chars: std::iter::Peekable<std::str::Chars<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Scanner<'a> {
|
||||
fn new(s: &'a str) -> Self {
|
||||
Scanner {
|
||||
chars: s.chars().peekable(),
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&mut self) -> Option<char> {
|
||||
self.chars.peek().copied()
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<char> {
|
||||
self.chars.next()
|
||||
}
|
||||
|
||||
fn expect(&mut self, c: char) -> Option<()> {
|
||||
if self.next()? == c {
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn take_while<F: Fn(char) -> bool>(&mut self, f: F) -> String {
|
||||
let mut out = String::new();
|
||||
while let Some(c) = self.peek() {
|
||||
if f(c) {
|
||||
out.push(c);
|
||||
self.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn skip_one_space(&mut self) -> Option<()> {
|
||||
self.expect(' ')
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rfc5424(line: &str) -> Option<ParsedLine> {
|
||||
let mut sc = Scanner::new(line);
|
||||
|
||||
sc.expect('<')?;
|
||||
let pri_str = sc.take_while(|c| c.is_ascii_digit());
|
||||
if pri_str.is_empty() || pri_str.len() > 3 {
|
||||
return None;
|
||||
}
|
||||
sc.expect('>')?;
|
||||
let pri: u16 = pri_str.parse().ok()?;
|
||||
if pri > 191 {
|
||||
return None;
|
||||
}
|
||||
let facility = (pri / 8) as u8;
|
||||
let severity = (pri % 8) as u8;
|
||||
|
||||
let version = sc.take_while(|c| c.is_ascii_digit());
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
sc.skip_one_space()?;
|
||||
|
||||
let timestamp = sc.take_while(|c| c != ' ');
|
||||
if timestamp.is_empty() {
|
||||
return None;
|
||||
}
|
||||
sc.skip_one_space()?;
|
||||
|
||||
let hostname = sc.take_while(|c| c != ' ');
|
||||
if hostname.is_empty() {
|
||||
return None;
|
||||
}
|
||||
sc.skip_one_space()?;
|
||||
|
||||
let app_name = sc.take_while(|c| c != ' ');
|
||||
if app_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
sc.skip_one_space()?;
|
||||
|
||||
let procid = sc.take_while(|c| c != ' ');
|
||||
if procid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
sc.skip_one_space()?;
|
||||
|
||||
let msgid = sc.take_while(|c| c != ' ');
|
||||
if msgid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
sc.skip_one_space()?;
|
||||
|
||||
let mut sd_pairs: Vec<(String, String, String)> = Vec::new();
|
||||
match sc.peek() {
|
||||
Some('-') => {
|
||||
sc.next();
|
||||
}
|
||||
Some('[') => loop {
|
||||
if sc.peek() != Some('[') {
|
||||
break;
|
||||
}
|
||||
sc.next();
|
||||
let sd_id = sc.take_while(|c| c != ' ' && c != ']');
|
||||
if sd_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
match sc.peek() {
|
||||
Some(' ') => {
|
||||
sc.next();
|
||||
let name = sc.take_while(|c| c != '=');
|
||||
sc.expect('=')?;
|
||||
sc.expect('"')?;
|
||||
let mut val = String::new();
|
||||
loop {
|
||||
match sc.next() {
|
||||
Some('\\') => val.push(sc.next()?),
|
||||
Some('"') => break,
|
||||
Some(c) => val.push(c),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
sd_pairs.push((sd_id.clone(), name, val));
|
||||
}
|
||||
Some(']') => {
|
||||
sc.next();
|
||||
break;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
let message = if sc.peek() == Some(' ') {
|
||||
sc.next();
|
||||
sc.take_while(|_| true)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut attributes = BTreeMap::new();
|
||||
attributes.insert("syslog.version".to_string(), version);
|
||||
attributes.insert("syslog.timestamp".to_string(), timestamp);
|
||||
attributes.insert("syslog.hostname".to_string(), hostname);
|
||||
attributes.insert("syslog.app_name".to_string(), app_name);
|
||||
attributes.insert("syslog.procid".to_string(), procid);
|
||||
attributes.insert("syslog.msgid".to_string(), msgid);
|
||||
for (sd_id, name, val) in sd_pairs {
|
||||
attributes.insert(format!("{sd_id}.{name}"), val);
|
||||
}
|
||||
|
||||
Some(ParsedLine {
|
||||
facility: Some(facility),
|
||||
severity: Some(severity),
|
||||
attributes,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_full_rfc5424_with_structured_data() {
|
||||
let line = r#"<165>1 2003-10-11T22:14:15.003Z mymachine.example.com evntslp - ID47 [exampleSDID@32473 iut="3" eventSource="Application" eventID="1011"] An application event log entry"#;
|
||||
let p = parse(line);
|
||||
assert_eq!(p.facility, Some(20));
|
||||
assert_eq!(p.severity, Some(5));
|
||||
assert_eq!(p.message, "An application event log entry");
|
||||
assert_eq!(
|
||||
p.attributes.get("[email protected]"),
|
||||
Some(&"3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
p.attributes.get("[email protected]"),
|
||||
Some(&"Application".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
p.attributes.get("syslog.hostname"),
|
||||
Some(&"mymachine.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_nil_structured_data_and_fields() {
|
||||
let line = "<34>1 2003-10-11T22:14:15.003Z mymachine su - ID47 - 'su root' failed";
|
||||
let p = parse(line);
|
||||
assert_eq!(p.facility, Some(4));
|
||||
assert_eq!(p.severity, Some(2));
|
||||
assert_eq!(p.attributes.get("syslog.procid"), Some(&"-".to_string()));
|
||||
assert_eq!(p.message, "'su root' failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_structured_data_elements() {
|
||||
let line = r#"<165>1 2003-10-11T22:14:15.003Z host app - ID47 [a@1 k="v"][b@1 k2="v2"] msg"#;
|
||||
let p = parse(line);
|
||||
assert_eq!(p.attributes.get("[email protected]"), Some(&"v".to_string()));
|
||||
assert_eq!(p.attributes.get("[email protected]"), Some(&"v2".to_string()));
|
||||
assert_eq!(p.message, "msg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_escaped_quote_in_param_value() {
|
||||
let line = r#"<165>1 2003-10-11T22:14:15.003Z host app - ID47 [a@1 k="has \"quote\" inside"] msg"#;
|
||||
let p = parse(line);
|
||||
assert_eq!(
|
||||
p.attributes.get("[email protected]"),
|
||||
Some(&"has \"quote\" inside".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_raw_passthrough_for_non_syslog_line() {
|
||||
let line = "this is just a plain log line, not syslog at all";
|
||||
let p = parse(line);
|
||||
assert_eq!(p.facility, None);
|
||||
assert_eq!(p.severity, None);
|
||||
assert!(p.attributes.is_empty());
|
||||
assert_eq!(p.message, line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_raw_passthrough_for_malformed_pri() {
|
||||
let line = "<abc>1 2003-10-11T22:14:15.003Z host app - ID47 - msg";
|
||||
let p = parse(line);
|
||||
assert_eq!(p.facility, None);
|
||||
assert_eq!(p.message, line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_structured_data_missing() {
|
||||
// Missing the required "-" or "[...]" for STRUCTURED-DATA.
|
||||
let line = "<34>1 2003-10-11T22:14:15.003Z host app 123 ID47";
|
||||
let p = parse(line);
|
||||
assert_eq!(p.facility, None);
|
||||
assert_eq!(p.message, line);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user