From 8b91ad844efe93278fb14de4ebf1ba1819852f00 Mon Sep 17 00:00:00 2001 From: jaronoff97 Date: Tue, 7 Jul 2026 17:59:14 -0400 Subject: [PATCH] fix: require io and better type checking --- README.md | 1 + src/bench/main.zig | 6 +- src/policy/log_transform.zig | 118 ++++++++++++++++++++++++-- src/policy/matcher_index.zig | 63 +++++++++++++- src/policy/parser.zig | 57 ++++++++++++- src/policy/policy_engine.zig | 121 ++++++++++++++++++++------- src/policy/probabilistic_sampler.zig | 13 +-- src/policy/types.zig | 15 ++++ 8 files changed, 342 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9945cb1..3b7d31a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ const engine = policy.PolicyEngine.init(bus, ®istry); var policy_id_buf: [16][]const u8 = undefined; const result = engine.evaluate(.log, &my_log_accessor, &my_log_ctx, &policy_id_buf, .{ .scratch = arena.allocator(), + .io = io, }); // Transforms whose required primitive (set/delete/move) is unwired on the diff --git a/src/bench/main.zig b/src/bench/main.zig index 4744e70..9e8f457 100644 --- a/src/bench/main.zig +++ b/src/bench/main.zig @@ -155,7 +155,7 @@ const LogState = struct { pub fn run(self: *LogState, alloc: std.mem.Allocator) void { _ = alloc; - _ = self.engine.evaluate(.log, &BenchLog.accessor, &self.ctx, &self.policy_id_buf, .{}); + _ = self.engine.evaluate(.log, &BenchLog.accessor, &self.ctx, &self.policy_id_buf, .{ .io = std.Options.debug_io }); } }; @@ -190,7 +190,7 @@ const MetricState = struct { pub fn run(self: *MetricState, alloc: std.mem.Allocator) void { _ = alloc; - _ = self.engine.evaluate(.metric, &BenchMetric.accessor, &self.ctx, &self.policy_id_buf, .{}); + _ = self.engine.evaluate(.metric, &BenchMetric.accessor, &self.ctx, &self.policy_id_buf, .{ .io = std.Options.debug_io }); } }; @@ -225,7 +225,7 @@ const TraceState = struct { pub fn run(self: *TraceState, alloc: std.mem.Allocator) void { _ = alloc; - _ = self.engine.evaluate(.trace, &BenchTrace.accessor, &self.ctx, &self.policy_id_buf, .{}); + _ = self.engine.evaluate(.trace, &BenchTrace.accessor, &self.ctx, &self.policy_id_buf, .{ .io = std.Options.debug_io }); } }; diff --git a/src/policy/log_transform.zig b/src/policy/log_transform.zig index 5443fee..e0b2888 100644 --- a/src/policy/log_transform.zig +++ b/src/policy/log_transform.zig @@ -202,9 +202,12 @@ pub fn applyRemove( /// /// When `options.compiled` is null, replaces the entire field value with /// `rule.replacement`. When non-null, runs the pre-compiled regex over the -/// `.string` value returned by `accessor.typed_value` and substitutes each -/// match using the pre-parsed replacement template. If the field is missing -/// or non-string, or the regex finds no match, the operation is a no-op. +/// value returned by `accessor.typed_value` and substitutes each match using +/// the pre-parsed replacement template. `.bytes` values (e.g. trace/span ids) +/// are hex-rendered first so regex redact can still target them, mirroring +/// how the engine hex-renders `.bytes` for string matchers. If the field is +/// missing, non-textual (bool/int/double), or the regex finds no match, the +/// operation is a no-op. /// /// Returns true if the field was redacted. pub fn applyRedact( @@ -220,8 +223,12 @@ pub fn applyRedact( const allocator = options.scratch orelse return false; const io = options.io orelse return false; const tv = accessor.typed_value(ctx, field_ref) orelse return false; - if (tv != .string) return false; // regex redact no-ops on non-string values - const value = tv.string; + var hex_buf: [types.max_hex_id_bytes * 2]u8 = undefined; + const value = switch (tv) { + .string => |s| s, + .bytes => |b| types.hexRenderId(b, &hex_buf), + .bool, .int, .double => return false, // regex redact no-ops on non-textual values + }; var out: std.ArrayList(u8) = .empty; // No deinit: `out.items` is handed to the accessor.set by reference @@ -647,6 +654,107 @@ test "applyRedact: regex on missing field is a no-op" { try testing.expect(!result); } +test "applyRedact: regex matches hex-rendered .bytes field (e.g. trace_id)" { + // Regression: identifier fields (LOG_FIELD_TRACE_ID/SPAN_ID) surface as + // `.bytes` via typed_value. Regex redact must hex-render them (like the + // engine does for string matchers) instead of no-oping. + const BytesCtx = struct { + const Self = @This(); + + stored: ?[]const u8 = null, + + fn valueFn(ctx: *const anyopaque, field: FieldRef) ?types.TypedValue { + _ = field; + const self: *const Self = @ptrCast(@alignCast(ctx)); + return .{ .bytes = self.stored orelse return null }; + } + + fn setFn(ctx: *anyopaque, field: FieldRef, value: []const u8) void { + _ = field; + const self: *Self = @ptrCast(@alignCast(ctx)); + self.stored = value; + } + + fn deleteFn(_: *anyopaque, _: FieldRef) bool { + return false; + } + fn moveFn(_: *anyopaque, _: FieldRef, _: []const u8) void {} + const accessor: LogAccessor = .{ + .typed_value = valueFn, + .set = setFn, + .delete = deleteFn, + .move = moveFn, + }; + }; + + const allocator = testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + // Raw bytes for trace_id 0xdeadbeef... + var ctx: BytesCtx = .{ .stored = &.{ 0xde, 0xad, 0xbe, 0xef } }; + + var rule: LogRedact = .{ + .field = .{ .log_field = .LOG_FIELD_TRACE_ID }, + .replacement = "$1", + .regex = "^(dead)beef$", + }; + + var compiled = try redact_mod.Compiled.init(allocator, rule.regex.?, rule.replacement); + defer compiled.deinit(); + + const result = applyRedact(&BytesCtx.accessor, &rule, @ptrCast(&ctx), .{ + .compiled = &compiled, + .scratch = arena.allocator(), + .io = std.Options.debug_io, + }); + try testing.expect(result); + try testing.expectEqualStrings("dead", ctx.stored.?); +} + +test "applyRedact: regex no-ops on non-textual (bool/int/double) typed value" { + const NumCtx = struct { + const Self = @This(); + fn valueFn(ctx: *const anyopaque, field: FieldRef) ?types.TypedValue { + _ = ctx; + _ = field; + return .{ .int = 42 }; + } + fn setFn(_: *anyopaque, _: FieldRef, _: []const u8) void {} + fn deleteFn(_: *anyopaque, _: FieldRef) bool { + return false; + } + fn moveFn(_: *anyopaque, _: FieldRef, _: []const u8) void {} + const accessor: LogAccessor = .{ + .typed_value = valueFn, + .set = setFn, + .delete = deleteFn, + .move = moveFn, + }; + }; + + const allocator = testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + var ctx: NumCtx = .{}; + + var rule: LogRedact = .{ + .field = .{ .log_attribute = testAttrPath("count") }, + .replacement = "X", + .regex = "\\d+", + }; + + var compiled = try redact_mod.Compiled.init(allocator, rule.regex.?, rule.replacement); + defer compiled.deinit(); + + const result = applyRedact(&NumCtx.accessor, &rule, @ptrCast(&ctx), .{ + .compiled = &compiled, + .scratch = arena.allocator(), + .io = std.Options.debug_io, + }); + try testing.expect(!result); +} + test "applyRename: renames existing field" { const allocator = testing.allocator; var ctx = TestContext.init(allocator); diff --git a/src/policy/matcher_index.zig b/src/policy/matcher_index.zig index 1c6c897..0f7e82c 100644 --- a/src/policy/matcher_index.zig +++ b/src/policy/matcher_index.zig @@ -1066,10 +1066,13 @@ fn IndexBuilder(comptime T: TelemetryType) type { return null; }, // equals.string_value compiles to a Hyperscan pattern like - // `exact`; other equals variants are typed checks. + // `exact`, but as an escaped literal so it matches the raw + // string rather than being interpreted as regex syntax; + // other equals variants are typed checks. .equals => |v| if (v.value != null and v.value.? == .string_value) { const p = v.value.?.string_value; - if (!self.patternCompiles(p, .exact, matcher.case_insensitive)) { + const escaped = try escapeRegexLiteral(self.temp_allocator, p); + if (!self.patternCompiles(escaped, .exact, matcher.case_insensitive)) { try self.recordError(signal ++ ": match[{d}]: invalid pattern \"{s}\"", policy_id, .{ idx, p }); return null; } @@ -1289,7 +1292,7 @@ fn IndexBuilder(comptime T: TelemetryType) type { else => {}, } - const pattern, const pattern_match_type, const negate = switch (m) { + const raw_pattern, const pattern_match_type, const negate = switch (m) { .regex => |r| .{ r, match_type.regex, matcher.negate }, .exact => |e| .{ e, match_type.exact, matcher.negate }, .equals => |v| .{ v.value.?.string_value, match_type.exact, matcher.negate }, @@ -1300,7 +1303,20 @@ fn IndexBuilder(comptime T: TelemetryType) type { .gt, .gte, .lt, .lte => unreachable, }; - if (pattern.len == 0) { + // equals.string_value is typed literal equality (documented as + // such, unlike the regex-flavored `exact`), so escape regex + // metacharacters before it's compiled as a Hyperscan pattern. + // temp_allocator is fine here: the pattern text is only read + // while compiling the Hyperscan database in finish(), which + // runs before the builder's arena is torn down. + const pattern = if (m == .equals) try escapeRegexLiteral(self.temp_allocator, raw_pattern) else raw_pattern; + + // Only regex/starts_with/ends_with/contains are trivially + // always-true when empty, so only they can be skipped as a + // no-op matcher; an empty `exact`/`equals` pattern is a + // meaningful "field is the empty string" check and must still + // be compiled (as `^$`). + if (pattern.len == 0 and pattern_match_type != .exact) { self.bus.debug(MatcherEmptyRegex{ .matcher_idx = matcher_idx }); return; } @@ -2158,6 +2174,24 @@ fn compilePatterns( return .{ .db = db, .meta = meta }; } +/// Escape regex metacharacters so `literal` can be embedded in a Hyperscan +/// pattern (via the `.exact` match type) and matched byte-for-byte, rather +/// than interpreted as regex syntax. Used for `equals.string_value`, which +/// is documented as typed string equality, not pattern matching. +fn escapeRegexLiteral(allocator: std.mem.Allocator, literal: []const u8) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + try out.ensureTotalCapacity(allocator, literal.len); + for (literal) |c| { + switch (c) { + '\\', '^', '$', '.', '|', '?', '*', '+', '(', ')', '[', ']', '{', '}' => try out.append(allocator, '\\'), + else => {}, + } + try out.append(allocator, c); + } + return out.toOwnedSlice(allocator); +} + fn formatPattern(buf: *[]u8, pattern: []const u8, pattern_match_type: match_type) []const u8 { const anchor_start, const anchor_end = switch (pattern_match_type) { .regex => return pattern, @@ -3163,6 +3197,27 @@ test "formatPattern: contains returns pattern unchanged" { try testing.expectEqualStrings("password", result); } +test "escapeRegexLiteral: escapes regex metacharacters" { + const allocator = testing.allocator; + const escaped = try escapeRegexLiteral(allocator, "a.b(c)[d]$e^f"); + defer allocator.free(escaped); + try testing.expectEqualStrings("a\\.b\\(c\\)\\[d\\]\\$e\\^f", escaped); +} + +test "escapeRegexLiteral: plain string is unchanged" { + const allocator = testing.allocator; + const escaped = try escapeRegexLiteral(allocator, "checkout-api"); + defer allocator.free(escaped); + try testing.expectEqualStrings("checkout-api", escaped); +} + +test "escapeRegexLiteral: empty string stays empty" { + const allocator = testing.allocator; + const escaped = try escapeRegexLiteral(allocator, ""); + defer allocator.free(escaped); + try testing.expectEqualStrings("", escaped); +} + test "Log matcher with starts_with" { const allocator = testing.allocator; var noop_bus: NoopEventBus = undefined; diff --git a/src/policy/parser.zig b/src/policy/parser.zig index 68ba01e..2d32b52 100644 --- a/src/policy/parser.zig +++ b/src/policy/parser.zig @@ -534,24 +534,29 @@ fn parseValue(allocator: std.mem.Allocator, json_val: std.json.Value) !Value { .string => |s| return Value{ .value = .{ .string_value = try allocator.dupe(u8, s) } }, .object => |obj| { if (obj.get("bool_value")) |v| { + if (v != .bool) return error.InvalidValue; return Value{ .value = .{ .bool_value = v.bool } }; } if (obj.get("int_value")) |v| { + if (v != .integer) return error.InvalidValue; return Value{ .value = .{ .int_value = v.integer } }; } if (obj.get("double_value")) |v| { + if (v != .float) return error.InvalidValue; return Value{ .value = .{ .double_value = v.float } }; } if (obj.get("hex_value")) |v| { - const hex_str = v.string; - const bytes = try hexDecode(allocator, hex_str); + if (v != .string) return error.InvalidValue; + const bytes = try hexDecode(allocator, v.string); return Value{ .value = .{ .bytes_value = bytes } }; } if (obj.get("bytes_value")) |v| { + if (v != .string) return error.InvalidValue; const bytes = try allocator.dupe(u8, v.string); return Value{ .value = .{ .bytes_value = bytes } }; } if (obj.get("string_value")) |v| { + if (v != .string) return error.InvalidValue; return Value{ .value = .{ .string_value = try allocator.dupe(u8, v.string) } }; } return error.InvalidValue; @@ -2693,6 +2698,54 @@ test "parseValue: string_value canonical (v1.6.0)" { try std.testing.expectEqualStrings("checkout-api", v.value.?.string_value); } +test "parseValue: string_value with non-string JSON type returns InvalidValue" { + const allocator = std.testing.allocator; + var obj = std.json.ObjectMap.empty; + defer obj.deinit(allocator); + try obj.put(allocator, "string_value", .{ .integer = 123 }); + try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj })); +} + +test "parseValue: bool_value with non-bool JSON type returns InvalidValue" { + const allocator = std.testing.allocator; + var obj = std.json.ObjectMap.empty; + defer obj.deinit(allocator); + try obj.put(allocator, "bool_value", .{ .string = "true" }); + try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj })); +} + +test "parseValue: int_value with non-integer JSON type returns InvalidValue" { + const allocator = std.testing.allocator; + var obj = std.json.ObjectMap.empty; + defer obj.deinit(allocator); + try obj.put(allocator, "int_value", .{ .string = "200" }); + try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj })); +} + +test "parseValue: double_value with non-float JSON type returns InvalidValue" { + const allocator = std.testing.allocator; + var obj = std.json.ObjectMap.empty; + defer obj.deinit(allocator); + try obj.put(allocator, "double_value", .{ .integer = 5 }); + try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj })); +} + +test "parseValue: hex_value with non-string JSON type returns InvalidValue" { + const allocator = std.testing.allocator; + var obj = std.json.ObjectMap.empty; + defer obj.deinit(allocator); + try obj.put(allocator, "hex_value", .{ .integer = 5 }); + try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj })); +} + +test "parseValue: bytes_value with non-string JSON type returns InvalidValue" { + const allocator = std.testing.allocator; + var obj = std.json.ObjectMap.empty; + defer obj.deinit(allocator); + try obj.put(allocator, "bytes_value", .{ .bool = true }); + try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .object = obj })); +} + test "parseNumericValue: int" { const v = try parseNumericValue(.{ .integer = 500 }); try std.testing.expect(v.value.? == .int_value); diff --git a/src/policy/policy_engine.zig b/src/policy/policy_engine.zig index 6b04477..9bd7a8d 100644 --- a/src/policy/policy_engine.zig +++ b/src/policy/policy_engine.zig @@ -193,20 +193,8 @@ fn isHexBytesField(comptime T: TelemetryType, field_ref: FieldRefType(T)) bool { }; } -/// trace_id is 16 bytes, span_id 8 — 32 covers both with headroom. -const max_hex_id_bytes = 32; - -/// Render raw id bytes as lowercase hex into `buf`. Returns the rendered slice, -/// or the raw bytes unchanged when they don't fit (degrade, never overflow). -fn hexRenderId(bytes: []const u8, buf: []u8) []const u8 { - if (bytes.len * 2 > buf.len) return bytes; - const hex = "0123456789abcdef"; - for (bytes, 0..) |b, i| { - buf[i * 2] = hex[b >> 4]; - buf[i * 2 + 1] = hex[b & 0x0f]; - } - return buf[0 .. bytes.len * 2]; -} +const max_hex_id_bytes = policy_types.max_hex_id_bytes; +const hexRenderId = policy_types.hexRenderId; /// Runtime inputs to `PolicyEngine.evaluate`. /// @@ -227,11 +215,11 @@ fn hexRenderId(bytes: []const u8, buf: []u8) []const u8 { pub const EvaluateOptions = struct { scratch: ?std.mem.Allocator = null, /// Io for the io-dependent decision paths: rate-limiter timestamps - /// (`per_second`/`per_minute` policies) and regex-redact mutex - /// serialization. Only consulted when such policies fire; a null `io` - /// degrades those paths gracefully (rate limit defaults to keep, regex - /// redact no-ops) exactly like a null `scratch`. - io: ?std.Io = null, + /// (`per_second`/`per_minute` policies), unkeyed percentage sampling + /// (needs a random source), and regex-redact mutex serialization. + /// Required — without it, rate limiting and unkeyed percentage sampling + /// cannot make a real decision, so there is no safe default to degrade to. + io: std.Io, }; // ============================================================================= @@ -374,7 +362,7 @@ pub const PolicyEngine = struct { // Phase 2: Find matching policies and determine decision var match_state: MatchState = undefined; - self.findMatchingPolicies(T, accessor, options.io, ctx, index, &scan_state, policy_id_buf, &match_state); + self.findMatchingPolicies(options.io, T, accessor, ctx, index, &scan_state, policy_id_buf, &match_state); const result_event: EvaluateResult = .{ .decision = match_state.decision, @@ -395,8 +383,8 @@ pub const PolicyEngine = struct { for (0..match_state.matched_count) |i| { const policy_index = match_state.matched_indices[i]; const result = self.applyLogTransforms( - accessor, options.io, + accessor, ctx, snapshot, policy_index, @@ -648,9 +636,9 @@ pub const PolicyEngine = struct { /// the actual W3C tracestate header as `ot=th:VALUE`. inline fn findMatchingPolicies( self: *const PolicyEngine, + io: std.Io, comptime T: TelemetryType, comptime accessor: *const AccessorType(T), - io: ?std.Io, ctx: *anyopaque, index: *const matcher_index.MatcherIndexType(T), scan_state: *const ScanState, @@ -756,8 +744,8 @@ pub const PolicyEngine = struct { /// Returns the transform result for stats recording. inline fn applyLogTransforms( self: *const PolicyEngine, + io: std.Io, comptime accessor: *const LogAccessor, - io: ?std.Io, ctx: *anyopaque, snapshot: *const PolicySnapshot, policy_index: PolicyIndex, @@ -850,17 +838,14 @@ pub const PolicyEngine = struct { /// Apply policy's keep value for non-percentage policies. /// Percentage sampling is handled by ProbabilisticSampler in findMatchingPolicies. - fn applyKeepValue(io: ?std.Io, policy_info: *const PolicyInfo) FilterDecision { + fn applyKeepValue(io: std.Io, policy_info: *const PolicyInfo) FilterDecision { return switch (policy_info.keep) { .none => .drop, .all => .keep, .percentage => .keep, // Should not reach here; handled by ProbabilisticSampler .per_second, .per_minute => { if (policy_info.rate_limiter) |rl| { - // Without io we cannot read the clock to rate-limit; default - // to keep (same as "no rate limiter configured"). - const real_io = io orelse return .keep; - return if (rl.shouldKeep(real_io)) .keep else .drop; + return if (rl.shouldKeep(io)) .keep else .drop; } return .keep; // No rate limiter configured, default to keep }, @@ -2314,6 +2299,86 @@ test "typed: equals string_value with case_insensitive (v1.6.0)" { try testing.expectEqual(FilterDecision.drop, evalTypedLog(&engine, &ctx, &policy_id_buf).decision); } +test "typed: equals string_value treats regex metacharacters as literal" { + const allocator = testing.allocator; + + const sv: proto.policy.Value = .{ .value = .{ .string_value = try allocator.dupe(u8, "a.b") } }; + var policy = try makeLogTypedPolicy(allocator, "drop-literal", "service.name", .{ .equals = sv }, "none"); + defer policy.deinit(allocator); + + var noop_bus: NoopEventBus = undefined; + noop_bus.init(std.Options.debug_io); + var registry = PolicyRegistry.init(allocator, noop_bus.eventBus()); + defer registry.deinit(); + try registry.updatePolicies(&.{policy}, "test", .file); + + const engine = PolicyEngine.init(noop_bus.eventBus(), ®istry); + var policy_id_buf: [16][]const u8 = undefined; + + // Exact literal match still fires. + var ctx = TypedLogContext.init(allocator); + defer ctx.deinit(); + _ = try ctx.withString("service.name", "a.b"); + try testing.expectEqual(FilterDecision.drop, evalTypedLog(&engine, &ctx, &policy_id_buf).decision); + + // '.' must not act as a regex wildcard: "axb" should not match "a.b". + var ctx2 = TypedLogContext.init(allocator); + defer ctx2.deinit(); + _ = try ctx2.withString("service.name", "axb"); + try testing.expectEqual(FilterDecision.unset, evalTypedLog(&engine, &ctx2, &policy_id_buf).decision); +} + +test "typed: equals string_value with regex-special literal is a valid pattern" { + const allocator = testing.allocator; + + // Previously rejected at validation because the unescaped '(' made an + // invalid regex; must now compile and match literally. + const sv: proto.policy.Value = .{ .value = .{ .string_value = try allocator.dupe(u8, "a(b") } }; + var policy = try makeLogTypedPolicy(allocator, "drop-paren", "service.name", .{ .equals = sv }, "none"); + defer policy.deinit(allocator); + + var noop_bus: NoopEventBus = undefined; + noop_bus.init(std.Options.debug_io); + var registry = PolicyRegistry.init(allocator, noop_bus.eventBus()); + defer registry.deinit(); + try registry.updatePolicies(&.{policy}, "test", .file); + + const engine = PolicyEngine.init(noop_bus.eventBus(), ®istry); + var policy_id_buf: [16][]const u8 = undefined; + + var ctx = TypedLogContext.init(allocator); + defer ctx.deinit(); + _ = try ctx.withString("service.name", "a(b"); + try testing.expectEqual(FilterDecision.drop, evalTypedLog(&engine, &ctx, &policy_id_buf).decision); +} + +test "typed: equals empty string_value matches only empty string" { + const allocator = testing.allocator; + + const sv: proto.policy.Value = .{ .value = .{ .string_value = try allocator.dupe(u8, "") } }; + var policy = try makeLogTypedPolicy(allocator, "drop-empty", "service.name", .{ .equals = sv }, "none"); + defer policy.deinit(allocator); + + var noop_bus: NoopEventBus = undefined; + noop_bus.init(std.Options.debug_io); + var registry = PolicyRegistry.init(allocator, noop_bus.eventBus()); + defer registry.deinit(); + try registry.updatePolicies(&.{policy}, "test", .file); + + const engine = PolicyEngine.init(noop_bus.eventBus(), ®istry); + var policy_id_buf: [16][]const u8 = undefined; + + var ctx = TypedLogContext.init(allocator); + defer ctx.deinit(); + _ = try ctx.withString("service.name", ""); + try testing.expectEqual(FilterDecision.drop, evalTypedLog(&engine, &ctx, &policy_id_buf).decision); + + var ctx2 = TypedLogContext.init(allocator); + defer ctx2.deinit(); + _ = try ctx2.withString("service.name", "nonempty"); + try testing.expectEqual(FilterDecision.unset, evalTypedLog(&engine, &ctx2, &policy_id_buf).decision); +} + test "evaluate: policy with keep=all and no transform" { const allocator = testing.allocator; diff --git a/src/policy/probabilistic_sampler.zig b/src/policy/probabilistic_sampler.zig index 66d1094..eff627f 100644 --- a/src/policy/probabilistic_sampler.zig +++ b/src/policy/probabilistic_sampler.zig @@ -127,13 +127,11 @@ pub const ProbabilisticSampler = struct { /// Per spec v1.6.0, without a sample_key each record MUST be sampled with a /// fresh high-quality random value — never derived from timestamps or /// record content — so this is intentionally not idempotent. - /// A null io fails open to keep (matching the rate limiter), except 0%. - pub fn shouldKeepRandom(self: ProbabilisticSampler, io: ?std.Io) bool { + pub fn shouldKeepRandom(self: ProbabilisticSampler, io: std.Io) bool { if (self.percentage >= 100.0) return true; if (self.percentage <= 0.0) return false; - const real_io = io orelse return true; var buf: [8]u8 = undefined; - real_io.random(&buf); + io.random(&buf); const r = std.mem.readInt(u64, &buf, .little) & (max_56bit - 1); return r >= self.threshold; } @@ -691,14 +689,9 @@ test "ProbabilisticSampler: initFromPercentage" { test "ProbabilisticSampler: shouldKeepRandom edge cases and distribution" { const io = std.Options.debug_io; - // 100% keeps all, 0% rejects all — with or without io + // 100% keeps all, 0% rejects all try testing.expect(ProbabilisticSampler.initFromPercentage(100).shouldKeepRandom(io)); - try testing.expect(ProbabilisticSampler.initFromPercentage(100).shouldKeepRandom(null)); try testing.expect(!ProbabilisticSampler.initFromPercentage(0).shouldKeepRandom(io)); - try testing.expect(!ProbabilisticSampler.initFromPercentage(0).shouldKeepRandom(null)); - - // Null io fails open to keep for a sampling percentage - try testing.expect(ProbabilisticSampler.initFromPercentage(50).shouldKeepRandom(null)); // Independent random decisions approximate the configured percentage const sampler = ProbabilisticSampler.initFromPercentage(50); diff --git a/src/policy/types.zig b/src/policy/types.zig index 909ccd0..d8c9ef4 100644 --- a/src/policy/types.zig +++ b/src/policy/types.zig @@ -301,6 +301,21 @@ pub const TypedValue = union(enum) { bytes: []const u8, }; +/// trace_id is 16 bytes, span_id 8 — 32 covers both with headroom. +pub const max_hex_id_bytes = 32; + +/// Render raw id bytes as lowercase hex into `buf`. Returns the rendered slice, +/// or the raw bytes unchanged when they don't fit (degrade, never overflow). +pub fn hexRenderId(bytes: []const u8, buf: []u8) []const u8 { + if (bytes.len * 2 > buf.len) return bytes; + const hex = "0123456789abcdef"; + for (bytes, 0..) |b, i| { + buf[i * 2] = hex[b >> 4]; + buf[i * 2 + 1] = hex[b & 0x0f]; + } + return buf[0 .. bytes.len * 2]; +} + // ============================================================================= // Log Accessor - Capability-tagged interface to consumer log records // =============================================================================