///|
/// A request is the smallest unit of an exposure report. Keeping the default
/// value with the request makes reports useful for detecting accidental fallbacks
/// instead of only counting successful flag reads.
pub(all) struct ExposureRequest {
key : String
default_value : FlagValue
} derive(Debug, Eq)
///|
pub fn exposure_request(
key : String,
default_value : FlagValue,
) -> ExposureRequest {
{ key, default_value }
}
///|
pub fn ExposureRequest::key(self : ExposureRequest) -> String {
self.key
}
///|
pub fn ExposureRequest::default_kind(self : ExposureRequest) -> String {
match self.default_value {
BoolValue(_) => "bool"
StringValue(_) => "string"
IntValue(_) => "int"
DoubleValue(_) => "double"
}
}
///|
/// Aggregated observations for one flag over a stable set of request contexts.
/// The counters are mutually informative: `matches + defaults` equals the
/// number of samples, while the reason counters explain the default path.
pub(all) struct ExposureRow {
key : String
kind : String
samples : Int
matches : Int
defaults : Int
target_misses : Int
rollout_matches : Int
rollout_misses : Int
type_mismatches : Int
disabled : Int
static_matches : Int
} derive(Debug, Eq)
///|
fn new_exposure_row(request : ExposureRequest) -> ExposureRow {
{
key: request.key,
kind: request.default_kind(),
samples: 0,
matches: 0,
defaults: 0,
target_misses: 0,
rollout_matches: 0,
rollout_misses: 0,
type_mismatches: 0,
disabled: 0,
static_matches: 0,
}
}
///|
pub fn ExposureRow::matched_ratio(self : ExposureRow) -> Double {
if self.samples == 0 {
0.0
} else {
self.matches.to_double() / self.samples.to_double()
}
}
///|
pub fn ExposureRow::default_ratio(self : ExposureRow) -> Double {
if self.samples == 0 {
0.0
} else {
self.defaults.to_double() / self.samples.to_double()
}
}
///|
pub fn ExposureRow::reason_count(self : ExposureRow, reason : String) -> Int {
match reason {
"target_miss" => self.target_misses
"rollout_match" => self.rollout_matches
"rollout_miss" => self.rollout_misses
"type_mismatch" => self.type_mismatches
"disabled" => self.disabled
"static" => self.static_matches
_ => 0
}
}
///|
pub fn ExposureRow::is_consistent(self : ExposureRow) -> Bool {
self.samples == self.matches + self.defaults &&
self.samples ==
self.target_misses +
self.rollout_matches +
self.rollout_misses +
self.type_mismatches +
self.disabled +
self.static_matches
}
///|
pub fn ExposureRow::has_default_path(self : ExposureRow) -> Bool {
self.defaults > 0
}
///|
pub fn ExposureRow::has_target_misses(self : ExposureRow) -> Bool {
self.target_misses > 0
}
///|
pub fn ExposureRow::summary(self : ExposureRow) -> String {
self.key +
" [" +
self.kind +
"] samples=" +
self.samples.to_string() +
", matches=" +
self.matches.to_string() +
", defaults=" +
self.defaults.to_string() +
", target_miss=" +
self.target_misses.to_string() +
", rollout=" +
self.rollout_matches.to_string() +
"/" +
self.rollout_misses.to_string()
}
///|
pub(all) struct ExposureReport {
rows : Map[String, ExposureRow]
samples : Int
requests : Int
} derive(Debug)
///|
fn exposure_record_reason(row : ExposureRow, reason : String) -> ExposureRow {
match reason {
"target_miss" => { ..row, target_misses: row.target_misses + 1 }
"rollout_match" =>
{
..row,
matches: row.matches + 1,
rollout_matches: row.rollout_matches + 1,
}
"rollout_miss" =>
{
..row,
defaults: row.defaults + 1,
rollout_misses: row.rollout_misses + 1,
}
"type_mismatch" =>
{
..row,
defaults: row.defaults + 1,
type_mismatches: row.type_mismatches + 1,
}
"disabled" =>
{ ..row, defaults: row.defaults + 1, disabled: row.disabled + 1 }
"static" | "target_match" =>
{
..row,
matches: row.matches + 1,
static_matches: row.static_matches + 1,
}
_ => { ..row, defaults: row.defaults + 1 }
}
}
///|
fn exposure_evaluate_request(
provider : Provider,
request : ExposureRequest,
ctx : EvalContext,
) -> String {
evaluate_value(provider, request.key, ctx, default=request.default_value).reason
}
///|
/// Analyzes every request/context pair. The evaluator remains the single source
/// of truth, so the report cannot silently diverge from normal application
/// behavior.
pub fn analyze_exposure(
provider : Provider,
requests : Array[ExposureRequest],
contexts : Array[EvalContext],
) -> ExposureReport {
let rows : Map[String, ExposureRow] = Map([])
for request in requests {
let mut row = new_exposure_row(request)
for ctx in contexts {
let reason = exposure_evaluate_request(provider, request, ctx)
row = exposure_record_reason(row, reason)
}
rows[request.key] = row
}
{ rows, samples: contexts.length(), requests: requests.length() }
}
///|
pub fn ExposureReport::row(self : ExposureReport, key : String) -> ExposureRow? {
self.rows.get(key)
}
///|
pub fn ExposureReport::keys(self : ExposureReport) -> Array[String] {
let keys = []
for key in self.rows.keys() {
keys.push(key)
}
keys.sort()
keys
}
///|
pub fn ExposureReport::sample_count(self : ExposureReport) -> Int {
self.samples
}
///|
pub fn ExposureReport::request_count(self : ExposureReport) -> Int {
self.requests
}
///|
pub fn ExposureReport::evaluation_count(self : ExposureReport) -> Int {
self.samples * self.requests
}
///|
pub fn ExposureReport::default_count(self : ExposureReport) -> Int {
let mut count = 0
for _, row in self.rows {
count += row.defaults
}
count
}
///|
pub fn ExposureReport::match_count(self : ExposureReport) -> Int {
let mut count = 0
for _, row in self.rows {
count += row.matches
}
count
}
///|
pub fn ExposureReport::default_ratio(self : ExposureReport) -> Double {
let total = self.evaluation_count()
if total == 0 {
0.0
} else {
self.default_count().to_double() / total.to_double()
}
}
///|
pub fn ExposureReport::has_anomalies(self : ExposureReport) -> Bool {
for _, row in self.rows {
if row.target_misses > 0 || row.type_mismatches > 0 || !row.is_consistent() {
return true
}
}
false
}
///|
pub fn ExposureReport::inconsistent_rows(
self : ExposureReport,
) -> Array[String] {
let result = []
for key, row in self.rows {
if !row.is_consistent() {
result.push(key)
}
}
result.sort()
result
}
///|
pub fn ExposureReport::rows_with_defaults(
self : ExposureReport,
) -> Array[String] {
let result = []
for key, row in self.rows {
if row.has_default_path() {
result.push(key)
}
}
result.sort()
result
}
///|
pub fn ExposureReport::render(self : ExposureReport) -> String {
let lines = [
"exposure samples=" +
self.samples.to_string() +
", requests=" +
self.requests.to_string() +
", evaluations=" +
self.evaluation_count().to_string(),
"matches=" +
self.match_count().to_string() +
", defaults=" +
self.default_count().to_string() +
", anomalies=" +
self.has_anomalies().to_string(),
]
for key in self.keys() {
lines.push("- " + self.rows[key].summary())
}
lines.join("\n")
}
///|
pub fn ExposureReport::rows_for_prefix(
self : ExposureReport,
prefix : String,
) -> Array[ExposureRow] {
let result = []
for key in self.keys() {
if key.has_prefix(prefix) {
result.push(self.rows[key])
}
}
result
}
///|
pub fn ExposureReport::coverage_percent(self : ExposureReport) -> Double {
if self.requests == 0 {
0.0
} else {
self.rows.length().to_double() * 100.0 / self.requests.to_double()
}
}
///|
pub(all) struct ExposureAssessment {
passed : Bool
default_ratio : Double
anomalous_rows : Array[String]
inconsistent_rows : Array[String]
message : String
} derive(Debug)
///|
pub fn ExposureReport::assess(
self : ExposureReport,
threshold : ExposureThreshold,
) -> ExposureAssessment {
let anomalous = self.rows_with_target_misses()
let type_errors = self.rows_with_type_mismatches()
for key in type_errors {
if !anomalous.contains(key) {
anomalous.push(key)
}
}
anomalous.sort()
let inconsistent = self.inconsistent_rows()
let passed = self.passes(threshold)
let message = if passed {
"exposure report passes configured threshold"
} else if inconsistent.length() > 0 {
"exposure report contains inconsistent counters"
} else if anomalous.length() > 0 {
"exposure report contains fallback or targeting anomalies"
} else {
"default exposure ratio exceeds threshold"
}
{
passed,
default_ratio: self.default_ratio(),
anomalous_rows: anomalous,
inconsistent_rows: inconsistent,
message,
}
}
///|
pub fn ExposureAssessment::summary(self : ExposureAssessment) -> String {
(if self.passed { "passed" } else { "failed" }) +
": default_ratio=" +
self.default_ratio.to_string() +
", anomalies=" +
self.anomalous_rows.length().to_string() +
", inconsistent=" +
self.inconsistent_rows.length().to_string() +
"; " +
self.message
}
///|
pub fn ExposureAssessment::is_actionable(self : ExposureAssessment) -> Bool {
!self.passed &&
(self.anomalous_rows.length() > 0 || self.inconsistent_rows.length() > 0)
}
///|
pub fn ExposureReport::empty() -> ExposureReport {
{ rows: Map([]), samples: 0, requests: 0 }
}
///|
/// Produces a human-actionable next step instead of exposing raw counters to
/// an operator who has to interpret them during a release window.
pub fn ExposureReport::recommendation(self : ExposureReport) -> String {
if self.samples == 0 || self.requests == 0 {
"collect a representative sample before release"
} else if self.inconsistent_rows().length() > 0 {
"fix inconsistent exposure counters before review"
} else if self.rows_with_type_mismatches().length() > 0 {
"review flag types and caller defaults before release"
} else if self.rows_with_target_misses().length() > 0 {
"review targeting attributes and fallback behavior"
} else if self.default_ratio() > 0.0 {
"review default exposure and confirm the intended rollout"
} else {
"exposure sample is healthy for the configured checks"
}
}
///|
pub fn ExposureReport::reason_totals(self : ExposureReport) -> Map[String, Int] {
let totals : Map[String, Int] = Map([])
for _, row in self.rows {
for
reason in [
"target_miss", "rollout_match", "rollout_miss", "type_mismatch", "disabled",
"static",
] {
let count = row.reason_count(reason)
if count > 0 {
totals[reason] = totals.get(reason).unwrap_or(0) + count
}
}
}
totals
}
///|
pub fn ExposureReport::is_representative(
self : ExposureReport,
minimum~ : Int,
) -> Bool {
minimum >= 0 && self.samples >= minimum && self.requests > 0
}
///|
pub fn ExposureReport::healthy_for_release(self : ExposureReport) -> Bool {
self.samples > 0 &&
self.requests > 0 &&
!self.has_anomalies() &&
self.default_ratio() == 0.0
}
///|
pub fn ExposureReport::rollout_rows(
self : ExposureReport,
) -> Array[ExposureRow] {
let result = []
for _, row in self.rows {
if row.rollout_matches > 0 || row.rollout_misses > 0 {
result.push(row)
}
}
result
}
///|
pub fn ExposureReport::rollout_match_ratio(self : ExposureReport) -> Double {
let mut rollout = 0
let mut matched = 0
for row in self.rollout_rows() {
rollout += row.rollout_matches + row.rollout_misses
matched += row.rollout_matches
}
if rollout == 0 {
0.0
} else {
matched.to_double() / rollout.to_double()
}
}
///|
/// A stable, compact signature is useful for comparing two canary samples in
/// logs without serializing every context or flag value.
pub fn ExposureReport::compact_signature(self : ExposureReport) -> String {
let parts = [
"samples=" + self.samples.to_string(),
"requests=" + self.requests.to_string(),
"defaults=" + self.default_count().to_string(),
]
for key in self.keys() {
let row = self.rows[key]
parts.push(
key + ":" + row.matches.to_string() + "/" + row.samples.to_string(),
)
}
parts.join("|")
}
///|
pub fn ExposureReport::same_shape(
self : ExposureReport,
other : ExposureReport,
) -> Bool {
self.keys() == other.keys() && self.requests == other.requests
}
///|
pub fn ExposureReport::difference(
self : ExposureReport,
other : ExposureReport,
) -> Array[String] {
let differences = []
for key in self.keys() {
match other.row(key) {
Some(right) => {
let left = self.rows[key]
if left.matches != right.matches || left.defaults != right.defaults {
differences.push(key)
}
}
None => differences.push(key)
}
}
for key in other.keys() {
if !self.rows.contains(key) {
differences.push(key)
}
}
differences.sort()
differences
}
///|
pub fn ExposureReport::kind_summary(self : ExposureReport) -> String {
let kinds : Map[String, Int] = Map([])
for _, row in self.rows {
kinds[row.kind] = kinds.get(row.kind).unwrap_or(0) + 1
}
let names = []
for kind in kinds.keys() {
names.push(kind + "=" + kinds[kind].to_string())
}
names.sort()
names.join(", ")
}
///|
pub fn ExposureReport::targeting_miss_rate(self : ExposureReport) -> Double {
let mut misses = 0
for _, row in self.rows {
misses += row.target_misses
}
let total = self.evaluation_count()
if total == 0 {
0.0
} else {
misses.to_double() / total.to_double()
}
}
///|
pub fn ExposureReport::has_requested_key(
self : ExposureReport,
key : String,
) -> Bool {
self.rows.contains(key)
}
///|
pub fn ExposureReport::empty_rows(self : ExposureReport) -> Array[String] {
let result = []
for key, row in self.rows {
if row.samples == 0 {
result.push(key)
}
}
result.sort()
result
}
///|
pub fn ExposureReport::quality_score(self : ExposureReport) -> Int {
if self.evaluation_count() == 0 {
0
} else {
let score = 100
let score = if self.targeting_miss_rate() > 0.0 {
score - 20
} else {
score
}
let score = if self.default_ratio() > 0.0 { score - 20 } else { score }
if self.inconsistent_rows().length() > 0 {
score - 60
} else {
score
}
}
}
///|
pub fn ExposureReport::merge(
self : ExposureReport,
other : ExposureReport,
) -> ExposureReport {
let merged = self.rows.copy()
for key, right in other.rows {
match merged.get(key) {
Some(left) =>
merged[key] = {
..left,
samples: left.samples + right.samples,
matches: left.matches + right.matches,
defaults: left.defaults + right.defaults,
target_misses: left.target_misses + right.target_misses,
rollout_matches: left.rollout_matches + right.rollout_matches,
rollout_misses: left.rollout_misses + right.rollout_misses,
type_mismatches: left.type_mismatches + right.type_mismatches,
disabled: left.disabled + right.disabled,
static_matches: left.static_matches + right.static_matches,
}
None => merged[key] = right
}
}
{
rows: merged,
samples: self.samples + other.samples,
requests: self.requests + other.requests,
}
}
///|
/// A threshold turns raw exposure data into a deployable gate. It is useful in
/// CI and can also be applied to a canary sample before a production switch.
pub(all) struct ExposureThreshold {
max_default_ratio : Double
reject_target_misses : Bool
reject_type_mismatches : Bool
} derive(Debug, Eq)
///|
pub fn safe_exposure_threshold() -> ExposureThreshold {
{
max_default_ratio: 0.0,
reject_target_misses: true,
reject_type_mismatches: true,
}
}
///|
pub fn permissive_exposure_threshold() -> ExposureThreshold {
{
max_default_ratio: 1.0,
reject_target_misses: false,
reject_type_mismatches: false,
}
}
///|
pub fn ExposureReport::passes(
self : ExposureReport,
threshold : ExposureThreshold,
) -> Bool {
self.default_ratio() <= threshold.max_default_ratio &&
(
!threshold.reject_target_misses ||
self.rows_with_target_misses().length() == 0
) &&
(
!threshold.reject_type_mismatches ||
self.rows_with_type_mismatches().length() == 0
) &&
self.inconsistent_rows().length() == 0
}
///|
pub fn ExposureReport::rows_with_target_misses(
self : ExposureReport,
) -> Array[String] {
let result = []
for key, row in self.rows {
if row.target_misses > 0 {
result.push(key)
}
}
result.sort()
result
}
///|
pub fn ExposureReport::rows_with_type_mismatches(
self : ExposureReport,
) -> Array[String] {
let result = []
for key, row in self.rows {
if row.type_mismatches > 0 {
result.push(key)
}
}
result.sort()
result
}