diff --git a/proto/tero/policy/v1/log.proto b/proto/tero/policy/v1/log.proto index f7d9c4e..b464f88 100644 --- a/proto/tero/policy/v1/log.proto +++ b/proto/tero/policy/v1/log.proto @@ -23,7 +23,7 @@ message LogTarget { // Valid values: // "all" - Keep everything (default, can be omitted) // "none" - Drop everything - // "N%" - Keep N percent (0-100), e.g. "50%" + // "N%" - Keep approximately N percent (0-100), e.g. "50%" // "N/s" - Keep at most N per second (shorthand for "N/1s"), e.g. "100/s" // "N/m" - Keep at most N per minute (shorthand for "N/1m"), e.g. "1000/m" // "N/Ds" - Keep at most N per D-second window, e.g. "1/5s" @@ -41,6 +41,11 @@ message LogTarget { // keep/drop decision. Use for lifecycle events (request_id, trace_id, job_id) // to avoid sampling individual log lines independently. // + // For percentage sampling (N%), omitting sample_key means each matching log is + // sampled independently using a random value generated at evaluation time. + // That default gives the expected approximate distribution, but is not + // idempotent across repeated evaluation of the same log sequence. + // // Only applies when keep is a sampling value (N%, N/s, N/m, N/Ds, N/Dm). // Example: sample_key = log_attribute["request_id"] with keep = "10%" means // 10% of requests are kept, with all logs from each kept request preserved. @@ -79,8 +84,8 @@ enum LogField { LOG_FIELD_BODY = 1; LOG_FIELD_SEVERITY_TEXT = 2; // trace_id and span_id are bytes. They are authored as lowercase hex and - // matched as raw bytes (exact/equals), or as their hex rendering for string - // match types. See the Value message and the spec's + // matched as raw bytes with equals.hex_value, or as their hex rendering for + // string pattern match types. See the Value message and the spec's // "Bytes and Identifier Fields" section. LOG_FIELD_TRACE_ID = 3; LOG_FIELD_SPAN_ID = 4; @@ -124,12 +129,17 @@ message LogMatcher { // Match type. Exactly one must be set. // - // The string match types (exact, regex, starts_with, ends_with, contains) - // operate only on string field values. To match non-string values, use the - // typed equals matcher or the numeric comparison matchers (gt, gte, lt, lte). + // Use the typed equals matcher for equality. The exact field is deprecated and + // will move to reserved in a future version after wire-format users migrate to + // equals.string_value. + // + // The string pattern match types (regex, starts_with, ends_with, contains) + // operate only on string field values. Use the numeric comparison matchers + // (gt, gte, lt, lte) for ranges. oneof match { - // Exact string match (string field values only) - string exact = 10; + // Deprecated: use equals.string_value instead. This field will move to + // reserved in a future version. + string exact = 10 [deprecated = true]; // Regular expression match (string field values only) string regex = 11; @@ -146,7 +156,7 @@ message LogMatcher { // Literal substring match (string field values only) string contains = 15; - // Typed equality for non-string field values (bool, int, double, bytes) + // Typed equality for string, bool, int, double, or bytes field values. Value equals = 22; // Numeric greater-than comparison (int/double field values) @@ -165,7 +175,8 @@ message LogMatcher { // If true, inverts the match result bool negate = 20; - // If true, applies case-insensitive matching to all match types + // If true, applies case-insensitive matching to equals.string_value and the + // string pattern match types. bool case_insensitive = 21; } diff --git a/proto/tero/policy/v1/metric.proto b/proto/tero/policy/v1/metric.proto index 84998b7..35a8db4 100644 --- a/proto/tero/policy/v1/metric.proto +++ b/proto/tero/policy/v1/metric.proto @@ -100,12 +100,17 @@ message MetricMatcher { // Match type. Exactly one must be set. // Note: For metric_type field, only exists is valid (type equality is implicit). // - // The string match types (exact, regex, starts_with, ends_with, contains) - // operate only on string field values. To match non-string values, use the - // typed equals matcher or the numeric comparison matchers (gt, gte, lt, lte). + // Use the typed equals matcher for equality. The exact field is deprecated and + // will move to reserved in a future version after wire-format users migrate to + // equals.string_value. + // + // The string pattern match types (regex, starts_with, ends_with, contains) + // operate only on string field values. Use the numeric comparison matchers + // (gt, gte, lt, lte) for ranges. oneof match { - // Exact string match (string field values only) - string exact = 10; + // Deprecated: use equals.string_value instead. This field will move to + // reserved in a future version. + string exact = 10 [deprecated = true]; // Regular expression match (string field values only) string regex = 11; @@ -122,7 +127,7 @@ message MetricMatcher { // Literal substring match (string field values only) string contains = 15; - // Typed equality for non-string field values (bool, int, double, bytes) + // Typed equality for string, bool, int, double, or bytes field values. Value equals = 22; // Numeric greater-than comparison (int/double field values) @@ -141,6 +146,7 @@ message MetricMatcher { // If true, inverts the match result bool negate = 20; - // If true, applies case-insensitive matching to all match types + // If true, applies case-insensitive matching to equals.string_value and the + // string pattern match types. bool case_insensitive = 21; } diff --git a/proto/tero/policy/v1/shared.proto b/proto/tero/policy/v1/shared.proto index 47f43af..260cead 100644 --- a/proto/tero/policy/v1/shared.proto +++ b/proto/tero/policy/v1/shared.proto @@ -51,9 +51,7 @@ message AttributePath { repeated string path = 1; } -// Value carries a typed, non-string scalar for the `equals` matcher. String -// equality is expressed with the `exact` match type, so this message -// intentionally has no string variant. +// Value carries a typed scalar for the `equals` matcher. // // `equals` matches when the field value has the same type and value as the set // variant. Integer and floating-point values are compared in a single numeric @@ -68,14 +66,16 @@ message AttributePath { // equals: true -> bool_value // equals: 200 -> int_value // equals: 0.5 -> double_value +// equals: "foo" -> string_value // // Bytes are authored either as proto-native base64 (`bytes_value`) or, more // readably, as a lowercase-hex string (`hex_value`). The two are equivalent — // hex_value is decoded to bytes at policy-compile time and yields the same bytes // as the corresponding bytes_value — but hex_value keeps identifiers // (trace/span ids) readable in the canonical proto/JSON form instead of base64. -// A bare string literal (e.g. `equals: "foo"`) MUST be rejected — use `exact` -// for strings. +// +// String equality SHOULD be authored with string_value. The older matcher-level +// `exact` field is deprecated and will move to reserved in a future version. message Value { // Exactly one must be set. oneof value { @@ -88,6 +88,7 @@ message Value { // input, canonically lowercase). Decoded to bytes at policy-compile time; // an invalid hex string MUST be rejected. string hex_value = 5; + string string_value = 6; } } diff --git a/proto/tero/policy/v1/trace.proto b/proto/tero/policy/v1/trace.proto index 0d65ba3..792751b 100644 --- a/proto/tero/policy/v1/trace.proto +++ b/proto/tero/policy/v1/trace.proto @@ -32,9 +32,9 @@ enum TraceField { // Span fields TRACE_FIELD_NAME = 1; // trace_id, span_id, and parent_span_id are bytes. They are authored as - // lowercase hex and matched as raw bytes (exact/equals), or as their hex - // rendering for string match types. See the Value message and the spec's - // "Bytes and Identifier Fields" section. + // lowercase hex and matched as raw bytes with equals.hex_value, or as their + // hex rendering for string pattern match types. See the Value message and the + // spec's "Bytes and Identifier Fields" section. TRACE_FIELD_TRACE_ID = 2; TRACE_FIELD_SPAN_ID = 3; TRACE_FIELD_PARENT_SPAN_ID = 4; @@ -119,12 +119,17 @@ message TraceMatcher { // Match type. Exactly one must be set. // Note: For span_kind and span_status fields, only exists is valid (equality is implicit). // - // The string match types (exact, regex, starts_with, ends_with, contains) - // operate only on string field values. To match non-string values, use the - // typed equals matcher or the numeric comparison matchers (gt, gte, lt, lte). + // Use the typed equals matcher for equality. The exact field is deprecated and + // will move to reserved in a future version after wire-format users migrate to + // equals.string_value. + // + // The string pattern match types (regex, starts_with, ends_with, contains) + // operate only on string field values. Use the numeric comparison matchers + // (gt, gte, lt, lte) for ranges. oneof match { - // Exact string match (string field values only) - string exact = 10; + // Deprecated: use equals.string_value instead. This field will move to + // reserved in a future version. + string exact = 10 [deprecated = true]; // Regular expression match (string field values only) string regex = 11; @@ -141,7 +146,7 @@ message TraceMatcher { // Literal substring match (string field values only) string contains = 15; - // Typed equality for non-string field values (bool, int, double, bytes) + // Typed equality for string, bool, int, double, or bytes field values. Value equals = 22; // Numeric greater-than comparison (int/double field values) @@ -160,7 +165,8 @@ message TraceMatcher { // If true, inverts the match result bool negate = 20; - // If true, applies case-insensitive matching to all match types + // If true, applies case-insensitive matching to equals.string_value and the + // string pattern match types. bool case_insensitive = 21; } diff --git a/src/bench/main.zig b/src/bench/main.zig index c569c6d..4744e70 100644 --- a/src/bench/main.zig +++ b/src/bench/main.zig @@ -16,6 +16,7 @@ const TraceFieldRef = policy_zig.TraceFieldRef; const LogAccessor = policy_zig.LogAccessor; const MetricAccessor = policy_zig.MetricAccessor; const TraceAccessor = policy_zig.TraceAccessor; +const TypedValue = policy_zig.TypedValue; const NoopEventBus = o11y.NoopEventBus; const COUNTS = [_]usize{ 1, 10, 100, 1000 }; @@ -24,38 +25,38 @@ const COUNTS = [_]usize{ 1, 10, 100, 1000 }; const BenchLog = struct { body: []const u8, - fn access(ctx: *const anyopaque, field: FieldRef) ?[]const u8 { + fn access(ctx: *const anyopaque, field: FieldRef) ?TypedValue { const self: *const BenchLog = @ptrCast(@alignCast(ctx)); return switch (field) { - .log_field => |lf| if (lf == .LOG_FIELD_BODY) self.body else null, + .log_field => |lf| if (lf == .LOG_FIELD_BODY) .{ .string = self.body } else null, else => null, }; } - const accessor: LogAccessor = .{ .value = access }; + const accessor: LogAccessor = .{ .typed_value = access }; }; const BenchMetric = struct { name: []const u8, - fn access(ctx: *const anyopaque, field: MetricFieldRef) ?[]const u8 { + fn access(ctx: *const anyopaque, field: MetricFieldRef) ?TypedValue { const self: *const BenchMetric = @ptrCast(@alignCast(ctx)); return switch (field) { - .metric_field => |mf| if (mf == .METRIC_FIELD_NAME) self.name else null, + .metric_field => |mf| if (mf == .METRIC_FIELD_NAME) .{ .string = self.name } else null, else => null, }; } - const accessor: MetricAccessor = .{ .value = access }; + const accessor: MetricAccessor = .{ .typed_value = access }; }; const BenchTrace = struct { name: []const u8, - fn access(ctx: *const anyopaque, field: TraceFieldRef) ?[]const u8 { + fn access(ctx: *const anyopaque, field: TraceFieldRef) ?TypedValue { const self: *const BenchTrace = @ptrCast(@alignCast(ctx)); return switch (field) { - .trace_field => |tf| if (tf == .TRACE_FIELD_NAME) self.name else null, + .trace_field => |tf| if (tf == .TRACE_FIELD_NAME) .{ .string = self.name } else null, else => null, }; } - const accessor: TraceAccessor = .{ .value = access }; + const accessor: TraceAccessor = .{ .typed_value = access }; }; // --- JSON builders (allocator passed per-call, Zig 0.16 style) --- diff --git a/src/policy/log_transform.zig b/src/policy/log_transform.zig index bc792dd..5443fee 100644 --- a/src/policy/log_transform.zig +++ b/src/policy/log_transform.zig @@ -202,10 +202,9 @@ 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 -/// textual representation returned by `accessor.value` and substitutes each -/// match using the pre-parsed replacement template. If the accessor returns -/// null (field missing or non-string), or the regex finds no match, the -/// operation is a no-op. +/// `.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. /// /// Returns true if the field was redacted. pub fn applyRedact( @@ -220,7 +219,9 @@ pub fn applyRedact( if (options.compiled) |c| { const allocator = options.scratch orelse return false; const io = options.io orelse return false; - const value = accessor.value(ctx, field_ref) 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 out: std.ArrayList(u8) = .empty; // No deinit: `out.items` is handed to the accessor.set by reference @@ -243,7 +244,7 @@ pub fn applyRedact( } // Whole-value redact: only fires if the field exists. - if (accessor.value(ctx, field_ref) == null) return false; + if (!accessor.callExists(ctx, field_ref)) return false; accessor.set.?(ctx, field_ref, rule.replacement); return true; @@ -414,8 +415,12 @@ const TestContext = struct { self.allocator.free(removed.value); } + fn accessorTypedValue(ctx: *const anyopaque, field: FieldRef) ?types.TypedValue { + return .{ .string = accessorValue(ctx, field) orelse return null }; + } + const accessor: LogAccessor = .{ - .value = accessorValue, + .typed_value = accessorTypedValue, .set = accessorSet, .delete = accessorDelete, .move = accessorMove, @@ -568,10 +573,10 @@ test "applyRedact: regex output outlives applyRedact return" { stored: ?[]const u8 = null, - fn valueFn(ctx: *const anyopaque, field: FieldRef) ?[]const u8 { + fn valueFn(ctx: *const anyopaque, field: FieldRef) ?types.TypedValue { _ = field; const self: *const Self = @ptrCast(@alignCast(ctx)); - return self.stored; + return .{ .string = self.stored orelse return null }; } fn setFn(ctx: *anyopaque, field: FieldRef, value: []const u8) void { @@ -585,7 +590,7 @@ test "applyRedact: regex output outlives applyRedact return" { } fn moveFn(_: *anyopaque, _: FieldRef, _: []const u8) void {} const accessor: LogAccessor = .{ - .value = valueFn, + .typed_value = valueFn, .set = setFn, .delete = deleteFn, .move = moveFn, diff --git a/src/policy/matcher_index.zig b/src/policy/matcher_index.zig index 36bfaa9..1c6c897 100644 --- a/src/policy/matcher_index.zig +++ b/src/policy/matcher_index.zig @@ -579,6 +579,10 @@ fn compileValue(allocator: std.mem.Allocator, v: proto.policy.Value) !?CompiledV } break :blk .{ .bytes = bytes }; }, + // string_value is string equality (≡ the deprecated `exact`) and is + // routed to the Hyperscan pattern path by addMatcher; it never + // compiles to a typed check. Null keeps an unrouted one fail-open. + .string_value => null, }; } @@ -1061,6 +1065,15 @@ fn IndexBuilder(comptime T: TelemetryType) type { try self.recordError(signal ++ ": match[{d}]: invalid pattern \"{s}\"", policy_id, .{ idx, p }); return null; }, + // equals.string_value compiles to a Hyperscan pattern like + // `exact`; 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)) { + try self.recordError(signal ++ ": match[{d}]: invalid pattern \"{s}\"", policy_id, .{ idx, p }); + return null; + } + }, else => {}, } return field_ref; @@ -1192,24 +1205,30 @@ fn IndexBuilder(comptime T: TelemetryType) type { // primitive. An invalid/unset value is silently dropped (fail-open). switch (m) { .equals => |v| { - const compiled = (try compileValue(self.allocator, v)) orelse { - self.bus.debug(TypedMatcherSkipped{ .matcher_idx = matcher_idx }); + // equals.string_value is string equality (≡ the deprecated + // `exact`, v1.6.0) and falls through to the pattern path + // below, so it gets Hyperscan and case_insensitive support. + const is_string = v.value != null and v.value.? == .string_value; + if (!is_string) { + const compiled = (try compileValue(self.allocator, v)) orelse { + self.bus.debug(TypedMatcherSkipped{ .matcher_idx = matcher_idx }); + return; + }; + // typed checks count toward required_match_count so the + // policy's match threshold is still enforced. + if (matcher.negate) { + self.current_negated_count += 1; + } else { + self.current_positive_count += 1; + } + try self.typed_checks_list.append(self.allocator, .{ + .policy_index = self.policy_index, + .field_ref = field_ref, + .matcher = .{ .equals = compiled }, + .negate = matcher.negate, + }); return; - }; - // typed checks count toward required_match_count so the - // policy's match threshold is still enforced. - if (matcher.negate) { - self.current_negated_count += 1; - } else { - self.current_positive_count += 1; } - try self.typed_checks_list.append(self.allocator, .{ - .policy_index = self.policy_index, - .field_ref = field_ref, - .matcher = .{ .equals = compiled }, - .negate = matcher.negate, - }); - return; }, .gt => |v| { const compiled = compileNumericValue(v) orelse { @@ -1273,11 +1292,12 @@ fn IndexBuilder(comptime T: TelemetryType) type { const 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 }, .exists => unreachable, .starts_with => |s| .{ s, match_type.starts_with, matcher.negate }, .ends_with => |s| .{ s, match_type.ends_with, matcher.negate }, .contains => |s| .{ s, match_type.contains, matcher.negate }, - .equals, .gt, .gte, .lt, .lte => unreachable, + .gt, .gte, .lt, .lte => unreachable, }; if (pattern.len == 0) { diff --git a/src/policy/parser.zig b/src/policy/parser.zig index a7a63cf..68ba01e 100644 --- a/src/policy/parser.zig +++ b/src/policy/parser.zig @@ -124,6 +124,7 @@ const LogMatcherJson = struct { // equals: true → bool_value // equals: 200 → int_value // equals: 0.5 → double_value + // equals: "foo" → string_value (v1.6.0) // equals: { hex_value: "deadbeef" } → canonical // gt/gte/lt/lte accept integer or float literals only. equals: ?std.json.Value = null, @@ -523,13 +524,14 @@ fn parseLogMatcher(allocator: std.mem.Allocator, jm: LogMatcherJson) !LogMatcher /// bool → bool_value /// int → int_value /// float → double_value -/// object with hex_value/bytes_value/bool_value/int_value/double_value → canonical -/// A bare string literal is rejected per spec (use `exact` for strings). +/// string → string_value (v1.6.0) +/// object with hex_value/bytes_value/bool_value/int_value/double_value/string_value → canonical fn parseValue(allocator: std.mem.Allocator, json_val: std.json.Value) !Value { switch (json_val) { .bool => |b| return Value{ .value = .{ .bool_value = b } }, .integer => |i| return Value{ .value = .{ .int_value = i } }, .float => |f| return Value{ .value = .{ .double_value = f } }, + .string => |s| return Value{ .value = .{ .string_value = try allocator.dupe(u8, s) } }, .object => |obj| { if (obj.get("bool_value")) |v| { return Value{ .value = .{ .bool_value = v.bool } }; @@ -549,9 +551,11 @@ fn parseValue(allocator: std.mem.Allocator, json_val: std.json.Value) !Value { const bytes = try allocator.dupe(u8, v.string); return Value{ .value = .{ .bytes_value = bytes } }; } + if (obj.get("string_value")) |v| { + return Value{ .value = .{ .string_value = try allocator.dupe(u8, v.string) } }; + } return error.InvalidValue; }, - .string => return error.InvalidValue, // use `exact` for strings else => return error.InvalidValue, } } @@ -2670,9 +2674,23 @@ test "parseValue: hex_value canonical" { try std.testing.expectEqualSlices(u8, &.{ 0xde, 0xad, 0xbe, 0xef }, v.value.?.bytes_value); } -test "parseValue: string rejected" { +test "parseValue: string shorthand (v1.6.0)" { + const allocator = std.testing.allocator; + const v = try parseValue(allocator, .{ .string = "foo" }); + defer allocator.free(v.value.?.string_value); + try std.testing.expect(v.value.? == .string_value); + try std.testing.expectEqualStrings("foo", v.value.?.string_value); +} + +test "parseValue: string_value canonical (v1.6.0)" { const allocator = std.testing.allocator; - try std.testing.expectError(error.InvalidValue, parseValue(allocator, .{ .string = "foo" })); + var obj = std.json.ObjectMap.empty; + defer obj.deinit(allocator); + try obj.put(allocator, "string_value", .{ .string = "checkout-api" }); + const v = try parseValue(allocator, .{ .object = obj }); + defer allocator.free(v.value.?.string_value); + try std.testing.expect(v.value.? == .string_value); + try std.testing.expectEqualStrings("checkout-api", v.value.?.string_value); } test "parseNumericValue: int" { @@ -2738,6 +2756,36 @@ test "parsePoliciesBytes: log policy with equals bool" { try std.testing.expect(matcher.match.?.equals.value.?.bool_value == true); } +test "parsePoliciesBytes: log policy with equals string (v1.6.0)" { + const allocator = std.testing.allocator; + + const json = + \\{ + \\ "policies": [{ + \\ "id": "drop-checkout", + \\ "name": "Drop checkout logs", + \\ "log": { + \\ "match": [ + \\ { "resource_attribute": ["service.name"], "equals": "checkout-api" } + \\ ], + \\ "keep": "none" + \\ } + \\ }] + \\} + ; + + const policies = try parsePoliciesBytes(allocator, json); + defer { + for (policies) |*p| p.deinit(allocator); + allocator.free(policies); + } + + const matcher = policies[0].target.?.log.match.items[0]; + try std.testing.expect(matcher.match.? == .equals); + try std.testing.expect(matcher.match.?.equals.value.? == .string_value); + try std.testing.expectEqualStrings("checkout-api", matcher.match.?.equals.value.?.string_value); +} + test "parsePoliciesBytes: log policy with gte int" { const allocator = std.testing.allocator; diff --git a/src/policy/policy_engine.zig b/src/policy/policy_engine.zig index 7dd93d4..6b04477 100644 --- a/src/policy/policy_engine.zig +++ b/src/policy/policy_engine.zig @@ -487,7 +487,17 @@ pub const PolicyEngine = struct { // they have no Hyperscan DB and don't read the field value. if (!matcher_key.has_value_db) continue; - const raw_value = accessor.value(ctx, field_ref) orelse { + // String matchers consume the `.string` variant. Identifier + // fields arrive as `.bytes` and are hex-rendered before matching + // (policies write them as hex literals). Other variants are not + // string-matchable and skip the scan like an absent field. + var id_hex_buf: [max_hex_id_bytes * 2]u8 = undefined; + const value = blk: { + if (accessor.typed_value(ctx, field_ref)) |tv| switch (tv) { + .string => |s| break :blk s, + .bytes => |b| if (isHexBytesField(T, field_ref)) break :blk hexRenderId(b, &id_hex_buf), + else => {}, + }; if (self.bus.isEnabled(.debug)) { const event: MatcherKeyFieldNotPresent = .{ .telemetry_type = T, @@ -502,14 +512,6 @@ pub const PolicyEngine = struct { continue; }; - // Identifier fields are stored as raw bytes but written as hex - // literals in policies — render before string matching. - var id_hex_buf: [max_hex_id_bytes * 2]u8 = undefined; - const value = if (isHexBytesField(T, field_ref)) - hexRenderId(raw_value, &id_hex_buf) - else - raw_value; - if (self.bus.isEnabled(.debug)) { const event: MatcherKeyFieldValue = .{ .telemetry_type = T, @@ -577,12 +579,7 @@ pub const PolicyEngine = struct { // comparison fires (negation failed), leave alone when it doesn't // (negation succeeded). for (index.getTypedChecks()) |check| { - const typed_val: ?policy_types.TypedValue = if (comptime accessor.typed_value != null) - accessor.typed_value.?(ctx, check.field_ref) - else if (accessor.value(ctx, check.field_ref)) |s| - policy_types.TypedValue{ .string = s } - else - null; + const typed_val = accessor.typed_value(ctx, check.field_ref); const fired = check.matcher.evaluate(typed_val); @@ -612,10 +609,9 @@ pub const PolicyEngine = struct { /// Get raw bytes for probabilistic sampling. /// - /// Prefers `accessor.typed_value` to get `TypedValue.bytes` directly — - /// the sampler expects raw bytes and no longer accepts hex-encoded strings. - /// Falls back to `accessor.value` (treating the result as raw bytes) when - /// `typed_value` is not wired or returns a non-bytes variant. + /// The sampler expects raw bytes: `.bytes` values are used directly + /// (identifiers, no hex-encoded strings), `.string` values as their byte + /// content. Other variants cannot seed randomness and return null. /// /// - Traces: `TRACE_FIELD_TRACE_ID` raw bytes (16 bytes per OTel spec). /// - Logs with sample_key: sample key field value as raw bytes. @@ -626,27 +622,20 @@ pub const PolicyEngine = struct { ctx: *anyopaque, policy_info: *const PolicyInfo, ) ?[]const u8 { - if (T == .trace) { - const trace_id_ref: FieldRefType(T) = .{ .trace_field = .TRACE_FIELD_TRACE_ID }; - if (comptime accessor.typed_value != null) { - if (accessor.typed_value.?(ctx, trace_id_ref)) |tv| { - if (tv == .bytes) return tv.bytes; - } - } - return accessor.value(ctx, trace_id_ref); - } else if (T == .log) { - if (policy_info.sample_key) |sample_key| { - if (FieldRef.fromSampleKeyField(sample_key.field)) |field_ref| { - if (comptime accessor.typed_value != null) { - if (accessor.typed_value.?(ctx, field_ref)) |tv| { - if (tv == .bytes) return tv.bytes; - } - } - return accessor.value(ctx, field_ref); - } - } - } - return null; + const field_ref: FieldRefType(T) = switch (T) { + .trace => .{ .trace_field = .TRACE_FIELD_TRACE_ID }, + .log => blk: { + const sample_key = policy_info.sample_key orelse return null; + break :blk FieldRef.fromSampleKeyField(sample_key.field) orelse return null; + }, + .metric => return null, + }; + const tv = accessor.typed_value(ctx, field_ref) orelse return null; + return switch (tv) { + .bytes => |b| b, + .string => |s| s, + else => null, + }; } /// Find all matching policies, apply sampling/rate limiting, and determine final decision. @@ -709,7 +698,10 @@ pub const PolicyEngine = struct { if (T == .trace) { // Trace sampling: read tracestate, run full sample(), write threshold back const ts_ref: FieldRefType(T) = .{ .trace_field = .TRACE_FIELD_TRACE_STATE }; - const tracestate = accessor.value(ctx, ts_ref) orelse ""; + const tracestate = ts: { + const tv = accessor.typed_value(ctx, ts_ref) orelse break :ts ""; + break :ts if (tv == .string) tv.string else ""; + }; const sr = s.sample(input, tracestate); if (sr.keep) { @@ -730,7 +722,13 @@ pub const PolicyEngine = struct { break :blk if (sr.keep) FilterDecision.keep else FilterDecision.drop; } - // Non-trace: simple keep/drop from shouldKeep + // Non-trace (log): with no sample_key (or a missing/empty + // key value) the spec requires an independent random + // decision per record, so empty input takes the random + // path instead of failing closed. + if (input.len == 0) { + break :blk if (s.shouldKeepRandom(io)) FilterDecision.keep else FilterDecision.drop; + } break :blk if (s.shouldKeep(input)) FilterDecision.keep else FilterDecision.drop; } break :blk applyKeepValue(io, policy_info); @@ -924,7 +922,11 @@ const TestLogContext = struct { }; } - pub const accessor: LogAccessor = .{ .value = fieldAccessor }; + fn typedAccessor(ctx_ptr: *const anyopaque, field: FieldRef) ?TypedValue { + return .{ .string = fieldAccessor(ctx_ptr, field) orelse return null }; + } + + pub const accessor: LogAccessor = .{ .typed_value = typedAccessor }; }; fn evalTestLog( @@ -1835,8 +1837,12 @@ const MutableTestLogContext = struct { if (value) |v| self.setAttribute(to, v) catch return; } + fn typedAccessor(ctx_ptr: *const anyopaque, field: FieldRef) ?TypedValue { + return .{ .string = fieldAccessor(ctx_ptr, field) orelse return null }; + } + pub const accessor: LogAccessor = .{ - .value = fieldAccessor, + .typed_value = typedAccessor, .set = accessorSet, .delete = accessorDelete, .move = accessorMove, @@ -1921,15 +1927,6 @@ const TypedLogContext = struct { }; } - fn fieldValue(ctx_ptr: *const anyopaque, field: FieldRef) ?[]const u8 { - const self: *const TypedLogContext = @ptrCast(@alignCast(ctx_ptr)); - const key = attrKey(field) orelse return null; - return switch (self.attrs.get(key) orelse return null) { - .string => |s| s, - else => null, // non-string → null for string-match path - }; - } - fn fieldTypedValue(ctx_ptr: *const anyopaque, field: FieldRef) ?TypedValue { const self: *const TypedLogContext = @ptrCast(@alignCast(ctx_ptr)); const key = attrKey(field) orelse return null; @@ -1942,7 +1939,6 @@ const TypedLogContext = struct { } pub const accessor: LogAccessor = .{ - .value = fieldValue, .typed_value = fieldTypedValue, }; }; @@ -2248,6 +2244,76 @@ test "typed: negated equals" { try testing.expectEqual(FilterDecision.drop, evalTypedLog(&engine, &ctx500, &policy_id_buf).decision); } +test "typed: equals string_value matches like exact (v1.6.0)" { + const allocator = testing.allocator; + + const sv: proto.policy.Value = .{ .value = .{ .string_value = try allocator.dupe(u8, "checkout-api") } }; + var policy = try makeLogTypedPolicy(allocator, "drop-checkout", "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", "checkout-api"); + try testing.expectEqual(FilterDecision.drop, evalTypedLog(&engine, &ctx, &policy_id_buf).decision); + + // Prefix is not equality + var ctx2 = TypedLogContext.init(allocator); + defer ctx2.deinit(); + _ = try ctx2.withString("service.name", "checkout-api-v2"); + try testing.expectEqual(FilterDecision.unset, evalTypedLog(&engine, &ctx2, &policy_id_buf).decision); + + // Different case does not match without case_insensitive + var ctx3 = TypedLogContext.init(allocator); + defer ctx3.deinit(); + _ = try ctx3.withString("service.name", "CHECKOUT-API"); + try testing.expectEqual(FilterDecision.unset, evalTypedLog(&engine, &ctx3, &policy_id_buf).decision); +} + +test "typed: equals string_value with case_insensitive (v1.6.0)" { + const allocator = testing.allocator; + + var attr_path: proto.policy.AttributePath = .{}; + try attr_path.path.append(allocator, try allocator.dupe(u8, "env")); + + var policy: Policy = .{ + .id = try allocator.dupe(u8, "drop-prod"), + .name = try allocator.dupe(u8, "drop-prod"), + .enabled = true, + .target = .{ .log = .{ + .keep = try allocator.dupe(u8, "none"), + } }, + }; + try policy.target.?.log.match.append(allocator, .{ + .field = .{ .log_attribute = attr_path }, + .match = .{ .equals = .{ .value = .{ .string_value = try allocator.dupe(u8, "production") } } }, + .case_insensitive = true, + }); + 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("env", "PRODUCTION"); + try testing.expectEqual(FilterDecision.drop, evalTypedLog(&engine, &ctx, &policy_id_buf).decision); +} + test "evaluate: policy with keep=all and no transform" { const allocator = testing.allocator; @@ -3386,7 +3452,11 @@ const TestMetricContext = struct { }; } - pub const accessor: MetricAccessor = .{ .value = fieldAccessor }; + fn typedAccessor(ctx_ptr: *const anyopaque, field: MetricFieldRef) ?TypedValue { + return .{ .string = fieldAccessor(ctx_ptr, field) orelse return null }; + } + + pub const accessor: MetricAccessor = .{ .typed_value = typedAccessor }; }; fn evalMetric( @@ -4063,38 +4133,10 @@ test "PolicyEngine: percentage sampling - 100% keeps all" { try testing.expectEqual(FilterDecision.keep, result.decision); } -test "PolicyEngine: percentage sampling - deterministic per context" { - const allocator = testing.allocator; - - var policy: Policy = .{ - .id = try allocator.dupe(u8, "policy-1"), - .name = try allocator.dupe(u8, "sample-50-percent"), - .enabled = true, - .target = .{ .log = .{ - .keep = try allocator.dupe(u8, "50%"), - } }, - }; - try policy.target.?.log.match.append(allocator, .{ - .field = .{ .log_field = .LOG_FIELD_BODY }, - .match = .{ .regex = try allocator.dupe(u8, "test") }, - }); - 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}, "file-provider", .file); - - const engine = PolicyEngine.init(noop_bus.eventBus(), ®istry); - var policy_id_buf: [16][]const u8 = undefined; - - // Same context should produce same decision (deterministic) - var test_log: TestLogContext = .{ .message = "test message" }; - const result1 = evalTestLog(&engine, &test_log, &policy_id_buf); - const result2 = evalTestLog(&engine, &test_log, &policy_id_buf); - try testing.expectEqual(result1.decision, result2.decision); -} +// Note: deterministic percentage sampling requires a sample_key (see the +// "sample_key provides deterministic sampling" test). Without one, the spec +// (v1.6.0) requires an independent random decision per record — covered by +// "percentage sampling without sample_key is independent random". test "PolicyEngine: rate limiting - respects limit" { const allocator = testing.allocator; @@ -4479,14 +4521,56 @@ test "PolicyEngine: sample_key missing field falls back to default" { const engine = PolicyEngine.init(noop_bus.eventBus(), ®istry); var policy_id_buf: [16][]const u8 = undefined; - // Should still work (falls back to context pointer hash) + // Missing key value falls back to the independent random default var log1: TestLogContext = .{ .message = "test message" }; const result = evalTestLog(&engine, &log1, &policy_id_buf); - // Should get a decision (either keep or drop based on hash) + // Should get a decision (either keep or drop, randomly) try testing.expect(result.decision == .keep or result.decision == .drop); } +test "PolicyEngine: percentage sampling without sample_key is independent random (v1.6.0)" { + const allocator = testing.allocator; + + // 50% log sampling with no sample_key: each record must get an + // independent random decision — previously this failed closed and + // dropped everything. + var policy: Policy = .{ + .id = try allocator.dupe(u8, "sample-unkeyed"), + .name = try allocator.dupe(u8, "sample-unkeyed"), + .enabled = true, + .target = .{ .log = .{ + .keep = try allocator.dupe(u8, "50%"), + } }, + }; + try policy.target.?.log.match.append(allocator, .{ + .field = .{ .log_field = .LOG_FIELD_BODY }, + .match = .{ .regex = try allocator.dupe(u8, "^.*") }, + }); + 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}, "file-provider", .file); + + const engine = PolicyEngine.init(noop_bus.eventBus(), ®istry); + var policy_id_buf: [16][]const u8 = undefined; + + var kept: u32 = 0; + const total: u32 = 2000; + for (0..total) |_| { + var log: TestLogContext = .{ .message = "same message every time" }; + if (evalTestLog(&engine, &log, &policy_id_buf).decision == .keep) kept += 1; + } + + // Identical records must not all get the same decision, and the split + // should be roughly 50% (wide bounds keep this test deterministic enough). + const ratio = @as(f64, @floatFromInt(kept)) / @as(f64, @floatFromInt(total)); + try testing.expect(ratio > 0.4 and ratio < 0.6); +} + test "PolicyEngine: log resource_schema_url matching" { const allocator = testing.allocator; @@ -4680,27 +4764,16 @@ const TestTraceContext = struct { last_mutate_value: ?[]const u8 = null, mutate_count: usize = 0, - pub fn fieldAccessor(ctx_ptr: *const anyopaque, field: TraceFieldRef) ?[]const u8 { - const self: *const TestTraceContext = @ptrCast(@alignCast(ctx_ptr)); - return switch (field) { - .trace_field => |tf| switch (tf) { - .TRACE_FIELD_NAME => self.name, - .TRACE_FIELD_SPAN_ID => self.span_id, - .TRACE_FIELD_TRACE_STATE => self.trace_state, - // trace_id is bytes — return null from the string path so - // string matchers don't fire on it; sampling uses typed_value. - .TRACE_FIELD_TRACE_ID => null, - else => null, - }, - else => null, - }; - } - pub fn typedFieldAccessor(ctx_ptr: *const anyopaque, field: TraceFieldRef) ?TypedValue { const self: *const TestTraceContext = @ptrCast(@alignCast(ctx_ptr)); return switch (field) { .trace_field => |tf| switch (tf) { - .TRACE_FIELD_TRACE_ID => if (self.trace_id) |id| TypedValue{ .bytes = id } else null, + .TRACE_FIELD_NAME => .{ .string = self.name orelse return null }, + .TRACE_FIELD_SPAN_ID => .{ .string = self.span_id orelse return null }, + .TRACE_FIELD_TRACE_STATE => .{ .string = self.trace_state orelse return null }, + // trace_id is raw bytes: the engine hex-renders it for string + // matchers and hands the raw bytes to the sampler. + .TRACE_FIELD_TRACE_ID => .{ .bytes = self.trace_id orelse return null }, else => null, }, else => null, @@ -4720,7 +4793,6 @@ const TestTraceContext = struct { } pub const accessor: TraceAccessor = .{ - .value = fieldAccessor, .typed_value = typedFieldAccessor, .set = accessorSet, }; @@ -4820,17 +4892,17 @@ test "PolicyEngine: trace sampling drop does not write threshold" { try testing.expectEqual(@as(usize, 0), ctx.mutate_count); } -// Context whose `value(trace_id)` returns the raw 16 bytes (the canonical -// in-memory form for both wire paths). Exercises the engine's hex rendering of -// identifier bytes before string matching. +// Context whose `typed_value(trace_id)` returns the raw 16 bytes (the +// canonical in-memory form for both wire paths). Exercises the engine's hex +// rendering of identifier bytes before string matching. const TestHexTraceContext = struct { trace_id: ?[]const u8 = null, - pub fn fieldAccessor(ctx_ptr: *const anyopaque, field: TraceFieldRef) ?[]const u8 { + pub fn typedFieldAccessor(ctx_ptr: *const anyopaque, field: TraceFieldRef) ?TypedValue { const self: *const TestHexTraceContext = @ptrCast(@alignCast(ctx_ptr)); return switch (field) { .trace_field => |tf| switch (tf) { - .TRACE_FIELD_TRACE_ID => self.trace_id, + .TRACE_FIELD_TRACE_ID => .{ .bytes = self.trace_id orelse return null }, else => null, }, else => null, @@ -4839,7 +4911,7 @@ const TestHexTraceContext = struct { pub fn noopSet(_: *anyopaque, _: TraceFieldRef, _: []const u8) void {} - pub const accessor: TraceAccessor = .{ .value = fieldAccessor, .set = noopSet }; + pub const accessor: TraceAccessor = .{ .typed_value = typedFieldAccessor, .set = noopSet }; }; test "PolicyEngine: string matcher on trace_id renders raw bytes as hex" { diff --git a/src/policy/probabilistic_sampler.zig b/src/policy/probabilistic_sampler.zig index 15e5a31..66d1094 100644 --- a/src/policy/probabilistic_sampler.zig +++ b/src/policy/probabilistic_sampler.zig @@ -123,6 +123,21 @@ pub const ProbabilisticSampler = struct { return self.sample(input, "").keep; } + /// Independent random keep/drop for unkeyed log percentage sampling. + /// 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 { + 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); + const r = std.mem.readInt(u64, &buf, .little) & (max_56bit - 1); + return r >= self.threshold; + } + /// Calculate threshold from percentage /// T = floor((1 - percentage/100) * 2^56) pub fn calculateThreshold(percentage: f32) u64 { @@ -673,6 +688,29 @@ test "ProbabilisticSampler: initFromPercentage" { try testing.expect(full.shouldKeep("anything")); } +test "ProbabilisticSampler: shouldKeepRandom edge cases and distribution" { + const io = std.Options.debug_io; + + // 100% keeps all, 0% rejects all — with or without io + 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); + var kept: u32 = 0; + const total: u32 = 10000; + for (0..total) |_| { + if (sampler.shouldKeepRandom(io)) kept += 1; + } + const ratio = @as(f64, @floatFromInt(kept)) / @as(f64, @floatFromInt(total)); + try testing.expect(ratio > 0.45 and ratio < 0.55); +} + test "ProbabilisticSampler: shouldKeep matches sample" { const sampler = ProbabilisticSampler.initFromPercentage(50); const input = "test-input-bytes"; diff --git a/src/policy/types.zig b/src/policy/types.zig index 84c7c82..909ccd0 100644 --- a/src/policy/types.zig +++ b/src/policy/types.zig @@ -287,12 +287,9 @@ pub const MetricFieldRef = union(enum) { /// A typed field value returned by the `typed_value` accessor primitive. /// -/// Used by the `equals`, `gt`, `gte`, `lt`, and `lte` matchers, which need -/// to compare against non-string values (booleans, integers, floats, bytes). -/// -/// The default string path (`value` accessor) is still used for string-typed -/// matchers and for `typed_value`-less accessors that fall back to -/// `TypedValue.string` wrapping. +/// String matchers (exact/regex/starts_with/ends_with/contains) consume the +/// `.string` variant; the typed matchers (`equals`, `gt`, `gte`, `lt`, `lte`) +/// compare all variants. /// /// `string` and `bytes` slices are borrowed from the consumer record and must /// remain valid for the duration of the `engine.evaluate` call. @@ -313,27 +310,29 @@ pub const TypedValue = union(enum) { /// the engine dispatches every read and write through these function /// pointers, with ctx being the consumer-owned record the pointers operate on. /// -/// `value` is the only required field. Optional primitives advertise consumer -/// capability: when an `apply*` transform needs a primitive that's null, the -/// transform no-ops (does not count toward `*_applied`) — so a consumer that -/// doesn't wire `set` simply won't apply redact/add even if a matching policy -/// fires. +/// `typed_value` is the only required field. Optional primitives advertise +/// consumer capability: when an `apply*` transform needs a primitive that's +/// null, the transform no-ops (does not count toward `*_applied`) — so a +/// consumer that doesn't wire `set` simply won't apply redact/add even if a +/// matching policy fires. pub const LogAccessor = struct { - /// Read field as bytes for pattern matching. - /// Returns null when the field is absent OR its underlying value is not a - /// string. Consumers that want exists-matchers to fire on non-string - /// fields should wire `exists` to report presence independently. + /// Read a field as a typed value. This is the single read primitive: + /// string matchers consume the `.string` variant, the typed matchers + /// (`equals`/`gt`/`gte`/`lt`/`lte`) compare all variants, sampling reads + /// `.bytes`/`.string`, and regex redact reads `.string`. + /// + /// Identifier fields (`LOG_FIELD_TRACE_ID`, `LOG_FIELD_SPAN_ID`): return + /// `.bytes` with the RAW id bytes. The engine hex-renders them for string + /// matchers (so hex literals in policies match) and uses the raw bytes + /// for sampling. Returning null hides the id from matching. /// - /// Exception — identifier fields (`LOG_FIELD_TRACE_ID`, `LOG_FIELD_SPAN_ID`): - /// return the RAW id bytes here, not null. The engine hex-renders them before - /// matching (so hex literals in policies match), and consults this primitive, - /// not `typed_value`, for string matchers. Returning null hides the id from - /// exact/contains/regex matching. - value: *const fn (ctx: *const anyopaque, field: FieldRef) ?[]const u8, + /// Return null when the field is absent. A present-but-wrong-type value + /// causes a non-match (never a runtime error) consistent with fail-open. + typed_value: *const fn (ctx: *const anyopaque, field: FieldRef) ?TypedValue, /// Returns true if the field is present, regardless of underlying type. - /// When null, the engine falls back to `value(...) != null`, preserving - /// the old "non-null means present" semantics. + /// When null, the engine falls back to `typed_value(...) != null`, + /// preserving the "non-null means present" semantics. exists: ?*const fn (ctx: *const anyopaque, field: FieldRef) bool = null, /// Upsert a field. The engine pre-checks `upsert=false` conflicts via @@ -364,37 +363,24 @@ pub const LogAccessor = struct { /// Wiring this enables: log.rename. move: ?*const fn (ctx: *anyopaque, from: FieldRef, to: []const u8) void = null, - /// Read a field as a typed value for `equals`/`gt`/`gte`/`lt`/`lte` matchers. - /// - /// When null, the engine falls back to `value()` and treats the result as - /// `TypedValue.string` — typed matchers will fire only against string fields. - /// Override to expose int/double/bool/bytes attribute values so typed - /// matchers can compare against them correctly. - /// - /// Return null when the field is absent. A present-but-wrong-type value - /// causes a non-match (never a runtime error) consistent with fail-open. - typed_value: ?*const fn (ctx: *const anyopaque, field: FieldRef) ?TypedValue = null, - /// Returns true if the field is present. Uses the wired `exists` primitive - /// when available, otherwise falls back to `value != null`. + /// when available, otherwise falls back to `typed_value != null`. pub fn callExists(self: *const LogAccessor, ctx: *const anyopaque, field: FieldRef) bool { if (self.exists) |f| return f(ctx, field); - return self.value(ctx, field) != null; + return self.typed_value(ctx, field) != null; } }; /// Read+write interface to a metric record. pub const MetricAccessor = struct { - value: *const fn (ctx: *const anyopaque, field: MetricFieldRef) ?[]const u8, + /// Read a field as a typed value. See `LogAccessor.typed_value`. + typed_value: *const fn (ctx: *const anyopaque, field: MetricFieldRef) ?TypedValue, exists: ?*const fn (ctx: *const anyopaque, field: MetricFieldRef) bool = null, - /// Typed read for `equals`/`gt`/`gte`/`lt`/`lte` matchers. See `LogAccessor.typed_value`. - typed_value: ?*const fn (ctx: *const anyopaque, field: MetricFieldRef) ?TypedValue = null, - pub fn callExists(self: *const MetricAccessor, ctx: *const anyopaque, field: MetricFieldRef) bool { if (self.exists) |f| return f(ctx, field); - return self.value(ctx, field) != null; + return self.typed_value(ctx, field) != null; } }; @@ -403,26 +389,22 @@ pub const MetricAccessor = struct { /// writes when probabilistic sampling produces a threshold; consumers wire /// `set` to merge that threshold into their tracestate representation. pub const TraceAccessor = struct { - /// Read field as bytes for pattern matching; null when absent/non-string. + /// Read a field as a typed value. See `LogAccessor.typed_value`. /// - /// Exception — identifier fields (`TRACE_FIELD_TRACE_ID`, - /// `TRACE_FIELD_SPAN_ID`, `TRACE_FIELD_PARENT_SPAN_ID`): return the RAW id - /// bytes here, not null. The engine hex-renders them before matching and - /// consults this primitive, not `typed_value`, for string matchers. - /// Returning null hides the id from exact/contains/regex matching. - value: *const fn (ctx: *const anyopaque, field: TraceFieldRef) ?[]const u8, + /// Identifier fields (`TRACE_FIELD_TRACE_ID`, `TRACE_FIELD_SPAN_ID`, + /// `TRACE_FIELD_PARENT_SPAN_ID`): return `.bytes` with the RAW id bytes; + /// the engine hex-renders them for string matchers and uses the raw bytes + /// for sampling. + typed_value: *const fn (ctx: *const anyopaque, field: TraceFieldRef) ?TypedValue, exists: ?*const fn (ctx: *const anyopaque, field: TraceFieldRef) bool = null, /// Wiring this enables: trace sampling threshold writeback. set: ?*const fn (ctx: *anyopaque, field: TraceFieldRef, value: []const u8) void = null, - /// Typed read for `equals`/`gt`/`gte`/`lt`/`lte` matchers. See `LogAccessor.typed_value`. - typed_value: ?*const fn (ctx: *const anyopaque, field: TraceFieldRef) ?TypedValue = null, - pub fn callExists(self: *const TraceAccessor, ctx: *const anyopaque, field: TraceFieldRef) bool { if (self.exists) |f| return f(ctx, field); - return self.value(ctx, field) != null; + return self.typed_value(ctx, field) != null; } }; diff --git a/src/proto/tero/policy/v1.pb.zig b/src/proto/tero/policy/v1.pb.zig index 63b6899..3f05abd 100644 --- a/src/proto/tero/policy/v1.pb.zig +++ b/src/proto/tero/policy/v1.pb.zig @@ -122,9 +122,7 @@ pub const AttributePath = struct { } }; -/// Value carries a typed, non-string scalar for the `equals` matcher. String -/// equality is expressed with the `exact` match type, so this message -/// intentionally has no string variant. +/// Value carries a typed scalar for the `equals` matcher. /// /// `equals` matches when the field value has the same type and value as the set /// variant. Integer and floating-point values are compared in a single numeric @@ -139,14 +137,16 @@ pub const AttributePath = struct { /// equals: true -> bool_value /// equals: 200 -> int_value /// equals: 0.5 -> double_value +/// equals: "foo" -> string_value /// /// Bytes are authored either as proto-native base64 (`bytes_value`) or, more /// readably, as a lowercase-hex string (`hex_value`). The two are equivalent — /// hex_value is decoded to bytes at policy-compile time and yields the same bytes /// as the corresponding bytes_value — but hex_value keeps identifiers /// (trace/span ids) readable in the canonical proto/JSON form instead of base64. -/// A bare string literal (e.g. `equals: "foo"`) MUST be rejected — use `exact` -/// for strings. +/// +/// String equality SHOULD be authored with string_value. The older matcher-level +/// `exact` field is deprecated and will move to reserved in a future version. pub const Value = struct { value: ?value_union = null, @@ -156,6 +156,7 @@ pub const Value = struct { double_value, bytes_value, hex_value, + string_value, }; pub const value_union = union(_value_case) { bool_value: bool, @@ -163,12 +164,14 @@ pub const Value = struct { double_value: f64, bytes_value: []const u8, hex_value: []const u8, + string_value: []const u8, pub const _desc_table = .{ .bool_value = fd(1, .{ .scalar = .bool }), .int_value = fd(2, .{ .scalar = .int64 }), .double_value = fd(3, .{ .scalar = .double }), .bytes_value = fd(4, .{ .scalar = .bytes }), .hex_value = fd(5, .{ .scalar = .string }), + .string_value = fd(6, .{ .scalar = .string }), }; };