use obs_types::{Cardinality, Classification, FieldKind, Severity, Tier};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LintProtoType {
String,
Bytes,
Numeric,
Bool,
Other(String),
}
impl LintProtoType {
#[must_use]
pub fn from_rust_token(s: &str) -> Self {
let normalised: String = s.chars().filter(|c| !c.is_whitespace()).collect();
if normalised == "String"
|| normalised.ends_with("::String")
|| normalised == "&str"
|| normalised.ends_with("::str")
{
return Self::String;
}
if normalised == "Vec<u8>"
|| normalised.ends_with("::Vec<u8>")
|| normalised == "Bytes"
|| normalised.ends_with("::Bytes")
{
return Self::Bytes;
}
if matches!(
normalised.as_str(),
"bool"
| "i8"
| "i16"
| "i32"
| "i64"
| "u8"
| "u16"
| "u32"
| "u64"
| "f32"
| "f64"
| "usize"
| "isize"
) {
return if normalised == "bool" {
Self::Bool
} else {
Self::Numeric
};
}
Self::Other(normalised)
}
}
#[derive(Debug, Clone)]
pub struct LintField {
pub name: String,
pub kind: FieldKind,
pub cardinality: Cardinality,
pub classification: Classification,
pub has_metric: bool,
pub proto_type: Option<LintProtoType>,
}
#[derive(Debug, Clone)]
pub struct LintInput {
pub event_name: String,
pub tier: Tier,
pub event_prefix: String,
pub fields: Vec<LintField>,
}
#[derive(Debug, Clone)]
pub struct LintError {
pub code: &'static str,
pub message: String,
}
impl LintError {
fn new(code: &'static str, message: String) -> Self {
Self { code, message }
}
}
#[must_use]
pub fn emit_lints(input: &LintInput) -> Vec<LintError> {
let mut out: Vec<LintError> = Vec::new();
check_l011(input, &mut out);
check_l009(input, &mut out);
for f in &input.fields {
check_per_field(input, f, &mut out);
}
out
}
#[must_use]
pub fn emit_cross_event_lints(events: &[(String, u64)]) -> Vec<LintError> {
let mut out = Vec::new();
for (i, a) in events.iter().enumerate() {
for b in events.iter().skip(i + 1) {
if a.1 == b.1 {
let msg = format!(
"obs L013: schema_hash collision: `{a}` and `{b}` both hash to \
{hash:#018x}.\nhelp: rename one event so the canonical descriptor differs \
(any field rename / reorder will do).",
a = a.0,
b = b.0,
hash = a.1,
);
out.push(LintError::new("L013", msg));
}
}
}
out
}
fn check_l011(input: &LintInput, out: &mut Vec<LintError>) {
if !input.event_name.starts_with(&input.event_prefix) {
let msg = format!(
"obs L011: event type name `{name}` must start with `{prefix}`\nnote: the `{prefix}` \
prefix gives every event type a unique visual identity at call sites.\nhelp: rename \
to `{prefix}{name}`.",
name = input.event_name,
prefix = input.event_prefix,
);
out.push(LintError::new("L011", msg));
}
}
fn check_l009(input: &LintInput, out: &mut Vec<LintError>) {
if input.fields.is_empty() {
let msg = format!(
"obs L009: event `{name}` has no fields\nnote: empty events make analytics joins \
meaningless and indicate an unfinished schema.\nhelp: declare at least one field or \
rethink whether the event should exist.",
name = input.event_name,
);
out.push(LintError::new("L009", msg));
}
}
fn check_per_field(input: &LintInput, f: &LintField, out: &mut Vec<LintError>) {
if matches!(f.kind, FieldKind::Label) && !f.cardinality.is_label_compatible() {
let msg = format!(
"obs L001: field `{name}` is LABEL but cardinality is not label-compatible\nnote: \
LABEL fields must be Low or Medium cardinality. High and Unbounded are illegal \
because they would explode the metric attribute set.\nhelp: change `kind: LABEL` to \
`kind: ATTRIBUTE` if the value is high-cardinality (an ATTRIBUTE is logged but never \
becomes a metric dim).",
name = f.name,
);
out.push(LintError::new("L001", msg));
}
if matches!(f.kind, FieldKind::Label) && matches!(f.classification, Classification::Pii) {
let msg = format!(
"obs L002: field `{name}` is LABEL with classification PII\nnote: PII fields cannot \
be LABEL because labels become metric attributes that are kept indefinitely and leak \
into vendor backends.\nhelp: change kind to ATTRIBUTE so the value is logged + \
analytics-only, and the redactor can scrub it on the durable path.",
name = f.name,
);
out.push(LintError::new("L002", msg));
}
if matches!(f.classification, Classification::Secret)
&& matches!(input.tier, Tier::Log | Tier::Audit)
{
let msg = format!(
"obs L003: field `{name}` is SECRET on a `{tier}` tier event\nnote: SECRET fields are \
forbidden on LOG/AUDIT tiers because those tiers persist payloads to long-retained \
sinks.\nhelp: move the field to a non-secret column, or move the event to \
TRACE/METRIC tier (which do not persist payload bytes).",
name = f.name,
tier = input.tier.as_str(),
);
out.push(LintError::new("L003", msg));
}
if matches!(f.kind, FieldKind::Measurement) && !f.has_metric {
let msg = format!(
"obs L004: field `{name}` is MEASUREMENT without a metric kind\nnote: MEASUREMENT \
fields must declare a metric kind (counter / gauge / histogram) so the OTLP metric \
sink can dispatch correctly.\nhelp: annotate the proto field with a metric option \
such as kind=METRIC_KIND_COUNTER and a unit string.",
name = f.name,
);
out.push(LintError::new("L004", msg));
}
if matches!(input.tier, Tier::Audit)
&& matches!(
f.classification,
Classification::Pii | Classification::Secret
)
{
let cls = match f.classification {
Classification::Pii => "PII",
Classification::Secret => "SECRET",
_ => "classified",
};
let msg = format!(
"obs L006: AUDIT-tier event must not carry `{cls}` field `{name}`\nnote: AUDIT events \
ship to long-retained immutable sinks; classified data must be redacted at the \
source.\nhelp: drop the field or move the event to a non-AUDIT tier.",
name = f.name,
);
out.push(LintError::new("L006", msg));
}
if !is_snake_case(&f.name) {
let msg = format!(
"obs L007: field `{name}` is not snake_case\nnote: every obs field name maps 1:1 to a \
proto field, OTLP attribute, and analytics column; snake_case is required so the \
projection round-trips deterministically.\nhelp: rename to `{suggest}`.",
name = f.name,
suggest = to_snake_case(&f.name),
);
out.push(LintError::new("L007", msg));
}
const RESERVED: &[&str] = &[
"ts_ns",
"service",
"instance",
"schema_hash",
"callsite_id",
"sev",
"tier",
"labels",
"payload",
"sampling_reason",
];
if !matches!(
f.kind,
FieldKind::TraceId | FieldKind::SpanId | FieldKind::ParentSpanId
) && RESERVED.contains(&f.name.as_str())
{
let msg = format!(
"obs L012: field `{name}` shadows envelope-reserved name\nnote: `{name}` is one of \
the obs envelope's first-class fields. A payload field by the same name would clash \
on the analytics surface.\nhelp: rename the field; if the intent was to project onto \
the envelope slot, set the appropriate kind (e.g. `kind: TRACE_ID`).",
name = f.name,
);
out.push(LintError::new("L012", msg));
}
if let Some(expected) = expected_correlation_name(f.kind) {
if f.name != expected {
let msg = format!(
"obs L014: field `{name}` declares `kind` as a correlation slot but is not named \
`{expected}`\nnote: codegen projects fields whose kind is TRACE_ID / SPAN_ID / \
PARENT_SPAN_ID into the envelope slot of the same name; renaming keeps the \
analytics column predictable.\nhelp: rename the field to `{expected}` or change \
the `kind` to ATTRIBUTE.",
name = f.name,
);
out.push(LintError::new("L014", msg));
}
if let Some(t) = &f.proto_type
&& !matches!(t, LintProtoType::String | LintProtoType::Other(_))
{
let actual = match t {
LintProtoType::Bytes => "bytes",
LintProtoType::Numeric => "numeric",
LintProtoType::Bool => "bool",
_ => "unknown",
};
let msg = format!(
"obs L014: field `{name}` has kind {kind} but proto type is {actual}; expected \
string\nnote: correlation slots are projected into \
`env.trace_id`/`env.span_id`/`env.parent_span_id` which are typed `string`; a \
non-string proto type would require a runtime cast.\nhelp: change the field's \
proto type to `string`.",
name = f.name,
kind = correlation_kind_label(f.kind),
);
out.push(LintError::new("L014", msg));
}
}
}
fn expected_correlation_name(k: FieldKind) -> Option<&'static str> {
match k {
FieldKind::TraceId => Some("trace_id"),
FieldKind::SpanId => Some("span_id"),
FieldKind::ParentSpanId => Some("parent_span_id"),
_ => None,
}
}
fn correlation_kind_label(k: FieldKind) -> &'static str {
match k {
FieldKind::TraceId => "TRACE_ID",
FieldKind::SpanId => "SPAN_ID",
FieldKind::ParentSpanId => "PARENT_SPAN_ID",
_ => "",
}
}
fn is_snake_case(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
&& !s.starts_with('_')
&& !s.ends_with('_')
&& !s.contains("__")
}
fn to_snake_case(s: &str) -> String {
use heck::ToSnakeCase;
s.to_snake_case()
}
#[doc(hidden)]
pub fn _ensure_severity_link(_: Severity) {}
#[cfg(test)]
mod tests {
use obs_types::{Cardinality, Classification, FieldKind, Tier};
use super::*;
fn input(prefix: &str, name: &str, tier: Tier, fields: Vec<LintField>) -> LintInput {
LintInput {
event_name: name.to_string(),
tier,
event_prefix: prefix.to_string(),
fields,
}
}
fn field(name: &str, kind: FieldKind) -> LintField {
LintField {
name: name.to_string(),
kind,
cardinality: Cardinality::Low,
classification: Classification::Internal,
has_metric: false,
proto_type: Some(LintProtoType::String),
}
}
#[test]
fn test_should_flag_l011_when_prefix_missing() {
let i = input(
"Obs",
"RequestStarted",
Tier::Log,
vec![field("a", FieldKind::Attribute)],
);
let errs = emit_lints(&i);
assert!(errs.iter().any(|e| e.code == "L011"));
}
#[test]
fn test_should_flag_l009_when_no_fields() {
let i = input("Obs", "ObsX", Tier::Log, vec![]);
let errs = emit_lints(&i);
assert!(errs.iter().any(|e| e.code == "L009"));
}
#[test]
fn test_should_flag_l001_when_label_high_cardinality() {
let mut f = field("user_id", FieldKind::Label);
f.cardinality = Cardinality::High;
let i = input("Obs", "ObsX", Tier::Log, vec![f]);
let errs = emit_lints(&i);
assert!(errs.iter().any(|e| e.code == "L001"));
}
#[test]
fn test_should_flag_l003_secret_on_log() {
let mut f = field("token", FieldKind::Attribute);
f.classification = Classification::Secret;
let i = input("Obs", "ObsX", Tier::Log, vec![f]);
let errs = emit_lints(&i);
assert!(errs.iter().any(|e| e.code == "L003"));
}
#[test]
fn test_should_flag_l014_when_wrong_name() {
let f = field("trc_id", FieldKind::TraceId);
let i = input("Obs", "ObsX", Tier::Log, vec![f]);
let errs = emit_lints(&i);
assert!(errs.iter().any(|e| e.code == "L014"));
}
#[test]
fn test_should_flag_l014_when_wrong_proto_type() {
let mut f = field("trace_id", FieldKind::TraceId);
f.proto_type = Some(LintProtoType::Bytes);
let i = input("Obs", "ObsX", Tier::Log, vec![f]);
let errs = emit_lints(&i);
assert!(errs.iter().any(|e| e.code == "L014"));
}
#[test]
fn test_should_pass_when_correlation_field_correct() {
let f = field("trace_id", FieldKind::TraceId);
let i = input("Obs", "ObsX", Tier::Log, vec![f]);
let errs = emit_lints(&i);
assert!(errs.iter().all(|e| e.code != "L014"));
}
#[test]
fn test_should_detect_l013_collision() {
let pairs = vec![("a.v1.X".to_string(), 1u64), ("a.v1.Y".to_string(), 1u64)];
let errs = emit_cross_event_lints(&pairs);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, "L013");
}
#[test]
fn test_should_skip_l013_when_unique() {
let pairs = vec![("a.v1.X".to_string(), 1u64), ("a.v1.Y".to_string(), 2u64)];
assert!(emit_cross_event_lints(&pairs).is_empty());
}
#[test]
fn test_should_recognize_string_rust_token() {
assert_eq!(
LintProtoType::from_rust_token("String"),
LintProtoType::String
);
assert_eq!(
LintProtoType::from_rust_token("::std::string::String"),
LintProtoType::String
);
assert!(matches!(
LintProtoType::from_rust_token("Vec < u8 >"),
LintProtoType::Bytes
));
}
}