///|
/// Explainable changes detected between two logfmt batches.
pub(all) enum DriftKind {
DriftAddedField
DriftRemovedField
DriftTypeChanged
DriftTypeConfidenceDrop
DriftPrevalenceIncrease
DriftPrevalenceDecrease
DriftValueLengthGrowth
DriftNewShape
DriftRetiredShape
DriftInvalidRateIncrease
DriftRiskIncrease
} derive(Eq, Debug)
///|
pub fn DriftKind::label(self : DriftKind) -> String {
match self {
DriftAddedField => "added_field"
DriftRemovedField => "removed_field"
DriftTypeChanged => "type_changed"
DriftTypeConfidenceDrop => "type_confidence_drop"
DriftPrevalenceIncrease => "prevalence_increase"
DriftPrevalenceDecrease => "prevalence_decrease"
DriftValueLengthGrowth => "value_length_growth"
DriftNewShape => "new_shape"
DriftRetiredShape => "retired_shape"
DriftInvalidRateIncrease => "invalid_rate_increase"
DriftRiskIncrease => "risk_increase"
}
}
///|
pub struct DriftPolicy {
prevalence_delta : Int
confidence_drop : Int
max_length_growth : Int
invalid_rate_delta : Int
average_risk_delta : Int
min_field_prevalence : Int
report_retired_shapes : Bool
} derive(Eq, Debug)
///|
pub fn DriftPolicy::default() -> DriftPolicy {
{
prevalence_delta: 20,
confidence_drop: 20,
max_length_growth: 32,
invalid_rate_delta: 5,
average_risk_delta: 3,
min_field_prevalence: 10,
report_retired_shapes: false,
}
}
///|
pub fn DriftPolicy::ci() -> DriftPolicy {
{
prevalence_delta: 10,
confidence_drop: 10,
max_length_growth: 16,
invalid_rate_delta: 0,
average_risk_delta: 1,
min_field_prevalence: 5,
report_retired_shapes: true,
}
}
///|
pub fn DriftPolicy::exploratory() -> DriftPolicy {
{
prevalence_delta: 30,
confidence_drop: 30,
max_length_growth: 64,
invalid_rate_delta: 10,
average_risk_delta: 5,
min_field_prevalence: 25,
report_retired_shapes: false,
}
}
///|
pub fn DriftPolicy::with_prevalence_delta(
self : DriftPolicy,
prevalence_delta : Int,
) -> DriftPolicy {
{ ..self, prevalence_delta, }
}
///|
pub fn DriftPolicy::with_invalid_budget(
self : DriftPolicy,
invalid_rate_delta : Int,
) -> DriftPolicy {
{ ..self, invalid_rate_delta, }
}
///|
pub fn DriftPolicy::with_retired_shapes(
self : DriftPolicy,
report_retired_shapes : Bool,
) -> DriftPolicy {
{ ..self, report_retired_shapes, }
}
///|
pub struct DriftFinding {
kind : DriftKind
severity : Severity
subject : String
baseline : String
current : String
message : String
} derive(Eq, Debug)
///|
pub fn DriftFinding::kind(self : DriftFinding) -> DriftKind {
self.kind
}
///|
pub fn DriftFinding::severity(self : DriftFinding) -> Severity {
self.severity
}
///|
pub fn DriftFinding::subject(self : DriftFinding) -> String {
self.subject
}
///|
pub fn DriftFinding::baseline(self : DriftFinding) -> String {
self.baseline
}
///|
pub fn DriftFinding::current(self : DriftFinding) -> String {
self.current
}
///|
pub fn DriftFinding::message(self : DriftFinding) -> String {
self.message
}
///|
pub struct DriftReport {
baseline_lines : Int
current_lines : Int
findings : Array[DriftFinding]
} derive(Eq, Debug)
///|
pub fn DriftReport::baseline_lines(self : DriftReport) -> Int {
self.baseline_lines
}
///|
pub fn DriftReport::current_lines(self : DriftReport) -> Int {
self.current_lines
}
///|
pub fn DriftReport::findings(self : DriftReport) -> Array[DriftFinding] {
self.findings
}
///|
pub fn DriftReport::finding_count(self : DriftReport) -> Int {
self.findings.length()
}
///|
pub fn DriftReport::critical_count(self : DriftReport) -> Int {
let mut count = 0
for finding in self.findings {
if finding.severity() == Critical {
count = count + 1
}
}
count
}
///|
pub fn DriftReport::warning_count(self : DriftReport) -> Int {
let mut count = 0
for finding in self.findings {
if finding.severity() == Warning {
count = count + 1
}
}
count
}
///|
pub fn DriftReport::risk_score(self : DriftReport) -> Int {
let mut score = 0
for finding in self.findings {
score = score + finding.severity().score()
}
score
}
///|
pub fn DriftReport::decision(self : DriftReport) -> String {
if self.critical_count() > 0 {
"reject"
} else if self.warning_count() > 0 {
"review"
} else {
"accept"
}
}
///|
pub fn DriftReport::text_report(self : DriftReport) -> String {
let mut output = "MoonLogfmt drift report\n"
output = output + "baseline_lines: " + self.baseline_lines.to_string() + "\n"
output = output + "current_lines: " + self.current_lines.to_string() + "\n"
output = output + "findings: " + self.finding_count().to_string() + "\n"
output = output + "risk_score: " + self.risk_score().to_string() + "\n"
output = output + "decision: " + self.decision() + "\n"
if self.findings.length() == 0 {
return output + "\nNo material drift detected."
}
output = output + "\nChanges:\n"
for finding in self.findings {
output = output +
"- [" +
finding.severity().label() +
"] " +
finding.kind().label() +
" subject=" +
finding.subject()
if finding.baseline() != "" {
output = output + " baseline=" + finding.baseline()
}
if finding.current() != "" {
output = output + " current=" + finding.current()
}
output = output + ": " + finding.message() + "\n"
}
output
}
///|
pub fn DriftReport::json_report(self : DriftReport) -> String {
let mut output = "{"
output = output +
"\"baseline_lines\":" +
self.baseline_lines.to_string() +
","
output = output + "\"current_lines\":" + self.current_lines.to_string() + ","
output = output + "\"findings\":" + self.finding_count().to_string() + ","
output = output + "\"risk_score\":" + self.risk_score().to_string() + ","
output = output + "\"decision\":\"" + self.decision() + "\","
output = output + "\"items\":["
for index = 0; index < self.findings.length(); index = index + 1 {
let finding = self.findings[index]
if index > 0 {
output = output + ","
}
output = output + "{"
output = output + "\"kind\":\"" + finding.kind().label() + "\","
output = output + "\"severity\":\"" + finding.severity().label() + "\","
output = output + "\"subject\":\"" + escape_json(finding.subject()) + "\","
output = output +
"\"baseline\":\"" +
escape_json(finding.baseline()) +
"\","
output = output + "\"current\":\"" + escape_json(finding.current()) + "\","
output = output + "\"message\":\"" + escape_json(finding.message()) + "\""
output = output + "}"
}
output + "]}"
}
///|
pub fn compare_batches(
baseline : BatchReport,
current : BatchReport,
policy? : DriftPolicy = DriftPolicy::default(),
) -> DriftReport {
let findings : Array[DriftFinding] = []
drift_compare_profiles(baseline, current, policy, findings)
drift_compare_shapes(baseline, current, policy, findings)
let invalid_delta = current.invalid_percent() - baseline.invalid_percent()
if invalid_delta > policy.invalid_rate_delta {
findings.push({
kind: DriftInvalidRateIncrease,
severity: Critical,
subject: "batch",
baseline: baseline.invalid_percent().to_string() + "%",
current: current.invalid_percent().to_string() + "%",
message: "invalid logfmt rate increased beyond the configured budget",
})
}
let risk_delta = current.average_risk_score() - baseline.average_risk_score()
if risk_delta > policy.average_risk_delta {
findings.push({
kind: DriftRiskIncrease,
severity: Warning,
subject: "batch",
baseline: baseline.average_risk_score().to_string(),
current: current.average_risk_score().to_string(),
message: "average audit risk increased beyond the configured budget",
})
}
{
baseline_lines: baseline.processed_lines(),
current_lines: current.processed_lines(),
findings,
}
}
///|
/// Freezes a reusable contract from a previously analyzed baseline.
pub fn contract_from_batch(
report : BatchReport,
name? : String = "batch-baseline",
required_percent? : Int = 95,
unknown_fields? : UnknownFieldPolicy = UnknownWarn,
) -> LogContract {
let rules : Array[FieldRule] = []
for profile in report.profiles() {
let required = profile.prevalence_percent(report.valid_lines()) >=
required_percent
let mut kind = profile.dominant_kind()
if profile.type_confidence() < 80 {
kind = ValueText
}
let mut rule = FieldRule::typed(profile.key(), kind, required~).with_max_length(
profile.max_length(),
)
if profile.distribution().count(ValueEmpty) > 0 {
rule = rule.with_blank(true)
}
if profile.distribution().count(ValueFlag) > 0 {
rule = rule.with_flag(true)
}
if profile.distinct_values().length() > 0 &&
profile.distinct_values().length() <= 8 &&
contract_can_infer_enum(profile.key(), kind) {
rule = rule.with_allowed_values(profile.distinct_values())
}
rules.push(rule)
}
let max_fields = if rules.length() < 8 { 8 } else { rules.length() + 4 }
LogContract::new(name, rules, unknown_fields~, max_fields~)
}
///|
fn drift_compare_profiles(
baseline : BatchReport,
current : BatchReport,
policy : DriftPolicy,
findings : Array[DriftFinding],
) -> Unit {
for current_profile in current.profiles() {
match baseline.profile_for(current_profile.key()) {
None => {
let prevalence = current_profile.prevalence_percent(
current.valid_lines(),
)
if prevalence >= policy.min_field_prevalence {
findings.push({
kind: DriftAddedField,
severity: Warning,
subject: current_profile.key(),
baseline: "absent",
current: prevalence.to_string() + "%",
message: "field appeared with material prevalence",
})
}
}
Some(baseline_profile) => {
let baseline_prevalence = baseline_profile.prevalence_percent(
baseline.valid_lines(),
)
let current_prevalence = current_profile.prevalence_percent(
current.valid_lines(),
)
let prevalence_delta = current_prevalence - baseline_prevalence
if prevalence_delta >= policy.prevalence_delta {
findings.push({
kind: DriftPrevalenceIncrease,
severity: Info,
subject: current_profile.key(),
baseline: baseline_prevalence.to_string() + "%",
current: current_prevalence.to_string() + "%",
message: "field became materially more common",
})
} else if prevalence_delta <= -policy.prevalence_delta {
findings.push({
kind: DriftPrevalenceDecrease,
severity: Warning,
subject: current_profile.key(),
baseline: baseline_prevalence.to_string() + "%",
current: current_prevalence.to_string() + "%",
message: "field became materially less common",
})
}
if baseline_profile.dominant_kind() != current_profile.dominant_kind() &&
baseline_profile.type_confidence() >= 70 &&
current_profile.type_confidence() >= 70 {
findings.push({
kind: DriftTypeChanged,
severity: Critical,
subject: current_profile.key(),
baseline: baseline_profile.dominant_kind().label(),
current: current_profile.dominant_kind().label(),
message: "dominant semantic value type changed",
})
}
let confidence_delta = baseline_profile.type_confidence() -
current_profile.type_confidence()
if confidence_delta >= policy.confidence_drop {
findings.push({
kind: DriftTypeConfidenceDrop,
severity: Warning,
subject: current_profile.key(),
baseline: baseline_profile.type_confidence().to_string() + "%",
current: current_profile.type_confidence().to_string() + "%",
message: "field values became less type-consistent",
})
}
let length_delta = current_profile.max_length() -
baseline_profile.max_length()
if length_delta > policy.max_length_growth {
findings.push({
kind: DriftValueLengthGrowth,
severity: Warning,
subject: current_profile.key(),
baseline: baseline_profile.max_length().to_string(),
current: current_profile.max_length().to_string(),
message: "maximum observed value length grew materially",
})
}
}
}
}
for baseline_profile in baseline.profiles() {
match current.profile_for(baseline_profile.key()) {
Some(_) => ()
None => {
let prevalence = baseline_profile.prevalence_percent(
baseline.valid_lines(),
)
if prevalence >= policy.min_field_prevalence {
findings.push({
kind: DriftRemovedField,
severity: Critical,
subject: baseline_profile.key(),
baseline: prevalence.to_string() + "%",
current: "absent",
message: "previously material field disappeared",
})
}
}
}
}
}
///|
fn drift_compare_shapes(
baseline : BatchReport,
current : BatchReport,
policy : DriftPolicy,
findings : Array[DriftFinding],
) -> Unit {
for shape in current.shapes() {
match baseline.shape_for_fingerprint(shape.fingerprint()) {
Some(existing) =>
if existing.shape() != shape.shape() {
findings.push({
kind: DriftNewShape,
severity: Critical,
subject: shape.fingerprint(),
baseline: existing.shape(),
current: shape.shape(),
message: "shape fingerprint collision detected",
})
}
None =>
findings.push({
kind: DriftNewShape,
severity: Warning,
subject: shape.fingerprint(),
baseline: "absent",
current: shape.shape(),
message: "new value-free record shape appeared",
})
}
}
if policy.report_retired_shapes {
for shape in baseline.shapes() {
match current.shape_for_fingerprint(shape.fingerprint()) {
Some(_) => ()
None =>
findings.push({
kind: DriftRetiredShape,
severity: Info,
subject: shape.fingerprint(),
baseline: shape.shape(),
current: "absent",
message: "baseline record shape was not observed",
})
}
}
}
}